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::Objective;
556use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
557use crate::envelope::Campaign;
558use crate::registry::{AnchorRegistry, EffectRegistry, EntityRegistry, ItemRegistry};
559use crate::validate::{AnchorProviders, declares_bonfire, quest_ancestors, station_kind_diag};
560use std::collections::BTreeSet;
561
562crate::dw_code! {
563    /// (spec-0016 §1) A wave declares `respawns_on_rest: true` but the campaign
564    /// declares no `bonfire` — nothing can ever re-seat it, so the field is a
565    /// silent no-op. Either add the bonfire the re-seat is meant to hang off, or
566    /// drop the field.
567    pub const REST_RESEAT_NO_BONFIRE: DwCode = DwCode::new("DW0370", ExitTier::Build);
568}
569
570crate::dw_code! {
571    /// A `drops[]` `slot` entry does not
572    /// name a distinct slot the same entity's `equipment` actually fills — the
573    /// slot is empty, or the same slot is declared twice. A mob can only leave
574    /// behind a piece it wears, and it can only leave it behind once.
575    pub const DROP_SLOT_UNFILLED: DwCode = DwCode::new("DW0490", ExitTier::Build);
576}
577
578crate::dw_code! {
579    /// `drops[]` on an encounter that is
580    /// not billed `elite` or `boss`. Only a named fight leaves anything behind;
581    /// an ordinary mob's kit is never farmable (no-grind constitution), so the
582    /// declaration is refused rather than silently making rank-and-file gear
583    /// lootable.
584    pub const DROP_NOT_TIERED: DwCode = DwCode::new("DW0491", ExitTier::Build);
585}
586
587crate::dw_code! {
588    /// A `collect` `dropped_by` is not backed by the wave it names:
589    /// the wave declares no `{item}` drop of this objective's item, the count
590    /// asks for more copies than the wave's mobs can yield, or the objective
591    /// also declares a `container` (the item cannot come out of a box *and* off
592    /// a body).
593    pub const DROP_COLLECT_UNSOURCED: DwCode = DwCode::new("DW0492", ExitTier::Build);
594}
595
596crate::dw_code! {
597    /// A `collect` `dropped_by` is not ordered after the fight that
598    /// produces it: no `kill` objective for that wave precedes this collect in
599    /// the objective graph. Without that edge "kill the boss, take its key" is
600    /// an authoring intention the quest graph cannot prove, and the collect
601    /// reads as reachable from the campaign's first tick.
602    pub const DROP_COLLECT_UNORDERED: DwCode = DwCode::new("DW0493", ExitTier::Build);
603}
604
605crate::dw_code! {
606    /// (spec-0016 §6) A wave's TD `lane` / `summon` declaration is structurally
607    /// invalid or internally contradictory: an empty `waypoints` list, a
608    /// waypoint anchor no area's prefab provides, a repeated consecutive
609    /// waypoint, an `aggro_radius` outside `4..=64`, a mob whose
610    /// `attributes.follow_range` disagrees with `aggro_radius` (they MUST be
611    /// equal — a patrolling raider holds ground against a target it cannot
612    /// engage), or `lane` together with `summon: aggro-edge` (a lane IS the
613    /// routing; aggro-edge is its opposite).
614    pub const LANE_INVALID: DwCode = DwCode::new("DW0381", ExitTier::Build);
615}
616
617crate::dw_code! {
618    /// (spec-0016 §6) A lane wave contains a non-raider species. `Patrolling` /
619    /// `patrol_target` are Raider NBT: on anything else they are dropped and the
620    /// mob simply stands where it spawned. The admitted set is vanilla's own
621    /// `#minecraft:raiders` tag, read from the vendored tag table — never a
622    /// species list this engine keeps. Non-raiders use `summon: aggro-edge`
623    /// instead.
624    pub const LANE_NOT_RAIDER: DwCode = DwCode::new("DW0382", ExitTier::Build);
625}
626
627crate::dw_code! {
628    /// (spec-0016 §6) A lane wave fields fewer than 2 mobs. A lone patroller
629    /// sets `Patrolling:0b` on itself when it finds no companion within its
630    /// follow range (vanilla), so a one-mob lane cancels itself.
631    pub const LANE_SQUAD_TOO_SMALL: DwCode = DwCode::new("DW0383", ExitTier::Build);
632}
633
634crate::dw_code! {
635    /// (spec-0016 §6) A lane `pillager` is not holding a crossbow. Its only
636    /// attack goal is the crossbow goal, so a pillager that acquires a target it
637    /// has no runnable attack for freezes in place indefinitely — patrol blocked
638    /// by the target, nothing to run instead (live-verified deadlock).
639    pub const LANE_UNARMED: DwCode = DwCode::new("DW0384", ExitTier::Build);
640}
641
642crate::dw_code! {
643    /// (spec-0016 §6) A `summon: aggro-edge` wave mob declares no
644    /// `attributes.follow_range`. That radius IS the summon ring — the distance
645    /// at which the mob perceives the party — so it is authored, never guessed
646    /// from a vanilla defaults table the compiler cannot verify.
647    pub const AGGRO_EDGE_NO_RANGE: DwCode = DwCode::new("DW0385", ExitTier::Build);
648}
649
650crate::dw_code! {
651    /// (v0.6) A campaign stages actors meant to **fight** — unleashed, or
652    /// declared `vulnerable` — but declares no `world.difficulty`, and the
653    /// engine's derivation ([`crate::derived_difficulty`]) ships `peaceful`
654    /// because it fields no wave and stages no body peaceful discards. Every
655    /// such fighter is a species peaceful keeps, and on peaceful every hit it
656    /// lands on a player whose damage scales with difficulty — a mob's melee and
657    /// its projectiles — is zero. Advisory (warning, exit 0): declaring
658    /// `world.difficulty` settles it.
659    pub const DIFFICULTY_UNDECLARED_ACTORS: DwCode = DwCode::new("DW0469", ExitTier::Build);
660}
661
662crate::dw_code! {
663    /// (spec-0016 §1, spec-0023, souls ruling 5/7: "stage bosses never respawn
664    /// on rest") A wave declares BOTH `tier: boss` and `respawns_on_rest: true`.
665    /// `tier` and `respawns_on_rest` are two fields on the same [`Wave`]
666    /// declaration — the only place a "boss" billing and a "re-seat on rest"
667    /// contract can land on one another; an [`Actor`] carries `tier` too but has
668    /// no `respawns_on_rest` field at all (it is killed by hand, never re-seated
669    /// by a bonfire), so this is the sole structurally expressible violation of
670    /// the ruling. A rest-respawning boss re-fight breaks the retry economy the
671    /// ruling exists to protect: a boss is the campaign's named fight, not
672    /// trash pressure the party grinds back down every rest. Validation-tier
673    /// (exit 1), `dsl::validate`. Prescription: drop `tier: boss` if the
674    /// encounter really is meant to re-seat (bill it `elite` instead), or drop
675    /// `respawns_on_rest` if it really is the boss.
676    ///
677    /// [`Wave`]: crate::Wave
678    /// [`Actor`]: crate::Actor
679    pub const BOSS_RESPAWNS_ON_REST: DwCode = DwCode::new("DW0499", ExitTier::Build);
680}
681
682/// The vanilla `entity_type` tag whose members honour `Patrolling` /
683/// `patrol_target`: `#minecraft:raiders`.
684///
685/// On anything outside it the keys are inert — the mob stands where it spawned —
686/// which is the silent no-op class `DW0382` exists to make loud.
687///
688/// **The species list is Mojang's, never ours**, the same rule `DW0496` follows
689/// for `#minecraft:burn_in_daylight`. In the pinned game these are the same six
690/// types by two independent routes: the data branch publishes them as this tag,
691/// and the code branch makes exactly them subclasses of `PatrollingMonster` —
692/// whose own `registerGoals` adds the `LongDistancePatrolGoal` every one of them
693/// inherits. A hand-written table is how the two come apart, and had: it named
694/// five, omitting `minecraft:illusioner`, so a lane of illusioners was refused a
695/// march the game would have walked.
696const LANE_RAIDER_TAG: &str = "minecraft:raiders";
697
698/// Whether `entity` may be fielded in a lane — membership of [`LANE_RAIDER_TAG`].
699///
700/// `#minecraft:raiders` names only concrete types in the pinned game (no nested
701/// `#tag` member), so [`crate::registry::entity_in_tag`]'s deliberate
702/// non-expansion cannot narrow this set.
703fn is_lane_raider(entity: &str) -> bool {
704    crate::registry::entity_in_tag(entity, LANE_RAIDER_TAG)
705}
706
707/// Species whose ONLY attack goal is gated on holding a specific weapon: they
708/// acquire a target, find no runnable attack goal, and freeze — while the patrol
709/// goal stays blocked by the very target they cannot hit (`DW0384`). A pillager
710/// is a crossbow mob and nothing else, so this table has exactly one row.
711///
712/// The near miss is `minecraft:illusioner`, whose ranged goal is bow-gated the
713/// same way — but it also carries two spell goals that are gated on nothing but
714/// a target, so a bare-handed illusioner has something runnable and does not
715/// freeze. Every other raider melees or casts bare-handed.
716const LANE_WEAPON_GATED: [(&str, &str); 1] = [("pillager", "minecraft:crossbow")];
717
718/// The bare entity id (`minecraft:pillager` → `pillager`).
719fn bare_entity(id: &str) -> &str {
720    id.strip_prefix("minecraft:").unwrap_or(id)
721}
722
723/// The advisory half of the difficulty surface (`DW0469`): a campaign that
724/// stages a **fighting** actor, declares no `world.difficulty`, and is derived
725/// `peaceful` ([`crate::derived_difficulty`]: no wave, no staged body peaceful
726/// discards).
727///
728/// A fighter of a species peaceful discards never reaches this check: the
729/// derivation already ships `easy` for it. What remains is a fighter peaceful
730/// keeps — and on peaceful, `Player#hurtServer` scales every damage source that
731/// `scalesWithDifficulty()` (a living non-player attacker's melee and
732/// projectiles) to zero, so the fight lands no blow.
733///
734/// "Fighting" is read off the campaign's own declarations: an `unleash-actor`
735/// at any effect root ([`crate::fight::unleashed_actors`]) or `vulnerable: true`.
736pub(crate) fn difficulty_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
737    if c.world.content.difficulty.is_some()
738        || crate::derived_difficulty(c) != crate::WorldDifficulty::Peaceful
739    {
740        return;
741    }
742    let unleashed = crate::fight::unleashed_actors(c);
743    let fighters: Vec<String> = c
744        .quests
745        .content
746        .actors
747        .iter()
748        .filter(|a| a.vulnerable || unleashed.contains(a.id.as_str()))
749        .map(|a| format!("{} ({})", a.id.as_str(), a.entity))
750        .collect();
751    if fighters.is_empty() {
752        return;
753    }
754    d.push(Diagnostic::warning(
755        DIFFICULTY_UNDECLARED_ACTORS,
756        "world",
757        "/content/difficulty".to_string(),
758        format!(
759            "this campaign stages {} actor(s) meant to FIGHT — unleashed, or declared \
760             `vulnerable` — [{}], but declares no `world.difficulty`, and with no wave and no \
761             body peaceful discards the engine derives `difficulty=peaceful`. These species \
762             survive peaceful, but on peaceful every hit they land on a player whose damage \
763             scales with difficulty (a mob's melee and its projectiles) is zero, so the fight \
764             lands no blow. Declare `world.difficulty` on the world stage: `easy` halves \
765             incoming damage, `normal` is the vanilla baseline.",
766            fighters.len(),
767            fighters.join(", ")
768        ),
769    ));
770}
771
772/// Validate the spec-0016 §6 wave `lane` / `summon` surface.
773///
774/// Five rules, five codes, each pinned to a live-verified 1.21.11 failure mode:
775/// * `DW0381` — the declaration does not resolve or contradicts itself;
776/// * `DW0382` — a lane species outside the raider family (the NBT is inert);
777/// * `DW0383` — a lane squad below 2 (a lone patroller self-cancels);
778/// * `DW0384` — a lane pillager without its crossbow (target-acquisition deadlock);
779/// * `DW0385` — an aggro-edge mob with no authored `follow_range` (no ring radius).
780///
781/// Anchor resolution stays lenient for pool areas the compiler resolves later —
782/// the same policy as the trap, trigger and shortcut checks. Waypoint *geometry*
783/// (standable, reachable, spaced > 10) is a build-tier proof over the assembled
784/// world (`DW0386`), not a validation-tier one.
785pub(crate) fn lane_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
786    let quests = &c.quests.content;
787    if quests
788        .waves
789        .iter()
790        .all(|w| w.lane.is_none() && w.summon.is_none())
791    {
792        return;
793    }
794    let providers = AnchorProviders::build(c, anchors);
795
796    for (i, w) in quests.waves.iter().enumerate() {
797        let aggro_edge = w.summon == Some(crate::WaveSummon::AggroEdge);
798        if aggro_edge {
799            if w.lane.is_some() {
800                d.push(Diagnostic::error(
801                    LANE_INVALID,
802                    "quests",
803                    format!("/content/waves/{i}/summon"),
804                    format!(
805                        "wave `{}` declares BOTH a `lane` and `summon: aggro-edge` (spec-0016 §6) \
806                         — a lane IS the routing (march while distant, native AI once aggroed), \
807                         and aggro-edge is its opposite (materialize already at the edge of \
808                         perception, no routing at all). Pick one.",
809                        w.id
810                    ),
811                ));
812            }
813            for (k, m) in w.mobs.iter().enumerate() {
814                if m.attributes.and_then(|a| a.follow_range).is_none() {
815                    d.push(Diagnostic::error(
816                        AGGRO_EDGE_NO_RANGE,
817                        "quests",
818                        format!("/content/waves/{i}/mobs/{k}/attributes"),
819                        format!(
820                            "`summon: aggro-edge` mob `{}` in wave `{}` declares no \
821                             `attributes.follow_range` (spec-0016 §6). That radius IS the summon \
822                             ring — the distance at which this mob perceives the party — so it is \
823                             authored, never guessed: the compiler will not fabricate a vanilla \
824                             default it cannot verify against the pinned server.",
825                            m.entity, w.id
826                        ),
827                    ));
828                }
829            }
830        }
831        let Some(lane) = &w.lane else { continue };
832
833        if lane.waypoints.is_empty() {
834            d.push(Diagnostic::error(
835                LANE_INVALID,
836                "quests",
837                format!("/content/waves/{i}/lane/waypoints"),
838                format!(
839                    "wave `{}` declares a `lane` with no waypoints (spec-0016 §6) — a lane is a \
840                     polyline the squad marches; give it at least one waypoint anchor",
841                    w.id
842                ),
843            ));
844        }
845        for (k, wp) in lane.waypoints.iter().enumerate() {
846            if let Some(f) = station_kind_diag(
847                &providers,
848                wp.as_str(),
849                crate::layout::StationKind::Point,
850                "a lane waypoint",
851                "quests",
852                format!("/content/waves/{i}/lane/waypoints/{k}"),
853            ) {
854                d.push(f);
855            }
856            if !providers.resolvable(wp.as_str()) {
857                d.push(Diagnostic::error(
858                    LANE_INVALID,
859                    "quests",
860                    format!("/content/waves/{i}/lane/waypoints/{k}"),
861                    format!(
862                        "lane waypoint anchor `{wp}` is not provided by any area's prefab — {}",
863                        providers.anchor_remedy(
864                            "use an anchor a prefab exposes (anchor names come from prefab \
865                             metadata; do NOT invent one)"
866                        ),
867                    ),
868                ));
869            }
870            if k > 0 && lane.waypoints[k - 1] == *wp {
871                d.push(Diagnostic::error(
872                    LANE_INVALID,
873                    "quests",
874                    format!("/content/waves/{i}/lane/waypoints/{k}"),
875                    format!(
876                        "lane waypoint `{wp}` repeats the one before it — the squad would be told \
877                         to march to where it already stands, and vanilla re-rolls a patrol \
878                         target on arrival. Remove the repeat."
879                    ),
880                ));
881            }
882        }
883        if !(4..=64).contains(&lane.aggro_radius) {
884            d.push(Diagnostic::error(
885                LANE_INVALID,
886                "quests",
887                format!("/content/waves/{i}/lane/aggro_radius"),
888                format!(
889                    "lane `aggro_radius` {} on wave `{}` is outside 4..=64 (spec-0016 §6). It is \
890                     emitted verbatim as the mobs' `follow_range` attribute AND as the release \
891                     radius; below 4 the squad walks into contact before it can see anyone, and \
892                     past 64 it aggroes across the whole delve.",
893                    lane.aggro_radius, w.id
894                ),
895            ));
896        }
897        if w.mobs.iter().map(|m| m.count).sum::<u32>() < 2 {
898            d.push(Diagnostic::error(
899                LANE_SQUAD_TOO_SMALL,
900                "quests",
901                format!("/content/waves/{i}/mobs"),
902                format!(
903                    "lane wave `{}` fields fewer than 2 mobs (spec-0016 §6). A lone patroller \
904                     sets `Patrolling:0b` on ITSELF when it finds no companion within its follow \
905                     range — vanilla behaviour, live-verified — so a one-mob lane cancels its own \
906                     routing and just stands there. Field a squad of at least 2.",
907                    w.id
908                ),
909            ));
910        }
911        for (k, m) in w.mobs.iter().enumerate() {
912            let bare = bare_entity(&m.entity);
913            if !is_lane_raider(&m.entity) {
914                d.push(Diagnostic::error(
915                    LANE_NOT_RAIDER,
916                    "quests",
917                    format!("/content/waves/{i}/mobs/{k}/entity"),
918                    format!(
919                        "lane wave `{}` fields `{}`, which is not raider-family (spec-0016 §6). \
920                         `Patrolling`/`patrol_target` are Raider NBT: on any other species they \
921                         are simply dropped and the mob stands where it spawned. Lane species \
922                         (vanilla's own `#{LANE_RAIDER_TAG}` tag): {}. For anything else use \
923                         `summon: aggro-edge`, which needs no patrol AI.",
924                        w.id,
925                        m.entity,
926                        crate::registry::entity_tag_members_bare(LANE_RAIDER_TAG).join(" / ")
927                    ),
928                ));
929            }
930            if let Some((_, weapon)) = LANE_WEAPON_GATED.iter().find(|(s, _)| *s == bare) {
931                let held = m
932                    .equipment
933                    .as_ref()
934                    .and_then(|e| e.main_hand.as_ref())
935                    .map_or(*weapon, |p| p.item());
936                if held != *weapon {
937                    d.push(Diagnostic::error(
938                        LANE_UNARMED,
939                        "quests",
940                        format!("/content/waves/{i}/mobs/{k}/equipment/main_hand"),
941                        format!(
942                            "lane `{bare}` in wave `{}` holds `{held}` instead of `{weapon}` \
943                             (spec-0016 §6). Its ONLY attack goal is the crossbow goal, so on \
944                             acquiring a target it has nothing runnable to do — and the patrol \
945                             goal is meanwhile blocked BY that target. The mob freezes in place \
946                             indefinitely (live-verified deadlock). Give it the crossbow, or drop \
947                             the `main_hand` override and take the compiler's default.",
948                            w.id
949                        ),
950                    ));
951                }
952            }
953        }
954        if let Some(bad) = w.mobs.iter().enumerate().find(|(_, m)| {
955            m.attributes
956                .and_then(|a| a.follow_range)
957                .is_some_and(|r| r != f64::from(lane.aggro_radius))
958        }) {
959            let (k, m) = bad;
960            d.push(Diagnostic::error(
961                LANE_INVALID,
962                "quests",
963                format!("/content/waves/{i}/mobs/{k}/attributes/follow_range"),
964                format!(
965                    "lane mob `{}` in wave `{}` declares `follow_range` {} but the lane's \
966                     `aggro_radius` is {} (spec-0016 §6). They MUST be equal: the release radius \
967                     is where routing hands over to native AI, and a patrolling raider that \
968                     targets a player outside its engagement range HOLDS GROUND instead of \
969                     marching — the squad stalls mid-lane. Drop the override (the compiler sets \
970                     `follow_range` from `aggro_radius`) or make the two agree.",
971                    m.entity,
972                    w.id,
973                    m.attributes
974                        .and_then(|a| a.follow_range)
975                        .unwrap_or_default(),
976                    lane.aggro_radius
977                ),
978            ));
979        }
980    }
981}
982
983/// Declared drops: what an elite or boss
984/// leaves behind is a **declared subset**, never automatically everything.
985///
986/// Four rules, all of them about the gap between what a campaign says and what
987/// the world can actually produce:
988///
989/// * `DW0491` — only an `elite`/`boss` encounter may declare drops. Rank-and-file
990///   gear stays unfarmable by construction (no-grind constitution).
991/// * `DW0490` — a `slot` entry must name a **distinct** slot the same entity's
992///   own `equipment` fills. A body cannot leave behind a piece it never wore.
993/// * `DW0492` — a `dropped_by` collect must be backed by the wave it names: the
994///   wave really declares that item, in at least the count the objective asks
995///   for, and the objective does not also adopt a container.
996/// * `DW0493` — that collect must be **ordered after** the fight, so the chain
997///   "kill the boss → take its key → open the door" is a proof rather than an
998///   intention.
999pub(crate) fn check_drops(
1000    c: &Campaign,
1001    quests: &crate::QuestsContent,
1002    items: &dyn ItemRegistry,
1003    d: &mut Vec<Diagnostic>,
1004) {
1005    use crate::{EncounterTier, MobDrop};
1006
1007    // --- the declaration side: waves and actors ---------------------------
1008    let tiered =
1009        |t: Option<EncounterTier>| matches!(t, Some(EncounterTier::Elite | EncounterTier::Boss));
1010    for (i, w) in quests.waves.iter().enumerate() {
1011        for (k, m) in w.mobs.iter().enumerate() {
1012            if m.drops.is_empty() {
1013                continue;
1014            }
1015            if !tiered(w.tier) {
1016                d.push(Diagnostic::error(
1017                    DROP_NOT_TIERED,
1018                    "quests",
1019                    format!("/content/waves/{i}/mobs/{k}/drops"),
1020                    format!(
1021                        "wave `{}` declares drops but is not billed `elite` or `boss` — only a \
1022                         named fight leaves anything behind; an ordinary mob's kit is never \
1023                         farmable. Declare the wave's `tier`, or remove the `drops`",
1024                        w.id
1025                    ),
1026                ));
1027            }
1028            check_drop_list(
1029                &m.drops,
1030                m.equipment.as_ref(),
1031                &format!("wave `{}` mob {k}", w.id),
1032                &format!("/content/waves/{i}/mobs/{k}/drops"),
1033                items,
1034                d,
1035            );
1036        }
1037    }
1038    for (i, a) in quests.actors.iter().enumerate() {
1039        if a.drops.is_empty() {
1040            continue;
1041        }
1042        if !tiered(a.tier) {
1043            d.push(Diagnostic::error(
1044                DROP_NOT_TIERED,
1045                "quests",
1046                format!("/content/actors/{i}/drops"),
1047                format!(
1048                    "actor `{}` declares drops but is not billed `elite` or `boss` — only a named \
1049                     fight leaves anything behind; a staged puppet's kit is never farmable. \
1050                     Declare the actor's `tier`, or remove the `drops`",
1051                    a.id
1052                ),
1053            ));
1054        }
1055        check_drop_list(
1056            &a.drops,
1057            a.equipment.as_ref(),
1058            &format!("actor `{}`", a.id),
1059            &format!("/content/actors/{i}/drops"),
1060            items,
1061            d,
1062        );
1063    }
1064
1065    // --- the consumption side: `collect.dropped_by` ------------------------
1066    // How many copies of each item every wave can yield: one per declaring mob
1067    // in the stack, so a pair of elites each dropping a sword yields two.
1068    let mut yielded: BTreeMap<&str, BTreeMap<&str, u32>> = BTreeMap::new();
1069    for w in &quests.waves {
1070        let per = yielded.entry(w.id.as_str()).or_default();
1071        for m in &w.mobs {
1072            for dr in &m.drops {
1073                if let MobDrop::Item(it) = dr {
1074                    *per.entry(it.item.as_str()).or_default() += m.count;
1075                }
1076            }
1077        }
1078    }
1079    let anc = quest_ancestors(c);
1080    // Which quests hold a `kill` objective for each wave, and which objective ids.
1081    let mut kills: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
1082    for q in &quests.quests {
1083        for o in &q.objectives {
1084            if let Objective::Kill { wave, id, .. } = o {
1085                kills
1086                    .entry(wave.as_str())
1087                    .or_default()
1088                    .push((q.id.as_str(), id.as_str()));
1089            }
1090        }
1091    }
1092    for (i, q) in quests.quests.iter().enumerate() {
1093        let after_anc = objective_ancestors(q);
1094        for (j, o) in q.objectives.iter().enumerate() {
1095            let Objective::Collect {
1096                id,
1097                item,
1098                count,
1099                container,
1100                dropped_by: Some(wave),
1101                ..
1102            } = o
1103            else {
1104                continue;
1105            };
1106            let path = format!("/content/quests/{i}/objectives/{j}/dropped_by");
1107            if container.is_some() {
1108                d.push(Diagnostic::error(
1109                    DROP_COLLECT_UNSOURCED,
1110                    "quests",
1111                    path.clone(),
1112                    format!(
1113                        "`collect` `{id}` declares both `dropped_by` (wave `{wave}`) and a \
1114                         `container` — the item comes off a body or out of a box, not both; drop \
1115                         whichever provisioning this beat does not use"
1116                    ),
1117                ));
1118            }
1119            let Some(per) = yielded.get(wave.as_str()) else {
1120                // Unknown wave: the ordinary dangling-reference diagnostic
1121                // (`DW0170`) already names it; nothing to add here.
1122                continue;
1123            };
1124            match per.get(item.as_str()).copied() {
1125                None => d.push(Diagnostic::error(
1126                    DROP_COLLECT_UNSOURCED,
1127                    "quests",
1128                    path.clone(),
1129                    format!(
1130                        "`collect` `{id}` takes `{item}` off wave `{wave}`, but no mob of that \
1131                         wave declares a `{{\"item\": \"{item}\"}}` drop — {}. Declare the drop on \
1132                         the wave's mob, or point `dropped_by` at the wave that really carries it",
1133                        if per.is_empty() {
1134                            "the wave declares no item drops at all".to_string()
1135                        } else {
1136                            format!(
1137                                "it declares {}",
1138                                per.keys().cloned().collect::<Vec<_>>().join(", ")
1139                            )
1140                        }
1141                    ),
1142                )),
1143                Some(have) if have < *count => d.push(Diagnostic::error(
1144                    DROP_COLLECT_UNSOURCED,
1145                    "quests",
1146                    path.clone(),
1147                    format!(
1148                        "`collect` `{id}` asks for {count} × `{item}`, but wave `{wave}` yields \
1149                         only {have} — a body drops its declared item once. Lower the `count`, or \
1150                         raise the declaring mob's `count`"
1151                    ),
1152                )),
1153                Some(_) => {}
1154            }
1155            // The ordering proof: some `kill` objective for this wave must
1156            // strictly precede this collect — in the same quest through the
1157            // `after` graph, or in a quest this one transitively depends on.
1158            let ordered = kills.get(wave.as_str()).is_some_and(|ks| {
1159                ks.iter().any(|(kq, ko)| {
1160                    if *kq == q.id.as_str() {
1161                        after_anc
1162                            .get(id.as_str())
1163                            .is_some_and(|set| set.contains(ko))
1164                    } else {
1165                        anc.get(q.id.as_str()).is_some_and(|set| set.contains(kq))
1166                    }
1167                })
1168            });
1169            if !ordered {
1170                d.push(Diagnostic::error(
1171                    DROP_COLLECT_UNORDERED,
1172                    "quests",
1173                    path,
1174                    format!(
1175                        "`collect` `{id}` takes `{item}` off wave `{wave}`, but no `kill` \
1176                         objective for `{wave}` is proven to run first — the item would be \
1177                         unreachable while the objective reads as active from the campaign's \
1178                         first tick. Add a `kill` objective for `{wave}` and list it in this \
1179                         objective's `after`, or put the kill in a quest this one `depends_on`"
1180                    ),
1181                ));
1182            }
1183        }
1184    }
1185}
1186
1187/// One entity's `drops[]` list: distinct, really-worn slots (`DW0490`) and
1188/// registry-valid quest items (`DW0143`). Shared by wave mobs and actors so the
1189/// two surfaces cannot drift.
1190fn check_drop_list(
1191    drops: &[crate::MobDrop],
1192    equipment: Option<&crate::MobEquipment>,
1193    what: &str,
1194    base_path: &str,
1195    items: &dyn ItemRegistry,
1196    d: &mut Vec<Diagnostic>,
1197) {
1198    use crate::MobDrop;
1199
1200    let mut seen_slots: BTreeSet<&'static str> = BTreeSet::new();
1201    for (n, dr) in drops.iter().enumerate() {
1202        match dr {
1203            MobDrop::Slot(s) => {
1204                let field = s.slot.field();
1205                let filled: Vec<&str> = equipment
1206                    .map(|eq| {
1207                        eq.slots()
1208                            .into_iter()
1209                            .filter(|(_, p)| p.is_some())
1210                            .map(|(name, _)| name)
1211                            .collect()
1212                    })
1213                    .unwrap_or_default();
1214                if equipment.is_none_or(|eq| eq.filled(s.slot).is_none()) {
1215                    d.push(Diagnostic::error(
1216                        DROP_SLOT_UNFILLED,
1217                        "quests",
1218                        format!("{base_path}/{n}/slot"),
1219                        format!(
1220                            "{what} declares a `{field}` drop, but its `equipment` puts nothing \
1221                             in `{field}` — {}. A body can only leave behind a piece it wears: \
1222                             equip the slot, or drop a slot it fills",
1223                            if filled.is_empty() {
1224                                "it declares no equipment at all".to_string()
1225                            } else {
1226                                format!("it fills {}", filled.join(", "))
1227                            }
1228                        ),
1229                    ));
1230                } else if !seen_slots.insert(field) {
1231                    d.push(Diagnostic::error(
1232                        DROP_SLOT_UNFILLED,
1233                        "quests",
1234                        format!("{base_path}/{n}/slot"),
1235                        format!(
1236                            "{what} declares the `{field}` drop twice — a body leaves each piece \
1237                             behind once; remove the duplicate entry"
1238                        ),
1239                    ));
1240                }
1241            }
1242            MobDrop::Item(it) => {
1243                if !items.contains(&it.item) {
1244                    d.push(Diagnostic::error(
1245                        codes::ITEM_UNKNOWN,
1246                        "quests",
1247                        format!("{base_path}/{n}/item"),
1248                        format!(
1249                            "{what} declares a drop of `{}`, which is not in the pinned 1.21.11 \
1250                             item registry — use a valid namespaced item id (e.g. \
1251                             `minecraft:tripwire_hook`)",
1252                            it.item
1253                        ),
1254                    ));
1255                }
1256            }
1257        }
1258    }
1259}
1260
1261/// Per-objective transitive `after` ancestors within one quest: `obj -> {every
1262/// objective that must complete before it}`. Acyclicity is guaranteed by
1263/// `DW0140`; a cyclic quest simply yields a partial set and the cycle's own
1264/// diagnostic fires.
1265fn objective_ancestors(q: &crate::Quest) -> BTreeMap<&str, BTreeSet<&str>> {
1266    let direct: BTreeMap<&str, Vec<&str>> = q
1267        .objectives
1268        .iter()
1269        .map(|o| {
1270            (
1271                o.id().as_str(),
1272                o.after().iter().map(|a| a.as_str()).collect::<Vec<_>>(),
1273            )
1274        })
1275        .collect();
1276    let mut out = BTreeMap::new();
1277    for o in &q.objectives {
1278        let mut anc: BTreeSet<&str> = BTreeSet::new();
1279        let mut stack = vec![o.id().as_str()];
1280        while let Some(cur) = stack.pop() {
1281            if let Some(ds) = direct.get(cur) {
1282                for dep in ds {
1283                    if anc.insert(dep) {
1284                        stack.push(dep);
1285                    }
1286                }
1287            }
1288        }
1289        out.insert(o.id().as_str(), anc);
1290    }
1291    out
1292}
1293
1294/// Validate one [`MobEquipment`] block — item ids against the pinned registry
1295/// (`DW0143`) and every piece's enchantments against the pinned enchantment
1296/// registry (`DW0433`) and level range (`DW0434`).
1297///
1298/// Shared verbatim by wave mobs and actors so the two surfaces cannot drift:
1299/// they are the same schema type and therefore must be the same rules.
1300pub(crate) fn check_equipment(
1301    eq: &crate::MobEquipment,
1302    what: &str,
1303    base_path: &str,
1304    items: &dyn ItemRegistry,
1305    d: &mut Vec<Diagnostic>,
1306) {
1307    let ench_reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
1308    for (slot, piece) in eq.slots() {
1309        let Some(piece) = piece else { continue };
1310        let it = piece.item();
1311        if !items.contains(it) {
1312            d.push(Diagnostic::error(
1313                codes::ITEM_UNKNOWN,
1314                "quests",
1315                format!("{base_path}/{slot}"),
1316                format!(
1317                    "{what} equipment `{slot}` item `{it}` is not in the pinned 1.21.11 \
1318                     item registry — use a valid namespaced item id (e.g. \
1319                     `minecraft:iron_helmet`)"
1320                ),
1321            ));
1322        }
1323        check_enchantments(
1324            piece.enchantments(),
1325            &format!("{what} equipment `{slot}`"),
1326            "quests",
1327            &format!("{base_path}/{slot}/enchantments"),
1328            &ench_reg,
1329            d,
1330        );
1331    }
1332}
1333
1334/// Wave-mob `equipment` (spec-0014): item ids and enchantments
1335/// ([`check_equipment`]).
1336pub(crate) fn wave_equipment_checks(
1337    c: &Campaign,
1338    items: &dyn ItemRegistry,
1339    d: &mut Vec<Diagnostic>,
1340) {
1341    let quests = &c.quests.content;
1342    // Wave-mob `equipment` item ids: every present slot must name a
1343    // pinned-1.21.11 item — the same registry and DW family as `give-item`
1344    // (`DW0143`).
1345    for (i, w) in quests.waves.iter().enumerate() {
1346        for (k, m) in w.mobs.iter().enumerate() {
1347            let Some(eq) = &m.equipment else { continue };
1348            check_equipment(
1349                eq,
1350                "wave-mob",
1351                &format!("/content/waves/{i}/mobs/{k}/equipment"),
1352                items,
1353                d,
1354            );
1355        }
1356    }
1357}
1358
1359/// A wave's `respawns_on_rest` (spec-0016 §1): inert with no `bonfire`
1360/// (`DW0370`), and forbidden on a `boss` (`DW0499`).
1361pub(crate) fn rest_reseat_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
1362    let quests = &c.quests.content;
1363    // spec-0016 §1: `respawns_on_rest` is re-seating *by a bonfire*. With no
1364    // `bonfire` anywhere in the campaign nothing can ever fire the re-seat, so
1365    // the field is a silent no-op — the class of defect this compiler always
1366    // turns loud (`DW0370`).
1367    let has_bonfire = declares_bonfire(c);
1368    if !has_bonfire {
1369        for (i, w) in quests.waves.iter().enumerate() {
1370            if w.respawns_on_rest {
1371                d.push(Diagnostic::error(
1372                    REST_RESEAT_NO_BONFIRE,
1373                    "quests",
1374                    format!("/content/waves/{i}/respawns_on_rest"),
1375                    format!(
1376                        "wave `{}` declares `respawns_on_rest: true` but this campaign declares \
1377                         no `bonfire` — nothing can ever re-seat it, so the field is inert. Add \
1378                         the `bonfire` the re-seat hangs off (spec-0016 §1), or drop the field; \
1379                         do NOT leave a silently dead declaration in the DSL.",
1380                        w.id.as_str()
1381                    ),
1382                ));
1383            }
1384        }
1385    }
1386
1387    // spec-0016 §1 + spec-0023, souls ruling 5/7 ("stage bosses never respawn
1388    // on rest"): `tier` and
1389    // `respawns_on_rest` are two fields on the SAME wave declaration — the only
1390    // place a "boss" billing and a "re-seat on rest" contract can land on one
1391    // another (an actor carries `tier` too, but has no `respawns_on_rest` field
1392    // at all, so it cannot express this violation). A rest-respawning boss
1393    // re-fight breaks the retry economy the ruling protects. Checked
1394    // unconditionally of `has_bonfire`: the combination is forbidden on its own
1395    // terms, not merely inert like `DW0370`.
1396    for (i, w) in quests.waves.iter().enumerate() {
1397        if w.respawns_on_rest && w.tier == Some(EncounterTier::Boss) {
1398            d.push(Diagnostic::error(
1399                BOSS_RESPAWNS_ON_REST,
1400                "quests",
1401                format!("/content/waves/{i}/respawns_on_rest"),
1402                format!(
1403                    "wave `{}` declares `tier: boss` AND `respawns_on_rest: true` — souls \
1404                     ruling 5/7 is that stage bosses never respawn on rest, since a \
1405                     rest-respawning boss re-fight breaks the retry economy the ruling \
1406                     protects. Drop `respawns_on_rest` if this really is the boss, or drop \
1407                     `tier: boss` (bill it `elite` instead) if the encounter is meant to \
1408                     re-seat.",
1409                    w.id.as_str()
1410                ),
1411            ));
1412        }
1413    }
1414}
1415
1416/// Wave declarations (spec-0001 v0.3): id syntax (`DW0110`), uniqueness
1417/// (`DW0111`) and every mob's entity id (`DW0173`).
1418pub(crate) fn wave_decl_checks(
1419    c: &Campaign,
1420    entities: &dyn EntityRegistry,
1421    d: &mut Vec<Diagnostic>,
1422) {
1423    let quests = &c.quests.content;
1424
1425    // Wave declarations.
1426    let mut seen_waves: BTreeSet<&str> = BTreeSet::new();
1427    for (i, w) in quests.waves.iter().enumerate() {
1428        if !w.id.is_valid_syntax() {
1429            d.push(Diagnostic::error(
1430                codes::ID_SYNTAX,
1431                "quests",
1432                format!("/content/waves/{i}/id"),
1433                format!(
1434                    "malformed wave id `{}` — wave ids must be lowercase kebab-case with the \
1435                     `wave/` prefix (e.g. `wave/ambush`)",
1436                    w.id
1437                ),
1438            ));
1439        }
1440        if !seen_waves.insert(w.id.as_str()) {
1441            d.push(Diagnostic::error(
1442                codes::ID_DUPLICATE,
1443                "quests",
1444                format!("/content/waves/{i}/id"),
1445                format!(
1446                    "duplicate wave id `{}` — rename one so every wave id is unique",
1447                    w.id
1448                ),
1449            ));
1450        }
1451        for (k, m) in w.mobs.iter().enumerate() {
1452            if !entities.contains(&m.entity) {
1453                d.push(Diagnostic::error(
1454                    codes::ENTITY_UNKNOWN,
1455                    "quests",
1456                    format!("/content/waves/{i}/mobs/{k}/entity"),
1457                    format!(
1458                        "wave-mob entity `{}` is not a known 1.21.11 entity id — use a valid \
1459                         namespaced entity id (e.g. `minecraft:zombie`)",
1460                        m.entity
1461                    ),
1462                ));
1463            }
1464        }
1465    }
1466}
1467
1468/// Every wave mob's status effects name a 1.21.11 effect (`DW0192`).
1469pub(crate) fn mob_effect_checks(
1470    c: &Campaign,
1471    effects: &dyn EffectRegistry,
1472    d: &mut Vec<Diagnostic>,
1473) {
1474    let quests = &c.quests.content;
1475
1476    // --- wave-mob effects + attributes ---
1477    for (i, w) in quests.waves.iter().enumerate() {
1478        for (k, m) in w.mobs.iter().enumerate() {
1479            for (e, eff) in m.effects.iter().enumerate() {
1480                if !effects.contains(&eff.effect) {
1481                    d.push(Diagnostic::error(
1482                        codes::EFFECT_UNKNOWN,
1483                        "quests",
1484                        format!("/content/waves/{i}/mobs/{k}/effects/{e}/effect"),
1485                        format!(
1486                            "wave-mob effect `{}` is not a known 1.21.11 status-effect id — use a \
1487                             valid namespaced effect id (e.g. `minecraft:strength`)",
1488                            eff.effect
1489                        ),
1490                    ));
1491                }
1492            }
1493        }
1494    }
1495}
1496
1497// ---------------------------------------------------------------------------
1498// Validation
1499// ---------------------------------------------------------------------------
1500
1501crate::dw_code! {
1502    /// (spec-0021) An `equipment` or `loot` enchantment id is not in the pinned
1503    /// 1.21.11 enchantment registry.
1504    pub const ENCHANTMENT_UNKNOWN: DwCode = DwCode::new("DW0433", ExitTier::Build);
1505}
1506
1507crate::dw_code! {
1508    /// (spec-0021) An enchantment level is outside the 1..=255 range vanilla's
1509    /// `minecraft:enchantments` component can carry.
1510    pub const ENCHANTMENT_LEVEL: DwCode = DwCode::new("DW0434", ExitTier::Build);
1511}
1512
1513/// Validate an enchantment map: known ids (`DW0433`), legal levels (`DW0434`).
1514///
1515/// Levels are checked against what the `minecraft:enchantments` **component**
1516/// can carry (1..=255), not against each enchantment's survival max. Exceeding
1517/// the survival max from a command is legal vanilla and is a legitimate way to
1518/// build a set-piece elite, so refusing it would be the compiler overruling a
1519/// design decision it cannot second-guess; 0 and >255 are simply not
1520/// representable and would be silently dropped by the game.
1521pub(crate) fn check_enchantments(
1522    ench: &std::collections::BTreeMap<String, u32>,
1523    what: &str,
1524    stage: &'static str,
1525    path: &str,
1526    reg: &dyn crate::registry::EnchantmentRegistry,
1527    d: &mut Vec<Diagnostic>,
1528) {
1529    for (id, level) in ench {
1530        if !reg.contains(id) {
1531            d.push(Diagnostic::error(
1532                ENCHANTMENT_UNKNOWN,
1533                stage,
1534                format!("{path}/{id}"),
1535                format!(
1536                    "{what} enchantment `{id}` is not in the pinned 1.21.11 enchantment \
1537                     registry — use a valid namespaced enchantment id (e.g. \
1538                     `minecraft:protection`, `minecraft:sharpness`). Note the vanilla \
1539                     ids for curses are `minecraft:binding_curse` and \
1540                     `minecraft:vanishing_curse`, NOT `curse_of_binding`."
1541                ),
1542            ));
1543        }
1544        if *level == 0 || *level > 255 {
1545            d.push(Diagnostic::error(
1546                ENCHANTMENT_LEVEL,
1547                stage,
1548                format!("{path}/{id}"),
1549                format!(
1550                    "{what} enchantment `{id}` has level {level}, outside the 1..=255 range \
1551                     the `minecraft:enchantments` component stores. Levels above an \
1552                     enchantment's survival maximum ARE allowed (that is how a set-piece \
1553                     elite is built) — but 0 means \"not enchanted\" and is silently \
1554                     dropped by the game, so declare the level you want or remove the entry."
1555                ),
1556            ));
1557        }
1558    }
1559}