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}