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