Skip to main content

delvewright_dsl/
stages.rs

1//! serde types for the six stage `content` payloads (spec-0001 v0.2).
2//!
3//! Every struct is `deny_unknown_fields`. Reserved enum values (npc `role`,
4//! objective/effect types) parse successfully but are rejected by validation
5//! ([`crate::validate`]) with code `DW0141`.
6
7use std::collections::BTreeMap;
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::ids::{
13    ActorId, AmbushId, AnchorId, AreaId, BranchId, BranchPointId, ClassId, DialogueId, EditBatchId,
14    EndingId, FlagId, LootId, NpcId, ObjectiveId, PoolId, PrefabId, QuestId, RegionId, ShortcutId,
15    TimedGateId, TrapId, TriggerId, WaveId,
16};
17
18/// serde default helper: `true` (used by DSL v0.4 `trigger.once`).
19fn default_true() -> bool {
20    true
21}
22
23/// serde `skip_serializing_if` helper: skip a `false` bool (DSL v0.4
24/// `objective.stealth`), keeping older campaigns byte-identical.
25fn is_false(b: &bool) -> bool {
26    !*b
27}
28
29// ---------------------------------------------------------------------------
30// Stage 1 — world
31// ---------------------------------------------------------------------------
32
33/// Stage 1 payload: setting, seed and the areas that make up the delve.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
35#[serde(deny_unknown_fields)]
36pub struct WorldContent {
37    /// Player-facing delve title.
38    pub title: String,
39    /// One-line thematic description.
40    pub theme: String,
41    /// Short narrative premise.
42    pub premise: String,
43    /// The single downstream randomness source (ADR-0006).
44    pub seed: u64,
45    /// Informational pacing target in minutes (v0: not enforced).
46    pub target_minutes: u32,
47    /// 1..N areas; each binds exactly one of `prefab` / `prefab_pool`.
48    pub areas: Vec<Area>,
49    /// Additional author-declared translation languages (BCP-47-style codes, e.g.
50    /// `["zh-cn"]`). English (`en`) is implicit, always canonical, and is **never**
51    /// listed here (spec-0001 i18n addendum). Absent or empty = English-only. Every
52    /// declared language must ship a fully-covering `l10n/<code>.json` sidecar
53    /// (`DW0180`/`DW0181`). Stage docs themselves stay pure English.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub languages: Vec<String>,
56    /// Declared initial world time (DSL v0.5, spec-0010). Dimension-global; frozen
57    /// by environment sealing (`advance_time false`) so the set state persists.
58    /// Absent = `noon` (the v0 default). Affects sky attenuation in the compiler's
59    /// assembled-light model.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub time: Option<WorldTime>,
62    /// Declared initial weather (DSL v0.5, spec-0010). Dimension-global; frozen by
63    /// environment sealing (`advance_weather false`). Absent = `clear`. Rain and
64    /// thunder attenuate effective sky brightness in the assembled-light model.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub weather: Option<WorldWeather>,
67    /// Declared combat difficulty (DSL v0.6, owner ruling 2026-08-03). Absent =
68    /// the compiler's historical derivation — `easy` when the campaign fields any
69    /// wave, `peaceful` when it fields none — which is what keeps every campaign
70    /// written before this field byte-identical. Declaring it overrides the
71    /// derivation for **both** the shipped `server.properties` and a `/difficulty`
72    /// in the sealing baseline, so the declaration also holds when the datapack is
73    /// dropped into somebody else's world.
74    ///
75    /// `peaceful` is rejected (`DW0468`). Raising difficulty changes the damage
76    /// players take — easy halves it — so combat arithmetic tuned under the old
77    /// implicit `easy` must be redone.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub difficulty: Option<WorldDifficulty>,
80    /// Scenic horizon (DSL v0.6, spec-0013). Absent or `void` = the void world
81    /// (byte-identical to v0.5). `ocean` swaps the world generator for a
82    /// deterministic superflat sea (bedrock/stone/water, sea level y=62) and drops
83    /// the area datum to y=60 (`sea_level-2`) so island pieces meet the sea at their
84    /// authored waterline. No structures or mobs either way.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub horizon: Option<Horizon>,
87    /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
88    /// derives a region from the placed geometry and a per-second clock returns any
89    /// player who leaves it to the last checkpoint. Required when `horizon` is
90    /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub boundary: Option<Boundary>,
93    /// The closing line on the campaign-completion advancement — the last
94    /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
95    /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
96    /// finale quest's `goal`, which is already both campaign-derived and
97    /// inventoried; the description was previously the hardcoded English
98    /// "You left the keep." on *every* delve, whatever its theme or language.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub outro: Option<String>,
101    /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
102    /// party of one is always legal and every pre-0.6 campaign keeps that reading.
103    /// A design whose beats genuinely need `n` players — two rooms whose switches
104    /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
105    /// lobby then refuses to start below it: the class-selection dialog stays shut
106    /// and the waiting players get a party-count actionbar instead.
107    ///
108    /// Progression is party state either way (spec-0018), so this is a *declaration
109    /// of intent*, not a mechanism: it makes a mandatory-n design first-class
110    /// (owner amendment 2026-08-02) and turns on the analyzer's n-agent division
111    /// proof. Out of `1..=4` is `DW0370`.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub min_players: Option<u8>,
114}
115
116/// Who a granted item goes to (DSL v0.6, spec-0018).
117///
118/// Progression is a fact about the party, so the default for every `give-item` and
119/// every class-kit entry is **all**: a quest beat that arms the party arms all of
120/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
121/// the stake): exactly one copy enters the party, handed to the player whose action
122/// earned it, and the party passes it around physically.
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
124#[serde(rename_all = "kebab-case")]
125pub enum Carrier {
126    /// Every party member receives the item (the default).
127    #[default]
128    All,
129    /// Exactly one copy, to the player whose action fired the effect.
130    One,
131}
132
133/// A declared world time state (DSL v0.5, spec-0010). The sole difference from
134/// vanilla is that the daylight cycle is frozen (`advance_time false`), so a set
135/// state persists for the whole delve until a `set-time` effect cuts to another.
136///
137/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
138/// tick count, and the tick form is the general one — so the states worth naming
139/// for a delve's pacing are not limited to the four keywords. `dusk` and `dawn`
140/// (owner ruling, 2026-08-03) are the tick form exposed first-class, per the
141/// no-hack rule: the DSL names the beat, the compiler emits `/time set <ticks>`.
142/// Every keyword-to-tick mapping lives in exactly one table ([`WorldTime::spec`]),
143/// and the four vanilla keywords still emit their keyword verbatim, so existing
144/// campaigns are byte-identical.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
146#[serde(rename_all = "kebab-case")]
147pub enum WorldTime {
148    /// Morning daylight (`/time set day`, 1000 ticks).
149    Day,
150    /// Midday, brightest (`/time set noon`, 6000 ticks) — the default.
151    #[default]
152    Noon,
153    /// Sunset — the sky visibly going orange and the day ending
154    /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
155    /// has finished setting, which is what the `night` keyword already sets, so
156    /// 13000 would make `dusk` a synonym rather than its own beat.
157    Dusk,
158    /// Night, sun fully down (`/time set night`, 13000 ticks).
159    Night,
160    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
161    Midnight,
162    /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
163    /// `sunrise` is accepted as a synonym on input.
164    #[serde(alias = "sunrise")]
165    Dawn,
166}
167
168impl WorldTime {
169    /// The single keyword/tick table: `(the /time set argument, daytime ticks)`.
170    ///
171    /// A state vanilla names keeps its keyword — the argument the compiler has
172    /// always emitted — so no shipped campaign's bytes move. A state vanilla does
173    /// not name emits the equivalent tick count, which is the same primitive.
174    const fn spec(self) -> (&'static str, i64) {
175        match self {
176            WorldTime::Day => ("day", 1000),
177            WorldTime::Noon => ("noon", 6000),
178            WorldTime::Dusk => ("12000", 12000),
179            WorldTime::Night => ("night", 13000),
180            WorldTime::Midnight => ("midnight", 18000),
181            WorldTime::Dawn => ("23000", 23000),
182        }
183    }
184
185    /// The vanilla `/time set` argument — a keyword for the four states vanilla
186    /// names, a tick count otherwise.
187    pub fn token(self) -> &'static str {
188        self.spec().0
189    }
190
191    /// The `daytime` tick value this state sets (the `time query daytime`
192    /// read-back). Vanilla constants: day=1000, noon=6000, dusk=12000 (sunset
193    /// onset), night=13000, midnight=18000, dawn=23000.
194    pub fn daytime_ticks(self) -> i64 {
195        self.spec().1
196    }
197}
198
199/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
200/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202#[serde(rename_all = "kebab-case")]
203pub enum WorldWeather {
204    /// Clear sky (`/weather clear`) — the default.
205    #[default]
206    Clear,
207    /// Rain (`/weather rain`).
208    Rain,
209    /// Thunderstorm (`/weather thunder`).
210    Thunder,
211}
212
213impl WorldWeather {
214    /// The vanilla `/weather` keyword.
215    pub fn token(self) -> &'static str {
216        match self {
217            WorldWeather::Clear => "clear",
218            WorldWeather::Rain => "rain",
219            WorldWeather::Thunder => "thunder",
220        }
221    }
222}
223
224/// The declared combat difficulty of the delve (DSL v0.6, owner ruling
225/// 2026-08-03). Values are the vanilla `/difficulty` keywords.
226///
227/// Difficulty is the single largest lever on how hard a delve *feels*, and until
228/// this field existed the compiler chose it: `easy` for any campaign with a wave,
229/// `peaceful` for one without. Easy **halves incoming player damage** —
230/// `min(dmg / 2 + 1, dmg)` — so every combat number in a pre-0.6 campaign was
231/// tuned against a halved world without anyone declaring it. A campaign that
232/// raises this must redo that arithmetic.
233///
234/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
235/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
236/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
237/// campaign would silently vanish. It is a variant only so the compiler can say
238/// that in a diagnostic instead of a serde "unknown variant" parse error.
239#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
240#[serde(rename_all = "kebab-case")]
241pub enum WorldDifficulty {
242    /// `/difficulty easy` — the compiler's historical choice for a wave
243    /// campaign, and the default reading of an absent field. Incoming player
244    /// damage is halved (`min(dmg / 2 + 1, dmg)`).
245    #[default]
246    Easy,
247    /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
248    Normal,
249    /// `/difficulty hard` — amplified damage, and zombies reinforce.
250    Hard,
251    /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
252    /// the rejection can be a diagnostic with a rationale.
253    Peaceful,
254}
255
256impl WorldDifficulty {
257    /// The vanilla `/difficulty` keyword.
258    pub fn token(self) -> &'static str {
259        match self {
260            WorldDifficulty::Peaceful => "peaceful",
261            WorldDifficulty::Easy => "easy",
262            WorldDifficulty::Normal => "normal",
263            WorldDifficulty::Hard => "hard",
264        }
265    }
266
267    /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
268    /// `/difficulty` query command returns — the only vanilla read-back path for
269    /// the setting, and so what the generated PackTest asserts on.
270    pub fn id(self) -> i32 {
271        match self {
272            WorldDifficulty::Peaceful => 0,
273            WorldDifficulty::Easy => 1,
274            WorldDifficulty::Normal => 2,
275            WorldDifficulty::Hard => 3,
276        }
277    }
278}
279
280/// A scenic horizon (DSL v0.6, spec-0013). `void` is the default and is
281/// byte-identical to v0.5 (empty-layer superflat, `minecraft:the_void` biome);
282/// `ocean` selects a pinned bedrock/stone/water superflat with sea level y=62,
283/// pure backdrop with no structures or mobs. The compiler owns the exact
284/// generator-settings; this enum only picks which one.
285#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286#[serde(rename_all = "kebab-case")]
287pub enum Horizon {
288    /// The void world (default) — no sky-filling geometry.
289    #[default]
290    Void,
291    /// A superflat sea backdrop; areas are placed on the sea-level datum (y=60) so
292    /// island pieces read as land ringed by the ocean.
293    Ocean,
294}
295
296/// The default boundary `margin` (blocks of horizontal breathing room added
297/// around the derived region). Separate function so `serde(default = …)` and the
298/// documented literal cannot drift.
299fn default_margin() -> u16 {
300    16
301}
302
303/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
304/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
305/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
306/// placed block − 8) — never authored — so "every anchor is inside" is structural.
307/// Enforcement is a per-second clock that returns any player outside the region to
308/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
309/// damage, no items lost.
310#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
311#[serde(deny_unknown_fields)]
312pub struct Boundary {
313    /// Horizontal breathing room in blocks added around the derived region on
314    /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
315    #[serde(default = "default_margin")]
316    pub margin: u16,
317    /// Actionbar message shown on return. Absent = the compiler's English default.
318    /// When set, it is inventoried under l10n key `world.boundary.message` and is
319    /// translated like every other player-facing string.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub message: Option<String>,
322}
323
324/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
325/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
326/// compiler owns the placement rule and block-light emission.
327#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328#[serde(rename_all = "kebab-case")]
329pub enum Fixture {
330    /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
331    Torch,
332    /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
333    Lantern,
334    /// Floor campfire (block light 15); never on or adjacent to a required path
335    /// cell (it is a damage source).
336    Campfire,
337    /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
338    Shroomlight,
339}
340
341impl Fixture {
342    /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
343    pub fn token(self) -> &'static str {
344        match self {
345            Fixture::Torch => "torch",
346            Fixture::Lantern => "lantern",
347            Fixture::Campfire => "campfire",
348            Fixture::Shroomlight => "shroomlight",
349        }
350    }
351}
352
353/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
354/// presence puts the area on the relight path: the compiler guarantees every
355/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
356/// with `DW0211` if it cannot.
357#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
358#[serde(deny_unknown_fields)]
359pub struct AreaLighting {
360    /// The fixture the relight pass places.
361    pub fixture: Fixture,
362    /// The minimum block+sky light guaranteed on reachable walkable cells
363    /// (1..=14, default 7). Validated by `DW0141`-adjacent bounds checking.
364    #[serde(default = "default_min_light")]
365    pub min_light: u8,
366}
367
368/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
369fn default_min_light() -> u8 {
370    7
371}
372
373/// A per-area **darkness mitigation** declaration (DSL v0.6).
374///
375/// The first-class answer to "this area is meant to be dark, and the players are
376/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
377/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
378/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
379/// gap between the check and the feature.
380///
381/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
382/// for `night vision`: that accepted a renamed water bottle, so the gate passed
383/// while nothing in the world granted night vision (owner, island QA).
384#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
385#[serde(rename_all = "kebab-case")]
386pub enum AreaMitigation {
387    /// Every player inside the area's placed bounds is kept under
388    /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
389    NightVision,
390}
391
392/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
393///
394/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
395/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
396/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
397/// full jigsaw layout semantics land in M2 task #9 (spec-0002).
398#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
399#[serde(deny_unknown_fields)]
400pub struct Area {
401    /// Unique area id.
402    pub id: AreaId,
403    /// Player-facing area name.
404    pub name: String,
405    /// The single prefab bound to this area (mutually exclusive with
406    /// `prefab_pool`).
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub prefab: Option<PrefabId>,
409    /// The jigsaw prefab pool bound to this area (mutually exclusive with
410    /// `prefab`); requires `pieces`.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub prefab_pool: Option<PoolId>,
413    /// Jigsaw piece-count bounds (only with `prefab_pool`).
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub pieces: Option<Pieces>,
416    /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
417    /// present, the compiler's relight pass guarantees `min_light` on every
418    /// reachable walkable cell of this area by placing the declared fixture, or
419    /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
420    /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub lighting: Option<AreaLighting>,
423    /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
424    /// the compiler emit a clocked `effect give` over this area's placed bounds and
425    /// is the (only) declaration that satisfies `DW0210` without `lighting`.
426    /// Independent of `lighting`: an area may declare both (fixtures *and* the
427    /// effect), either, or neither.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub mitigation: Option<AreaMitigation>,
430}
431
432/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
433#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
434#[serde(deny_unknown_fields)]
435pub struct Pieces {
436    /// Minimum number of pieces to assemble.
437    pub min: u32,
438    /// Maximum number of pieces to assemble.
439    pub max: u32,
440}
441
442// ---------------------------------------------------------------------------
443// Stage 2 — npcs
444// ---------------------------------------------------------------------------
445
446/// Stage 2 payload: the campaign's NPCs (casting sheets).
447#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
448#[serde(deny_unknown_fields)]
449pub struct NpcsContent {
450    /// All NPCs in the campaign.
451    pub npcs: Vec<Npc>,
452}
453
454/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
455///
456/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
457/// contract the stage-6 `dialogue` tree must honor.
458#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
459#[serde(deny_unknown_fields)]
460pub struct Npc {
461    /// Unique NPC id.
462    pub id: NpcId,
463    /// Player-facing name.
464    pub name: String,
465    /// NPC role.
466    pub role: Role,
467    /// The area this NPC stands in (stage-1 ref).
468    pub area: AreaId,
469    /// The prefab anchor this NPC stands on.
470    pub anchor: AnchorId,
471    /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
472    pub base_entity: String,
473    /// The structured persona (character contract for stage 6).
474    pub persona: Persona,
475    /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
476    /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
477    /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
478    /// Non-skinned NPCs are byte-identical to v0.3.
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub skin: Option<NpcSkin>,
481    /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
482    /// world init — its body and interaction hitbox only appear when a
483    /// [`QuestEffect::SpawnNpc`] fires, at this same `anchor`. The dual of
484    /// `despawn-npc`: a character with a scripted entrance must not stand at its
485    /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
486    /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
487    /// init, byte-identical to pre-0.6.
488    #[serde(default, skip_serializing_if = "is_false")]
489    pub deferred: bool,
490}
491
492/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG ships in the
493/// per-delve resource pack at `assets/delvewright/textures/npc/<texture_id>.png`
494/// (sourced from the campaign dir's `skins/<texture_id>.png`); the mannequin's
495/// `profile.texture` resolves to `delvewright:npc/<texture_id>`.
496#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
497#[serde(deny_unknown_fields)]
498pub struct NpcSkin {
499    /// Skin id: the PNG basename under `skins/` and the resource-pack texture
500    /// path segment (a bare kebab token; validated by `DW0190`).
501    pub texture_id: String,
502    /// Player model. **Required** (spec-0009): an omitted model renders slim, so
503    /// a wide skin on a slim model is distorted — the compiler always emits it.
504    pub model: SkinModel,
505}
506
507/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
508/// Alex). Emitted verbatim into the mannequin `profile.model`.
509#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
510#[serde(rename_all = "kebab-case")]
511pub enum SkinModel {
512    /// Classic 4-pixel arms (Steve).
513    Wide,
514    /// Slim 3-pixel arms (Alex).
515    Slim,
516}
517
518impl SkinModel {
519    /// The vanilla `profile.model` token.
520    pub fn token(self) -> &'static str {
521        match self {
522            SkinModel::Wide => "wide",
523            SkinModel::Slim => "slim",
524        }
525    }
526}
527
528/// A structured casting sheet (owner decision 2026-07-30). Structure lives in the
529/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
530/// are required; the rest are optional.
531#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
532#[serde(deny_unknown_fields)]
533pub struct Persona {
534    /// One-line character archetype (required).
535    pub archetype: String,
536    /// How the NPC speaks — register, tics, formality (required).
537    pub speech_style: String,
538    /// Emotional bearing toward the player (optional).
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub demeanor: Option<String>,
541    /// What the NPC wants (required).
542    pub motivation: String,
543    /// Something the NPC hides (optional).
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub secret: Option<String>,
546    /// Backstory colour (optional).
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub backstory: Option<String>,
549    /// Attitudes toward other same-stage NPCs (optional; refs validated).
550    #[serde(default, skip_serializing_if = "Vec::is_empty")]
551    pub relationships: Vec<Relationship>,
552}
553
554/// One persona relationship: an attitude toward another same-stage NPC.
555#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
556#[serde(deny_unknown_fields)]
557pub struct Relationship {
558    /// The other NPC (stage-2 ref, validated within stage 2).
559    pub npc: NpcId,
560    /// Free-text attitude toward that NPC.
561    pub attitude: String,
562}
563
564/// NPC role. `vendor` and `boss` are reserved (rejected in v0, `DW0141`).
565#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
566#[serde(rename_all = "kebab-case")]
567pub enum Role {
568    /// Gives and advances quests.
569    QuestGiver,
570    /// Flavor only.
571    Flavor,
572    /// Reserved (M2).
573    Vendor,
574    /// Reserved (M2).
575    Boss,
576}
577
578impl Role {
579    /// The reserved value name if this role is not implemented in v0.
580    pub fn reserved(self) -> Option<&'static str> {
581        match self {
582            Role::Vendor => Some("vendor"),
583            Role::Boss => Some("boss"),
584            Role::QuestGiver | Role::Flavor => None,
585        }
586    }
587}
588
589// ---------------------------------------------------------------------------
590// Stage 6 — dialogue
591// ---------------------------------------------------------------------------
592
593/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
594#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
595#[serde(deny_unknown_fields)]
596pub struct DialogueContent {
597    /// One tree per NPC (1:1 with stage 2, both directions).
598    pub dialogues: Vec<NpcDialogue>,
599}
600
601impl DialogueContent {
602    /// The dialogue tree for an NPC id, if present.
603    pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
604        self.dialogues.iter().find(|t| t.npc.as_str() == npc)
605    }
606}
607
608/// One NPC's dialogue tree: a root node plus a set of nodes.
609#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
610#[serde(deny_unknown_fields)]
611pub struct NpcDialogue {
612    /// The NPC this tree belongs to (stage-2 ref).
613    pub npc: NpcId,
614    /// The entry node id; every node must be reachable from it.
615    pub root: DialogueId,
616    /// The dialogue nodes.
617    pub nodes: Vec<DialogueNode>,
618}
619
620/// One dialogue node: text plus branching options.
621#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
622#[serde(deny_unknown_fields)]
623pub struct DialogueNode {
624    /// Node id (unique within this NPC's dialogue).
625    pub id: DialogueId,
626    /// The line the NPC speaks.
627    pub text: String,
628    /// Branching options; empty closes the dialog.
629    #[serde(default, skip_serializing_if = "Vec::is_empty")]
630    pub options: Vec<DialogueOption>,
631}
632
633/// One selectable dialogue option.
634#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
635#[serde(deny_unknown_fields)]
636pub struct DialogueOption {
637    /// Button label.
638    pub label: String,
639    /// The full line this button is the caption of (DSL v0.8, owner design
640    /// 2026-08-04; reserved `DW0141` earlier). Vanilla's dialog button codec
641    /// (`CommonButtonData`) carries an optional `tooltip` component beside
642    /// `label`, and the client hangs it on the button as a real hover tooltip —
643    /// so a caption on the button and the sentence the character actually says
644    /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
645    /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
646    /// scrolls. Player-visible, so it translates like the label
647    /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub tooltip: Option<String>,
650    /// Next node; omitted closes the dialog.
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub next: Option<DialogueId>,
653    /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
654    /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
655    /// gated option is hidden until every referenced flag has been set by a
656    /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
657    /// option cannot make a critical-path node unreachable (`DW0191`).
658    #[serde(default, skip_serializing_if = "Vec::is_empty")]
659    pub requires_flags: Vec<FlagId>,
660    /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the option is
661    /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
662    /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
663    /// option counts as *gated* for the `DW0191` deadlock guard: it can be
664    /// suppressed at any point, so it cannot be the only completing path.
665    #[serde(default, skip_serializing_if = "Vec::is_empty")]
666    pub forbids_flags: Vec<FlagId>,
667    /// Effects fired when this option is chosen.
668    #[serde(default, skip_serializing_if = "Vec::is_empty")]
669    pub effects: Vec<DialogueEffect>,
670    /// What choosing this option does to the story (DSL v0.8, spec-0025;
671    /// reserved `DW0141` earlier). Required at 0.8.0 for a **story-weight** beat
672    /// — an option carrying a `set-flag` effect, which is how a player's choice
673    /// forks the world (`DW0481`). An option that only walks the tree or
674    /// completes an objective needs none: the objective already declares one.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub happening: Option<Happening>,
677}
678
679/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
680/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
681/// dialogue choice, enabling flag-gated options/objectives/triggers).
682#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
683#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
684pub enum DialogueEffect {
685    /// Marks a stage-5 objective complete.
686    CompleteObjective {
687        /// The objective to complete (resolved at the stage-5 boundary).
688        objective: ObjectiveId,
689    },
690    /// Sets a campaign flag (DSL v0.4), mirroring [`QuestEffect::SetFlag`].
691    SetFlag {
692        /// The flag to set.
693        flag: FlagId,
694    },
695    /// Cuts the world time (DSL v0.5), mirroring [`QuestEffect::SetTime`].
696    SetTime {
697        /// The time state to cut to.
698        time: WorldTime,
699    },
700    /// Cuts the weather (DSL v0.5), mirroring [`QuestEffect::SetWeather`].
701    SetWeather {
702        /// The weather state to cut to.
703        weather: WorldWeather,
704    },
705    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
706    /// [`QuestEffect::SetCheckpoint`] — usable from a dialogue outcome.
707    SetCheckpoint {
708        /// The prefab checkpoint anchor the party respawns at.
709        anchor: AnchorId,
710        /// Per-player effects re-run on respawn while this checkpoint is active
711        /// (scene reset). Empty = no hook.
712        #[serde(default, skip_serializing_if = "Vec::is_empty")]
713        on_respawn: Vec<QuestEffect>,
714    },
715    /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
716    /// [`QuestEffect::SpawnNpc`] — a character who walks in mid-conversation.
717    SpawnNpc {
718        /// The NPC (stage-2 ref) to summon.
719        npc: NpcId,
720    },
721}
722
723impl DialogueEffect {
724    /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
725    /// (`set-time`/`set-weather`, spec-0010).
726    pub fn v05_effect(&self) -> Option<&'static str> {
727        match self {
728            DialogueEffect::SetTime { .. } => Some("set-time"),
729            DialogueEffect::SetWeather { .. } => Some("set-weather"),
730            _ => None,
731        }
732    }
733
734    /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
735    /// (`set-checkpoint`, spec-0012; `spawn-npc`). Reserved (`DW0141`) in an
736    /// earlier campaign.
737    pub fn v06_effect(&self) -> Option<&'static str> {
738        match self {
739            DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
740            DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
741            _ => None,
742        }
743    }
744
745    /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
746    pub fn spawn_npc(&self) -> Option<&NpcId> {
747        match self {
748            DialogueEffect::SpawnNpc { npc } => Some(npc),
749            _ => None,
750        }
751    }
752
753    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
754    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
755        match self {
756            DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
757                Some((anchor, on_respawn.as_slice()))
758            }
759            _ => None,
760        }
761    }
762
763    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
764    /// **effect root 5** ([`crate::effects`]). Named separately from
765    /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
766    /// same accessor modulo mutability, which is what lets one macro body generate
767    /// both.
768    pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
769        match self {
770            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
771            _ => None,
772        }
773    }
774
775    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
776    /// mutably so the localization pass can rewrite the player-visible strings
777    /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
778    /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
779    /// that reaches it read-only needs a way to reach it writable too.
780    pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
781        match self {
782            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
783            _ => None,
784        }
785    }
786}
787
788impl DialogueEffect {
789    /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
790    pub fn set_flag(&self) -> Option<&FlagId> {
791        match self {
792            DialogueEffect::SetFlag { flag } => Some(flag),
793            _ => None,
794        }
795    }
796
797    /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
798    /// (`set-flag`). Reserved (`DW0141`) in an earlier campaign.
799    pub fn v04_effect(&self) -> Option<&'static str> {
800        match self {
801            DialogueEffect::SetFlag { .. } => Some("set-flag"),
802            _ => None,
803        }
804    }
805
806    /// The target time if this is a v0.5 `set-time` dialogue effect.
807    pub fn set_time(&self) -> Option<WorldTime> {
808        match self {
809            DialogueEffect::SetTime { time } => Some(*time),
810            _ => None,
811        }
812    }
813
814    /// The target weather if this is a v0.5 `set-weather` dialogue effect.
815    pub fn set_weather(&self) -> Option<WorldWeather> {
816        match self {
817            DialogueEffect::SetWeather { weather } => Some(*weather),
818            _ => None,
819        }
820    }
821}
822
823// ---------------------------------------------------------------------------
824// Stage 3 — classes
825// ---------------------------------------------------------------------------
826
827/// Stage 3 payload: 1..4 selectable classes.
828#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
829#[serde(deny_unknown_fields)]
830pub struct ClassesContent {
831    /// The selectable classes.
832    pub classes: Vec<Class>,
833}
834
835/// A player class with a starting kit.
836#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
837#[serde(deny_unknown_fields)]
838pub struct Class {
839    /// Unique class id.
840    pub id: ClassId,
841    /// Player-facing name.
842    pub name: String,
843    /// Selection-screen blurb.
844    pub blurb: String,
845    /// Granted items.
846    pub kit: Vec<KitItem>,
847}
848
849/// One item in a class kit.
850///
851/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
852/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
853/// document using them is rejected as an unknown field (`DW0100`).
854#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
855#[serde(deny_unknown_fields)]
856pub struct KitItem {
857    /// Vanilla item id, validated against the pinned 1.21.11 registry.
858    pub item: String,
859    /// Stack count.
860    pub count: u32,
861    /// Optional display name.
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub name: Option<String>,
864    /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
865    /// per-player gear by construction — every player who picks the class gets the
866    /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
867    /// enters the party, given to the first player to pick this class.
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub carrier: Option<Carrier>,
870    /// **The flask** (DSL v0.8, spec-0016 §1, owner ruling 2026-08-03): this kit
871    /// entry is the class's recovery item, and resting at a bonfire replenishes
872    /// it to exactly `count`. A campaign that places a `bonfire` and declares no
873    /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
874    /// dying an investment, so a souls campaign without one is a build error, not
875    /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
876    #[serde(default, skip_serializing_if = "is_false")]
877    pub flask: bool,
878    /// **What is in the bottle** (DSL v0.8, spec-0016 §1, owner directive
879    /// 2026-08-03): the vanilla `minecraft:potion_contents` component of a
880    /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
881    /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
882    /// nothing — which is exactly the placeholder flask this field exists to
883    /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
884    /// no `contents` is `DW0487`.
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub contents: Option<PotionContents>,
887}
888
889/// The items whose vanilla item definition carries a `minecraft:potion_contents`
890/// component — the only items a kit `contents` may be declared on (`DW0486`).
891///
892/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
893/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
894/// misode/mcmeta ref `crates/compiler/data/PROVENANCE.md` pins): exactly these
895/// four items declare the component, and on any other item the game drops the
896/// data on the floor.
897pub const POTION_BEARING_ITEMS: &[&str] = &[
898    "minecraft:lingering_potion",
899    "minecraft:potion",
900    "minecraft:splash_potion",
901    "minecraft:tipped_arrow",
902];
903
904/// True if `item_id` (optionally un-namespaced) is one of the four
905/// [`POTION_BEARING_ITEMS`].
906pub fn is_potion_bearing_item(item_id: &str) -> bool {
907    let norm = if item_id.contains(':') {
908        item_id.to_string()
909    } else {
910        format!("minecraft:{item_id}")
911    };
912    POTION_BEARING_ITEMS.contains(&norm.as_str())
913}
914
915/// The two **instantaneous** status effects: they are applied once, on the tick
916/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
917/// `isInstantenous` before any effect instance is ever added), so a `duration` on
918/// one is a statement the game never reads — `DW0486` says so rather than letting
919/// an author believe they wrote a thirty-second heal.
920pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
921
922/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
923/// modelled field for field on vanilla rather than invented: a **named** potion,
924/// a list of **custom effects**, or both, plus the bottle-colour override.
925///
926/// Vanilla resolves a drink as the named potion's effects followed by the custom
927/// ones, and derives the bottle colour from those effects unless `color`
928/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
929/// Potion of Healing II a player would brew, not an approximation of one.
930#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
931#[serde(deny_unknown_fields)]
932pub struct PotionContents {
933    /// A named vanilla potion id (`minecraft:strong_healing`,
934    /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
935    /// `potion` registry (`DW0486`).
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub potion: Option<String>,
938    /// Custom effects applied on top of (or instead of) the named potion — the
939    /// escape hatch for a recovery item vanilla has no brew for.
940    #[serde(default, skip_serializing_if = "Vec::is_empty")]
941    pub effects: Vec<PotionEffect>,
942    /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
943    /// colour vanilla derives from the effects themselves.
944    #[serde(default, skip_serializing_if = "Option::is_none")]
945    pub color: Option<String>,
946}
947
948/// One entry of a potion's `custom_effects` list.
949#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
950#[serde(deny_unknown_fields)]
951pub struct PotionEffect {
952    /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
953    /// against the pinned registry (`DW0486`).
954    pub effect: String,
955    /// How long it lasts, in **ticks** (20 = one second). Required for every
956    /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
957    #[serde(default, skip_serializing_if = "Option::is_none")]
958    pub duration: Option<u32>,
959    /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub amplifier: Option<u32>,
962}
963
964impl PotionEffect {
965    /// True if this effect is applied once on drinking rather than over time.
966    pub fn is_instant(&self) -> bool {
967        let norm = if self.effect.contains(':') {
968            self.effect.clone()
969        } else {
970            format!("minecraft:{}", self.effect)
971        };
972        INSTANT_EFFECTS.contains(&norm.as_str())
973    }
974}
975
976/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
977/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
978/// need is refused — while a duration typed in *milliseconds*, or one that would
979/// overflow vanilla's int, is caught (`DW0486`).
980pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
981
982/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
983/// unsigned byte, so 255 is not a policy but the end of the field.
984pub const MAX_POTION_AMPLIFIER: u32 = 255;
985
986/// The canonical English title of the bonfire rest dialog (owner ruling
987/// 2026-08-03). Baked at emit time when the campaign authors no `prompt`, in the
988/// `world.boundary.message` tradition: a compiler default is not inventoried, an
989/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
990pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
991/// The canonical English label of the **rest and save** option.
992pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
993/// The canonical English label of the **save only** option.
994pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
995
996/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
997/// leaves the compiler's canonical English in place
998/// ([`QuestEffect::bonfire_labels`]).
999#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1000pub struct BonfireLabels<'a> {
1001    /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
1002    pub prompt: Option<&'a str>,
1003    /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
1004    pub rest_label: Option<&'a str>,
1005    /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
1006    pub save_label: Option<&'a str>,
1007}
1008
1009impl BonfireLabels<'_> {
1010    /// The dialog title actually emitted.
1011    pub fn prompt_or_default(&self) -> &str {
1012        self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
1013    }
1014    /// The **rest and save** label actually emitted.
1015    pub fn rest_or_default(&self) -> &str {
1016        self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
1017    }
1018    /// The **save only** label actually emitted.
1019    pub fn save_or_default(&self) -> &str {
1020        self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
1021    }
1022}
1023
1024// ---------------------------------------------------------------------------
1025// Stage 4 — quest-plan
1026// ---------------------------------------------------------------------------
1027
1028/// Stage 4 payload: the quest dependency plan.
1029#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1030#[serde(deny_unknown_fields)]
1031pub struct QuestPlanContent {
1032    /// Planned quests (expanded in stage 5).
1033    pub quests: Vec<PlannedQuest>,
1034    /// The quest whose completion ends the campaign.
1035    pub finale: QuestId,
1036    /// The campaign's declared **story forks** (DSL v0.8, spec-0025; reserved
1037    /// `DW0141` earlier). Empty/absent = a campaign that claims to have no
1038    /// branch — which the compiler then *verifies* rather than assumes: any flag
1039    /// that gates casts, staging or structure and is set on some playthroughs and
1040    /// not others belongs to no declared point and is `DW0480`.
1041    ///
1042    /// Enumerated branches are the **product of the declared points**, so the
1043    /// branch set is authored and small — never a combinatorial sweep of every
1044    /// flag in the campaign.
1045    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1046    pub branch_points: Vec<BranchPoint>,
1047}
1048
1049/// One declared story fork (DSL v0.8, spec-0025).
1050///
1051/// A branch point names the flag set the story forks on, the quest at which the
1052/// fork opens, and every branch it offers. Each branch pins the point's whole
1053/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
1054/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
1055/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
1056#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1057#[serde(deny_unknown_fields)]
1058pub struct BranchPoint {
1059    /// Unique branch-point id.
1060    pub id: BranchPointId,
1061    /// The quest at which the fork opens — every branch's divergent content is at
1062    /// or after it in the stage-4 DAG.
1063    pub opens_at: QuestId,
1064    /// The flag set the story forks on. Every branch's `flags` is a subset of
1065    /// this, and the flags it does not list are pinned **unset** on that branch.
1066    pub forks_on: Vec<FlagId>,
1067    /// The alternatives (≥ 2).
1068    pub branches: Vec<BranchDecl>,
1069}
1070
1071/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
1072#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1073#[serde(deny_unknown_fields)]
1074pub struct BranchDecl {
1075    /// Unique branch id (campaign-wide — it names the emitted chronicle file).
1076    pub id: BranchId,
1077    /// The subset of the point's `forks_on` that is SET on this branch. The rest
1078    /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
1079    /// option" branch — as long as it is genuinely reachable.
1080    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1081    pub flags: Vec<FlagId>,
1082    /// Where this branch goes: either the `quest/<kebab>` the branches converge
1083    /// at, or the `ending/<kebab>` this branch runs to.
1084    ///
1085    /// One field, not two mutually exclusive ones, because the **id prefix
1086    /// already says which it is** — the same convention every cross-stage
1087    /// reference in the DSL uses. That is what makes "exactly one of them" an
1088    /// unrepresentable state rather than a rule some diagnostic has to police: a
1089    /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
1090    /// the ordinary malformed-id `DW0110`, and one that names nothing is the
1091    /// ordinary dangling-reference `DW0112`.
1092    pub leads_to: String,
1093}
1094
1095impl BranchDecl {
1096    /// The convergence quest, if [`Self::leads_to`] names one.
1097    pub fn converges_at(&self) -> Option<QuestId> {
1098        let q = QuestId(self.leads_to.clone());
1099        q.is_valid_syntax().then_some(q)
1100    }
1101
1102    /// The ending, if [`Self::leads_to`] names one.
1103    pub fn ending(&self) -> Option<EndingId> {
1104        let e = EndingId(self.leads_to.clone());
1105        e.is_valid_syntax().then_some(e)
1106    }
1107}
1108
1109/// What a story node does to the story (DSL v0.8, spec-0025).
1110///
1111/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
1112/// design that never got written down node by node cannot compile. It is
1113/// **node-local on purpose** — there is no parallel per-branch script document
1114/// that could itself drift from the graph.
1115///
1116/// `text` is authoring/validation metadata, never shown to a player, so it is
1117/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
1118/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
1119/// chronicle is assembled from.
1120#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1121#[serde(deny_unknown_fields)]
1122pub struct Happening {
1123    /// The structured event verb — the machine-decidable part.
1124    pub verb: HappeningVerb,
1125    /// One line of prose stating what this node does to the story.
1126    pub text: String,
1127    /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
1128    /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
1129    /// story token the campaign tracks by hand. Optional, because not every beat
1130    /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
1131    /// over the beats that name one.
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub subject: Option<String>,
1134}
1135
1136/// The structured event vocabulary (DSL v0.8, spec-0025).
1137///
1138/// Deliberately small and closed. These ten verbs are what make a subset of
1139/// narrative errors machine-decidable per branch (`DW0485`); everything else a
1140/// beat means lives in [`Happening::text`], which the compiler never interprets.
1141/// Extend only when a real campaign cannot state its beat with what is here.
1142#[derive(
1143    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1144)]
1145#[serde(rename_all = "kebab-case")]
1146pub enum HappeningVerb {
1147    /// The subject is killed / destroyed. Terminal: nothing the subject does may
1148    /// follow it on the same branch.
1149    Dies,
1150    /// The subject comes through alive — the explicit counterpart of `dies`,
1151    /// which is what lets a branch state that somebody *did not* die.
1152    Survives,
1153    /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
1154    Departs,
1155    /// The subject enters the stage.
1156    Arrives,
1157    /// Somebody learns a fact — the true-information beat.
1158    Learns,
1159    /// Somebody comes to believe something (whether or not it is true) — the beat
1160    /// that carries a wrong belief forward, which is where branch drift shows.
1161    Believes,
1162    /// The party (or the subject) gains a thing.
1163    Gains,
1164    /// The party (or the subject) loses a thing. A second `loses` with no
1165    /// intervening `gains` is spending what is already spent (`DW0485`).
1166    Loses,
1167    /// A way is opened.
1168    Opens,
1169    /// A way is sealed. Cleared by `opens`.
1170    Seals,
1171}
1172
1173/// One planned quest (dependency-graph node).
1174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1175#[serde(deny_unknown_fields)]
1176pub struct PlannedQuest {
1177    /// Unique quest id.
1178    pub id: QuestId,
1179    /// Human-readable goal.
1180    pub goal: String,
1181    /// Area this quest takes place in (stage-1 ref).
1182    pub area: AreaId,
1183    /// NPCs involved (stage-2 refs).
1184    pub npcs: Vec<NpcId>,
1185    /// Prerequisite quests; edges must form a DAG.
1186    pub depends_on: Vec<QuestId>,
1187    /// v0 requires `true`; optional quests are reserved (`DW0133`).
1188    pub mandatory: bool,
1189    /// Act number (informational).
1190    pub act: u32,
1191}
1192
1193// ---------------------------------------------------------------------------
1194// Stage 5 — quests
1195// ---------------------------------------------------------------------------
1196
1197/// Stage 5 payload: quest expansions.
1198#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1199#[serde(deny_unknown_fields)]
1200pub struct QuestsContent {
1201    /// The expanded quests (1:1 with stage 4).
1202    pub quests: Vec<Quest>,
1203    /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
1204    /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
1205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1206    pub waves: Vec<Wave>,
1207    /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
1208    /// watches an anchor for a strike / use / approach event and fires a bundle
1209    /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
1210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1211    pub triggers: Vec<EnvTrigger>,
1212    /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
1213    /// compiler-emitted per-tick teleport (task #46). Distinct from stage-2 NPCs
1214    /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
1215    /// staging effects. Empty/absent before v0.6.
1216    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1217    pub actors: Vec<Actor>,
1218    /// Traps (DSL v0.6, spec-0011): redstone-native environmental hazards bound to
1219    /// `anchor/trap` prefab markers. Each gives mute trap hardware meaning — its
1220    /// trigger, dispenser payload, lethality, disarm, and reset. Empty/absent in
1221    /// pre-0.6 campaigns (reserved `DW0141`), so a v0.5-or-earlier campaign that
1222    /// declares none stays byte-identical.
1223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1224    pub traps: Vec<Trap>,
1225    /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
1226    /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
1227    /// campaigns (reserved `DW0141`), so a campaign that declares none stays
1228    /// byte-identical.
1229    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1230    pub shortcuts: Vec<Shortcut>,
1231    /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
1232    /// springs them". Empty/absent in pre-0.6 campaigns (reserved `DW0141`).
1233    ///
1234    /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
1235    /// each ambush into `triggers`, so the canonical form of a campaign is its
1236    /// **desugared** form. That is what keeps the canonical round-trip idempotent
1237    /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
1238    /// second time and duplicate the trigger ids), and it means the sugar exists
1239    /// at exactly one layer boundary — the authored `.json` — with nothing
1240    /// downstream needing to know it was ever there. The list itself is kept in
1241    /// memory so diagnostics can name the ambush the author wrote.
1242    /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
1243    /// Empty/absent in pre-0.6 campaigns (reserved `DW0141`).
1244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1245    pub timed_gates: Vec<TimedGate>,
1246    /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
1247    /// given contents at world init. Empty/absent in pre-0.6 campaigns
1248    /// (reserved `DW0141`).
1249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1250    pub loot: Vec<Loot>,
1251    #[serde(default, skip_serializing)]
1252    pub ambushes: Vec<Ambush>,
1253    /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
1254    /// authored `ambushes` are deliberately KEPT after expansion so validation
1255    /// and the counterplay proof can attribute diagnostics to the ambush the
1256    /// author actually wrote; this flag is what makes a second expansion a no-op.
1257    #[serde(skip)]
1258    pub ambushes_expanded: bool,
1259}
1260
1261impl QuestsContent {
1262    /// Every environment trigger this stage produces: the authored `triggers`
1263    /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
1264    /// declared order.
1265    ///
1266    /// **This is the single trigger authority.** Validation, the l10n
1267    /// inventory, the flag/wave producer scans, the nav proofs and emission all
1268    /// read triggers through it, so an ambush behaves exactly like the trigger
1269    /// an author would otherwise hand-write — there is no second code path for
1270    /// the sugar to drift down. Deterministic (declaration order, no hashing).
1271    pub fn all_triggers(&self) -> Vec<EnvTrigger> {
1272        let mut out = self.triggers.clone();
1273        out.extend(self.ambushes.iter().map(Ambush::to_trigger));
1274        out
1275    }
1276
1277    /// Desugar every `ambush` into a real environment trigger and clear the
1278    /// ambush list (spec-0016 §3). Called once, by
1279    /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
1280    /// (a second call sees no ambushes left to expand).
1281    pub fn expand_ambushes(&mut self) {
1282        if self.ambushes_expanded || self.ambushes.is_empty() {
1283            return;
1284        }
1285        self.triggers = self.all_triggers();
1286        self.ambushes_expanded = true;
1287    }
1288}
1289
1290/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
1291/// barrel the prefab already placed.
1292///
1293/// The container is **hardware the prefab authored**, exactly like a trap's
1294/// dispenser: this declaration gives an already-placed, already-lit, already
1295/// composed piece of furniture its contents. The compiler never places the
1296/// container itself — if the anchor's cell does not already hold one, that is a
1297/// content defect and a build error (`DW0431`), not something to paper over by
1298/// setblock-ing a chest into a wall.
1299#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1300#[serde(deny_unknown_fields)]
1301pub struct Loot {
1302    /// Unique loot id (`loot/<kebab>`).
1303    pub id: LootId,
1304    /// The anchor whose cell holds the container to fill.
1305    pub anchor: AnchorId,
1306    /// Contents, in declaration order. Slot assignment is positional and
1307    /// deterministic — the first entry lands in `container.0`, the second in
1308    /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
1309    pub items: Vec<LootItem>,
1310}
1311
1312/// One stack inside a [`Loot`] container.
1313#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1314#[serde(deny_unknown_fields)]
1315pub struct LootItem {
1316    /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
1317    /// 1.21.11 item registry (`DW0143`).
1318    pub item: String,
1319    /// Stack size. Defaults to 1.
1320    #[serde(default = "one_u32")]
1321    pub count: u32,
1322    /// Optional custom item name. Enters the l10n string inventory exactly like
1323    /// a class kit item's name.
1324    #[serde(default, skip_serializing_if = "Option::is_none")]
1325    pub name: Option<String>,
1326    /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
1327    /// 1.21 `minecraft:enchantments` item component.
1328    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1329    pub enchantments: BTreeMap<String, u32>,
1330}
1331
1332fn one_u32() -> u32 {
1333    1
1334}
1335
1336/// A stage-5 trap (DSL v0.6, spec-0011): a redstone-native environmental hazard.
1337/// The trap *mechanism* — the trigger (pressure plate / tripwire / trapped chest)
1338/// wired to a pre-placed empty dispenser — lives in the `.nbt` prefab at an
1339/// `anchor/trap` marker; this declaration gives the mute hardware meaning. The
1340/// compiler fills the dispenser payload, models the trigger cell as a hazard for
1341/// the completability proofs (`DW0342`), and — for a disarmable trap — emits the
1342/// disarm affordance. No detection is emitted for the harm itself: the redstone
1343/// fires it ("harm is redstone-native", spec-0011). Player-vs-mob distinguishing
1344/// matters in a sealed box-garden with controlled mobs, so `trapped-chest` (opened
1345/// by a player) is called out as the only player-distinct trigger.
1346#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1347#[serde(deny_unknown_fields)]
1348pub struct Trap {
1349    /// Unique trap id (`trap/<kebab>`).
1350    pub id: TrapId,
1351    /// The `anchor/trap` marker this trap binds to. Its cell is the trigger/hazard
1352    /// cell the compiler models; the anchor also carries the dispenser socket the
1353    /// payload fills.
1354    pub at: AnchorId,
1355    /// What springs the trap (all redstone-native).
1356    pub trigger: TrapTrigger,
1357    /// The **legacy** redstone consequence (spec-0011): a static dispenser
1358    /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
1359    /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
1360    /// so existing campaigns build unchanged. Optional since spec-0022; a trap
1361    /// must declare `effect`, `payload`, or both (`DW0440`).
1362    #[serde(default, skip_serializing_if = "Option::is_none")]
1363    pub effect: Option<TrapEffect>,
1364    /// The **command payload** (spec-0022): an ordered effect list in the same
1365    /// vocabulary quests use, run when the trigger fires. This is where a trap's
1366    /// consequence lives now — the compiler owns the detection tick and the
1367    /// effect vocabulary, so a trap's payload is authored like any other effect
1368    /// bundle rather than built out of dust and repeaters. Expressiveness moves
1369    /// from "what dust can carry" to "what the effect vocabulary can say":
1370    /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
1371    /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
1372    ///
1373    /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
1374    /// emitted before (byte-identical).
1375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1376    pub payload: Vec<QuestEffect>,
1377    /// How dangerous the trap is. A `lethal` trap on the forced critical path
1378    /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
1379    /// carry none. Defaults to `harmful`.
1380    #[serde(default)]
1381    pub lethality: Lethality,
1382    /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
1383    /// turn the trap off — setting a flag and emptying the dispenser — before the
1384    /// trap cell is forced.
1385    #[serde(default, skip_serializing_if = "Option::is_none")]
1386    pub disarm: Option<TrapDisarm>,
1387    /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
1388    /// spent — the survivability path); `rearm` = re-fires each trigger (default).
1389    #[serde(default)]
1390    pub reset: TrapReset,
1391    /// Flags that must be set before the trap is considered active (mirrors
1392    /// [`EnvTrigger::requires_flags`]). Default empty.
1393    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1394    pub requires_flags: Vec<FlagId>,
1395    /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
1396    /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
1397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398    pub forbids_flags: Vec<FlagId>,
1399}
1400
1401impl Trap {
1402    /// `(item, count)` if this trap declares a legacy `dispense` effect.
1403    pub fn dispense(&self) -> Option<(&str, u32)> {
1404        match &self.effect {
1405            Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
1406            None => None,
1407        }
1408    }
1409
1410    /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
1411    /// forced critical path).
1412    pub fn is_lethal(&self) -> bool {
1413        matches!(self.lethality, Lethality::Lethal)
1414    }
1415}
1416
1417/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
1418/// redstone-native — the hardware fires without any command — so the compiler
1419/// emits no detection for them; it only models the trigger cell as a hazard and
1420/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
1421/// is deliberately *not* a trap trigger: it is already fully expressible as an
1422/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
1423#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1424#[serde(rename_all = "kebab-case")]
1425pub enum TrapTrigger {
1426    /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
1427    PressurePlate,
1428    /// A tripwire line: any entity crossing it. Auto-rearms.
1429    Tripwire,
1430    /// A trapped chest: a *player opening it* (comparator pulse). The only
1431    /// player-distinct trigger — a controlled mob cannot spring it.
1432    TrappedChest,
1433}
1434
1435impl TrapTrigger {
1436    /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
1437    pub fn kind(&self) -> &'static str {
1438        match self {
1439            TrapTrigger::PressurePlate => "pressure-plate",
1440            TrapTrigger::Tripwire => "tripwire",
1441            TrapTrigger::TrappedChest => "trapped-chest",
1442        }
1443    }
1444}
1445
1446/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
1447/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
1448/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
1449/// block-destroying and unmodeled effects out of the schema by construction
1450/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
1451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1452#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1453pub enum TrapEffect {
1454    /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
1455    /// arrows, splash potions). The redstone fires it; terrain is untouched
1456    /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
1457    /// `Items` NBT — a deterministic, static payload.
1458    Dispense {
1459        /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
1460        item: String,
1461        /// How many to load into the dispenser stack.
1462        count: u32,
1463    },
1464}
1465
1466/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
1467/// forced-critical-path completability obligation (`DW0342`).
1468#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1469#[serde(rename_all = "kebab-case")]
1470pub enum Lethality {
1471    /// Can kill a full-health player — carries the `DW0342` obligation on the path.
1472    Lethal,
1473    /// Hurts but is not designed to kill (the default).
1474    #[default]
1475    Harmful,
1476    /// Cosmetic / trivial (a stumble, a scare).
1477    Nonlethal,
1478}
1479
1480/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
1481#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1482#[serde(rename_all = "kebab-case")]
1483pub enum TrapReset {
1484    /// Fires once, then is spent — the survivability path for a forced lethal trap
1485    /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
1486    /// soft-loop).
1487    Once,
1488    /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
1489    /// trap must be avoidable or disarmable, else `DW0342`.
1490    #[default]
1491    Rearm,
1492}
1493
1494/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
1495/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
1496/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
1497/// before the trap cell is forced. Discharges the `DW0342` obligation when the
1498/// affordance is reachable ahead of the trap without crossing it.
1499#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1500#[serde(deny_unknown_fields)]
1501pub struct TrapDisarm {
1502    /// The anchor the player interacts with to disarm.
1503    pub via: AnchorId,
1504    /// The flag set when the trap is disarmed (a new flag this trap produces; other
1505    /// objectives/triggers may read it via `requires_flags`).
1506    pub sets_flag: FlagId,
1507}
1508
1509/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
1510/// deterministic open/close clock, so passage is a timing read rather than a
1511/// permanent state.
1512///
1513/// **The proof is deliberately NOT all-phase passability** (owner ruling
1514/// 2026-08-02) — a gate that punishes bad timing is the entire point. What the
1515/// compiler requires is that the gate is *readable*: the set of entry phases from
1516/// which a walking player clears the span before it shuts must cover **≥ 20% of
1517/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
1518/// amount of learning the level makes it fair.
1519#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1520#[serde(deny_unknown_fields)]
1521pub struct TimedGate {
1522    /// Unique timed-gate id (`timed-gate/<kebab>`).
1523    pub id: TimedGateId,
1524    /// The gate anchor the clock drives. Its prefab metadata must declare a fill
1525    /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
1526    pub gate: AnchorId,
1527    /// Ticks the gate stays OPEN each cycle (> 0).
1528    pub open_ticks: u32,
1529    /// Ticks the gate stays CLOSED each cycle (> 0).
1530    pub closed_ticks: u32,
1531    /// Ticks after world init before the first open window begins (default 0) —
1532    /// how two gates in the same room are put out of step with each other. Must
1533    /// be less than the full cycle.
1534    #[serde(default, skip_serializing_if = "is_zero")]
1535    pub phase: u32,
1536    /// Whether the gate **kills** a player caught inside its region when it
1537    /// shuts (owner directive 2026-08-03, spec-0016 §4 addendum). A portcullis
1538    /// that merely shoves you aside teaches nothing; mistiming the crossing is
1539    /// supposed to be a death you learn from, which is the whole point of §4's
1540    /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
1541    /// absolute.
1542    ///
1543    /// This is a real judgement issued by command on the closing tick, not
1544    /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
1545    /// escapable, so it would make the portcullis a suggestion. `damage` at the
1546    /// closing edge is exact and unarguable.
1547    ///
1548    /// **Defaults to `false`**, so every campaign authored before this field
1549    /// existed compiles byte-identically; a delve opts its portcullis in.
1550    #[serde(default, skip_serializing_if = "is_false")]
1551    pub crush: bool,
1552    /// Optional **disarm** affordance (task #184, souls dossier §5.2): the third
1553    /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
1554    /// The real games' best timed hazards can be removed for good (Smouldering
1555    /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
1556    /// dance with is one rung short of the vocabulary.
1557    ///
1558    /// Interacting with the affordance suppresses the clock **permanently, with
1559    /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
1560    /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
1561    /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
1562    ///
1563    /// **Defaults to absent**, so every campaign authored before this field
1564    /// existed compiles byte-identically; a delve opts its portcullis in.
1565    #[serde(default, skip_serializing_if = "Option::is_none")]
1566    pub disarm: Option<TimedGateDisarm>,
1567}
1568
1569/// A [`TimedGate`]'s disarm affordance (task #184, souls dossier §5.2) — the
1570/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
1571/// grammar for every mechanism the party can switch off.
1572///
1573/// The player acts on the `via` anchor (a compiler-emitted interaction entity
1574/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
1575/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
1576/// put the gate back.
1577#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1578#[serde(deny_unknown_fields)]
1579pub struct TimedGateDisarm {
1580    /// The anchor the player interacts with to jam the gate. Must be an anchor
1581    /// some area's prefab provides, and never the gate anchor itself — the
1582    /// mechanism belongs beside the doorway, not inside the span that crushes.
1583    pub via: AnchorId,
1584    /// The flag set when the gate is disarmed (a new flag this gate produces;
1585    /// other objectives/triggers may read it via `requires_flags`).
1586    pub sets_flag: FlagId,
1587}
1588
1589/// serde `skip_serializing_if` helper: skip a `0`.
1590fn is_zero(n: &u32) -> bool {
1591    *n == 0
1592}
1593
1594/// A stage-5 **ambush** (spec-0016 §3) — one declaration for a beat that
1595/// otherwise takes a deferred actor set plus a hand-wired trigger.
1596///
1597/// **`telegraph` is optional, and that is a design ruling, not an oversight**
1598/// (owner, 2026-08-02). The un-telegraphed ambush — the shove off the cliff you
1599/// could not have known about — is core souls vocabulary: 初见杀 is how the level
1600/// teaches. The engine does not sand that edge off.
1601///
1602/// What the engine *does* owe the player is **counterplay on the retry**: having
1603/// died once, an informed player must have something to do about it. Determinism
1604/// guarantees the second attempt meets the same ambushers in the same cells; the
1605/// compiler adds the missing half — `DW0376` proves the trigger cell is not a
1606/// sealed pocket, i.e. that with every ambusher standing where it will stand,
1607/// a route out still exists (a retreat, luring ground, a positioning line).
1608/// Dying uninformed is a lesson; dying with no play available is a broken beat.
1609#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1610#[serde(deny_unknown_fields)]
1611pub struct Ambush {
1612    /// Unique ambush id (`ambush/<kebab>`).
1613    pub id: AmbushId,
1614    /// The anchor the trigger watches — the corner, doorway or ledge the player
1615    /// walks into.
1616    pub at: AnchorId,
1617    /// The stage-5 actors that spring (1..N). Each is summoned at its own
1618    /// declared anchor and immediately unleashed to real AI.
1619    pub actors: Vec<ActorId>,
1620    /// What springs it (`approach{range}` / `strike` / `use`), exactly the v0.4
1621    /// environment-trigger vocabulary.
1622    pub trigger: TriggerOn,
1623    /// The **optional** tell, fired at the trigger before the ambushers exist:
1624    /// a sound, a shadow, a line of narration. Empty = un-telegraphed, which is
1625    /// fully legal (owner ruling 2026-08-02).
1626    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1627    pub telegraph: Vec<QuestEffect>,
1628}
1629
1630impl Ambush {
1631    /// The environment trigger this ambush desugars to (spec-0016 §3): a
1632    /// one-shot trigger at `at` whose effects are the telegraph, then a
1633    /// `spawn-actor` + `unleash-actor` per listed actor, in declared order.
1634    ///
1635    /// This is the **single** expansion authority. Every consumer — validation,
1636    /// the l10n inventory, the flag/wave producer scans, nav, emission — reads
1637    /// triggers through [`QuestsContent::all_triggers`], so the sugar cannot
1638    /// diverge from a hand-written equivalent, and an ambush is exactly as
1639    /// debuggable as the trigger an author would otherwise type.
1640    pub fn to_trigger(&self) -> EnvTrigger {
1641        let mut effects = self.telegraph.clone();
1642        for a in &self.actors {
1643            effects.push(QuestEffect::SpawnActor {
1644                actor: a.clone(),
1645                happening: None,
1646            });
1647        }
1648        for a in &self.actors {
1649            effects.push(QuestEffect::UnleashActor {
1650                actor: a.clone(),
1651                happening: None,
1652            });
1653        }
1654        EnvTrigger {
1655            id: TriggerId(format!(
1656                "trigger/{}",
1657                crate::l10n::local_id(self.id.as_str())
1658            )),
1659            at: Some(self.at.clone()),
1660            on: self.trigger.clone(),
1661            requires_flags: Vec::new(),
1662            forbids_flags: Vec::new(),
1663            once: true,
1664            effects,
1665        }
1666    }
1667}
1668
1669/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
1670///
1671/// The owner's definition of the pattern: between two rest points there are two
1672/// routes. The **short** one starts sealed and holds nothing; the **long** one is
1673/// full of enemies and mechanisms. You earn the far side the hard way, pull one
1674/// mechanism, and the short route opens **forever**. That moment is the design.
1675///
1676/// The compiler owns three obligations, none of them optional:
1677/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
1678///    long route genuinely exists (`DW0373`);
1679/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
1680///    pays nothing is a leak, not a shortcut (`DW0360`);
1681/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
1682///    (`DW0372`). There is no re-sealing verb to reach for.
1683///
1684/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
1685/// untouched by this — the two verbs are deliberately disjoint.
1686#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1687#[serde(deny_unknown_fields)]
1688pub struct Shortcut {
1689    /// Unique shortcut id (`shortcut/<kebab>`).
1690    pub id: ShortcutId,
1691    /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
1692    /// carries the physical fill), and its metadata must declare the fill `block`
1693    /// the compiler clears — the same requirement `close-gate` has.
1694    pub gate: AnchorId,
1695    /// The FAR-side anchor whose interaction fires the permanent open. The
1696    /// compiler summons the affordance there and polls it, reusing the v0.4
1697    /// interaction-entity `use` primitive.
1698    pub unlock: AnchorId,
1699    /// Effects fired once, when the shortcut opens — the bar lifting, the
1700    /// elevator descending, the sound of a door you will never have to earn
1701    /// again. Emitted server-source-safe (the poll lives on the tick).
1702    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1703    pub on_unlock: Vec<QuestEffect>,
1704}
1705
1706/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
1707/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
1708/// entity's attack/interaction records; `approach` is a `distance` selector on
1709/// the tick. Look-at / break-attempt detection is excluded on principle (no
1710/// vanilla primitive).
1711#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1712#[serde(deny_unknown_fields)]
1713pub struct EnvTrigger {
1714    /// Unique trigger id (`trigger/<kebab>`).
1715    pub id: TriggerId,
1716    /// The anchor this trigger watches. Required for `strike` / `use` /
1717    /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
1718    /// v0.6), which watches a *character* and names it in `on.npc` instead —
1719    /// there is no cell for the author to supply and no cell the compiler
1720    /// would use. Either mismatch is `DW0194`.
1721    #[serde(default, skip_serializing_if = "Option::is_none")]
1722    pub at: Option<AnchorId>,
1723    /// The event that fires it.
1724    pub on: TriggerOn,
1725    /// Flags that must be set before the trigger can fire (DSL v0.4).
1726    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1727    pub requires_flags: Vec<FlagId>,
1728    /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the trigger is
1729    /// **suppressed** while ANY listed flag is set (by any player — flags are
1730    /// campaign state). The dual of `requires_flags`, so an "armed between two
1731    /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
1732    /// retaliation trigger with `requires_flags: [flag/sealed]` and
1733    /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
1734    /// the moment the wake beat takes over.
1735    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1736    pub forbids_flags: Vec<FlagId>,
1737    /// Fire at most once (default `true`, mirroring objective completion). Set
1738    /// `false` to allow re-firing every time the condition is met.
1739    #[serde(default = "default_true")]
1740    pub once: bool,
1741    /// Effects fired when the trigger matches.
1742    pub effects: Vec<QuestEffect>,
1743}
1744
1745impl EnvTrigger {
1746    /// The anchor this trigger watches, if it watches a place at all. `None`
1747    /// for `strike-npc`, whose target is a character.
1748    pub fn at_anchor(&self) -> Option<&str> {
1749        self.at.as_ref().map(|a| a.as_str())
1750    }
1751}
1752
1753/// The event an [`EnvTrigger`] watches (DSL v0.4).
1754#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1755#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
1756pub enum TriggerOn {
1757    /// The player attacks (left-clicks) the interaction entity at the anchor.
1758    Strike,
1759    /// The player uses (right-clicks) the interaction entity at the anchor.
1760    Use,
1761    /// The player comes within `range` blocks of the anchor.
1762    Approach {
1763        /// Approach radius (blocks).
1764        range: u32,
1765    },
1766    /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6, reserved
1767    /// `DW0141` earlier).
1768    ///
1769    /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
1770    /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
1771    /// body eclipses that cell (`DW0359`), so the click never reaches the
1772    /// trigger — the owner's island round-7 finding. This form has no cell. It
1773    /// rides the interaction entity the NPC already owns, which is the entity a
1774    /// click on that NPC reaches by construction.
1775    ///
1776    /// Right-click and left-click stay separate all the way down: a
1777    /// `minecraft:interaction` records them in two distinct NBT fields
1778    /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
1779    /// and this trigger takes the left-click, on one shared hitbox.
1780    StrikeNpc {
1781        /// The NPC (stage-2 ref) whose body is the target.
1782        npc: NpcId,
1783    },
1784}
1785
1786impl TriggerOn {
1787    /// The kebab tag (`strike` / `use` / `approach` / `strike-npc`).
1788    pub fn kind(&self) -> &'static str {
1789        match self {
1790            TriggerOn::Strike => "strike",
1791            TriggerOn::Use => "use",
1792            TriggerOn::Approach { .. } => "approach",
1793            TriggerOn::StrikeNpc { .. } => "strike-npc",
1794        }
1795    }
1796
1797    /// Whether this event needs an `at` anchor — true for everything that
1798    /// watches a place, false for `strike-npc`, which watches a character.
1799    pub fn needs_anchor(&self) -> bool {
1800        !matches!(self, TriggerOn::StrikeNpc { .. })
1801    }
1802
1803    /// The NPC whose body this event watches (`strike-npc` only).
1804    pub fn npc_target(&self) -> Option<&NpcId> {
1805        match self {
1806            TriggerOn::StrikeNpc { npc } => Some(npc),
1807            _ => None,
1808        }
1809    }
1810}
1811
1812/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
1813/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
1814/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
1815/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
1816/// and the `kill` objective completes when the count reaches zero.
1817#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1818#[serde(deny_unknown_fields)]
1819pub struct Wave {
1820    /// Unique wave id.
1821    pub id: WaveId,
1822    /// The anchor the wave's mobs spawn at.
1823    pub anchor: AnchorId,
1824    /// The mobs that make up the wave (1..N).
1825    pub mobs: Vec<WaveMob>,
1826    /// Re-seat this wave every time the party rests at (or respawns from) a
1827    /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
1828    /// enemies come back. The compiler kills any survivor carrying the wave tag
1829    /// and re-runs the wave's own spawn function, so the room is restored to its
1830    /// authored composition and spawn cells.
1831    ///
1832    /// Inert without a `bonfire` in the campaign, which is a compile error
1833    /// (`DW0370`) rather than a silent no-op.
1834    #[serde(default, skip_serializing_if = "is_false")]
1835    pub respawns_on_rest: bool,
1836    /// Tower-defense lane routing (spec-0016 §6): march this wave along a
1837    /// waypoint polyline while distant, hand it to native AI the instant a
1838    /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
1839    /// stand), byte-identical.
1840    #[serde(default, skip_serializing_if = "Option::is_none")]
1841    pub lane: Option<WaveLane>,
1842    /// Where the wave's mobs materialize (spec-0016 §6). Absent =
1843    /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
1844    /// wave `anchor`.
1845    #[serde(default, skip_serializing_if = "Option::is_none")]
1846    pub summon: Option<WaveSummon>,
1847    /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
1848    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
1849    ///
1850    /// This is a **declaration, not a knob**: the compiler never scales content
1851    /// from it (spec-0023 "Out of scope"). It exists because the validation
1852    /// ladder's inverted floor gate needs to know which fights the content
1853    /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
1854    /// on its first attempt is reported as too easy for its billing. Marking it
1855    /// is how the author opts into that scrutiny; the alternative — inferring
1856    /// "elite" from how tuned a stack looks — is exactly the downstream folklore
1857    /// CLAUDE.md's no-hack rule forbids.
1858    ///
1859    /// **It does reach emission in exactly one place** (spec-0016 §1, owner
1860    /// ruling 2026-08-05): in a campaign with a `bonfire`, a billed `elite`/
1861    /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
1862    /// rest *while it is still standing* — deleted and re-seated at full count
1863    /// and full health, so chipping it down one life at a time is never a path.
1864    /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
1865    /// `respawns_on_rest` at once.
1866    #[serde(default, skip_serializing_if = "Option::is_none")]
1867    pub tier: Option<EncounterTier>,
1868}
1869
1870/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
1871/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
1872/// — by one emission site: a bonfire refreshes a billed wave that is still
1873/// standing. Nothing about the encounter itself is scaled from it.
1874#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1875#[serde(rename_all = "kebab-case")]
1876pub enum EncounterTier {
1877    /// Trash / pressure. No floor expectation: a bot that wins it cold proves
1878    /// nothing either way. The default.
1879    #[default]
1880    Ordinary,
1881    /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
1882    /// and spawn-and-unleash vocabulary).
1883    Elite,
1884    /// A campaign's named fight. Same floor rule as `elite`; the distinction is
1885    /// for the run report a human reads.
1886    Boss,
1887}
1888
1889impl EncounterTier {
1890    /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
1891    pub fn token(self) -> &'static str {
1892        match self {
1893            EncounterTier::Ordinary => "ordinary",
1894            EncounterTier::Elite => "elite",
1895            EncounterTier::Boss => "boss",
1896        }
1897    }
1898
1899    /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
1900    /// content bills as hard carries an expectation the bot can measure; an
1901    /// ordinary one does not.
1902    pub fn has_floor_expectation(self) -> bool {
1903        matches!(self, EncounterTier::Elite | EncounterTier::Boss)
1904    }
1905}
1906
1907/// Where a wave's mobs materialize (spec-0016 §6).
1908#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1909#[serde(rename_all = "kebab-case")]
1910pub enum WaveSummon {
1911    /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
1912    /// behaviour and the default.
1913    Anchor,
1914    /// **Spirit-summoned at the edge of perception** (owner design 2026-08-02):
1915    /// each mob appears on the ring at its own `attributes.follow_range` from
1916    /// the wave `anchor`, so it acquires a target the instant it exists and
1917    /// closes under pure native AI. Species without patrol AI never march a
1918    /// lane; this is what they do instead — never a spawn on top of the party,
1919    /// never a mob that brushes past.
1920    ///
1921    /// With this mode the wave `anchor` is the **defended point** (what the ring
1922    /// is drawn around), not the spawn point. Each mob stack must declare its
1923    /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
1924    /// never guessed from a vanilla defaults table the compiler cannot verify.
1925    AggroEdge,
1926}
1927
1928/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
1929/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
1930/// (`docs/notes/td-routing-spike.md`).
1931///
1932/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
1933/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
1934/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
1935/// From that instant the mob is a plain native hostile. "Combat preempts
1936/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
1937/// no target — so the owner's rule (march while distant, fight with NATIVE AI
1938/// once aggroed, never brush past) falls out of the primitive unforced.
1939///
1940/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
1941/// pillager must keep its crossbow (`DW0384`).
1942#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1943#[serde(deny_unknown_fields)]
1944pub struct WaveLane {
1945    /// The lane polyline, in march order: anchors some area's prefab provides.
1946    /// At least one; consecutive legs (counting the wave `anchor` as the origin)
1947    /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
1948    /// within 10 blocks of arrival, so a tighter lane is a lane the engine
1949    /// quietly stops following (`DW0386`).
1950    pub waypoints: Vec<AnchorId>,
1951    /// The release radius, in blocks: the compiler sets every lane mob's
1952    /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
1953    /// at the same distance. The two MUST be equal — a patrolling raider that
1954    /// targets a player it cannot engage holds ground instead of marching, so a
1955    /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
1956    pub aggro_radius: u32,
1957}
1958
1959/// One mob stack in a [`Wave`].
1960#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1961#[serde(deny_unknown_fields)]
1962pub struct WaveMob {
1963    /// Vanilla entity id, validated against the pinned 1.21.11 registry.
1964    pub entity: String,
1965    /// How many to spawn.
1966    pub count: u32,
1967    /// Optional custom name (shown above the mob).
1968    #[serde(default, skip_serializing_if = "Option::is_none")]
1969    pub name: Option<String>,
1970    /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
1971    /// components. Enables e.g. a weakened live warden as a survivable stealth
1972    /// threat. Omitted = vanilla defaults.
1973    #[serde(default, skip_serializing_if = "Option::is_none")]
1974    pub attributes: Option<MobAttributes>,
1975    /// Optional permanent, ambient status effects (DSL v0.4).
1976    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1977    pub effects: Vec<MobEffect>,
1978    /// Optional worn/held equipment (DSL v0.6, task #65). A helmet is the
1979    /// sanctioned fix for daylight-burning undead (owner ruling) — never
1980    /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
1981    /// (`DW0143`, the give-item family); every emitted slot carries drop
1982    /// chance 0 so players can never farm wave gear (no-grind constitution).
1983    #[serde(default, skip_serializing_if = "Option::is_none")]
1984    pub equipment: Option<MobEquipment>,
1985    /// What this mob leaves behind when it dies (DSL v0.9, task #179; reserved
1986    /// `DW0141` earlier). Only an `elite`/`boss` wave may declare it (`DW0491`)
1987    /// — an ordinary mob's kit is never farmable. Empty = today's behaviour,
1988    /// drop chance 0 on every slot.
1989    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1990    pub drops: Vec<MobDrop>,
1991}
1992
1993/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
1994/// id for the matching vanilla equipment slot; an unset slot stays empty —
1995/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
1996/// wither-skeleton sword) still applies unless overridden. Emitted as the
1997/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
1998/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
1999#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2000#[serde(deny_unknown_fields)]
2001pub struct MobEquipment {
2002    /// Head slot.
2003    #[serde(default, skip_serializing_if = "Option::is_none")]
2004    pub head: Option<EquipItem>,
2005    /// Chest slot.
2006    #[serde(default, skip_serializing_if = "Option::is_none")]
2007    pub chest: Option<EquipItem>,
2008    /// Legs slot.
2009    #[serde(default, skip_serializing_if = "Option::is_none")]
2010    pub legs: Option<EquipItem>,
2011    /// Feet slot.
2012    #[serde(default, skip_serializing_if = "Option::is_none")]
2013    pub feet: Option<EquipItem>,
2014    /// Main-hand slot. Overrides the compiler's armed-mob default.
2015    #[serde(default, skip_serializing_if = "Option::is_none")]
2016    pub main_hand: Option<EquipItem>,
2017    /// Off-hand slot.
2018    #[serde(default, skip_serializing_if = "Option::is_none")]
2019    pub off_hand: Option<EquipItem>,
2020}
2021
2022impl MobEquipment {
2023    /// Every slot as `(dsl_field_name, piece)`, in the fixed schema order —
2024    /// the single iteration source for validation paths and emission.
2025    pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); 6] {
2026        [
2027            ("head", self.head.as_ref()),
2028            ("chest", self.chest.as_ref()),
2029            ("legs", self.legs.as_ref()),
2030            ("feet", self.feet.as_ref()),
2031            ("main_hand", self.main_hand.as_ref()),
2032            ("off_hand", self.off_hand.as_ref()),
2033        ]
2034    }
2035
2036    /// The piece this equipment declaration puts in `slot`, if any. The single
2037    /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
2038    /// actually wears (`DW0490`).
2039    pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
2040        match slot {
2041            EquipSlot::Head => self.head.as_ref(),
2042            EquipSlot::Chest => self.chest.as_ref(),
2043            EquipSlot::Legs => self.legs.as_ref(),
2044            EquipSlot::Feet => self.feet.as_ref(),
2045            EquipSlot::MainHand => self.main_hand.as_ref(),
2046            EquipSlot::OffHand => self.off_hand.as_ref(),
2047        }
2048    }
2049}
2050
2051/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
2052/// fills it (DSL v0.9, task #179). The DSL name and the summon-NBT key differ
2053/// (`main_hand` vs `mainhand`), so both live here and nowhere else.
2054#[derive(
2055    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2056)]
2057#[serde(rename_all = "snake_case")]
2058pub enum EquipSlot {
2059    /// Head slot.
2060    Head,
2061    /// Chest slot.
2062    Chest,
2063    /// Legs slot.
2064    Legs,
2065    /// Feet slot.
2066    Feet,
2067    /// Main-hand slot.
2068    MainHand,
2069    /// Off-hand slot.
2070    OffHand,
2071}
2072
2073impl EquipSlot {
2074    /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
2075    pub fn field(self) -> &'static str {
2076        match self {
2077            EquipSlot::Head => "head",
2078            EquipSlot::Chest => "chest",
2079            EquipSlot::Legs => "legs",
2080            EquipSlot::Feet => "feet",
2081            EquipSlot::MainHand => "main_hand",
2082            EquipSlot::OffHand => "off_hand",
2083        }
2084    }
2085
2086    /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`).
2087    pub fn nbt(self) -> &'static str {
2088        match self {
2089            EquipSlot::Head => "head",
2090            EquipSlot::Chest => "chest",
2091            EquipSlot::Legs => "legs",
2092            EquipSlot::Feet => "feet",
2093            EquipSlot::MainHand => "mainhand",
2094            EquipSlot::OffHand => "offhand",
2095        }
2096    }
2097}
2098
2099/// One declared drop of an elite or boss (DSL v0.9, task #179; owner ruling
2100/// 2026-08-04).
2101///
2102/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
2103/// usually one piece and never automatically everything. Two forms, told apart
2104/// by which field is present:
2105///
2106/// ```json
2107/// { "slot": "main_hand" }                                  // its axe
2108/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
2109/// ```
2110///
2111/// A `slot` entry must name a slot the same entity's `equipment` really fills
2112/// (`DW0490`) — the drop is the piece the player has been *looking at* through
2113/// the whole fight, so the two declarations cannot disagree. An `item` entry is
2114/// a token the fight *yields* rather than wears, and rides the entity's own
2115/// death loot table.
2116///
2117/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
2118/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
2119#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2120#[serde(untagged)]
2121pub enum MobDrop {
2122    /// A worn/held piece, named by its equipment slot.
2123    Slot(SlotDrop),
2124    /// A quest token the fight yields (not worn).
2125    Item(ItemDrop),
2126}
2127
2128/// The worn-piece form of a [`MobDrop`].
2129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2130#[serde(deny_unknown_fields)]
2131pub struct SlotDrop {
2132    /// The equipment slot whose piece drops. Must be filled by the entity's own
2133    /// `equipment` declaration (`DW0490`).
2134    pub slot: EquipSlot,
2135}
2136
2137/// The quest-token form of a [`MobDrop`].
2138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2139#[serde(deny_unknown_fields)]
2140pub struct ItemDrop {
2141    /// Vanilla item id, validated against the pinned 1.21.11 registry
2142    /// (`DW0143`, the give-item family).
2143    pub item: String,
2144    /// Display name the dropped stack carries as a `custom_name` component.
2145    /// Player-visible, so it enters the l10n string inventory and translates
2146    /// like any other line.
2147    #[serde(default, skip_serializing_if = "Option::is_none")]
2148    pub name: Option<String>,
2149}
2150
2151impl MobDrop {
2152    /// The equipment slot this entry names, or `None` for a quest-item drop.
2153    pub fn slot(&self) -> Option<EquipSlot> {
2154        match self {
2155            MobDrop::Slot(s) => Some(s.slot),
2156            MobDrop::Item(_) => None,
2157        }
2158    }
2159
2160    /// The item id this entry names, or `None` for a worn-piece drop.
2161    pub fn item(&self) -> Option<&str> {
2162        match self {
2163            MobDrop::Slot(_) => None,
2164            MobDrop::Item(i) => Some(&i.item),
2165        }
2166    }
2167
2168    /// The declared display name of a quest-item drop, when set.
2169    pub fn name(&self) -> Option<&String> {
2170        match self {
2171            MobDrop::Slot(_) => None,
2172            MobDrop::Item(i) => i.name.as_ref(),
2173        }
2174    }
2175
2176    /// Mutable access to the display name — the l10n traversal's hook.
2177    pub fn name_mut(&mut self) -> Option<&mut String> {
2178        match self {
2179            MobDrop::Slot(_) => None,
2180            MobDrop::Item(i) => i.name.as_mut(),
2181        }
2182    }
2183}
2184
2185/// One equipped item: either a bare item id, or an id carrying enchantments.
2186///
2187/// The plain form is the common case and stays a plain JSON string, which is
2188/// what keeps every campaign written before enchantments existed byte-identical
2189/// on re-serialisation:
2190///
2191/// ```json
2192/// "main_hand": "minecraft:netherite_sword"
2193/// "head": { "item": "minecraft:netherite_helmet",
2194///           "enchantments": { "minecraft:protection": 4 } }
2195/// ```
2196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2197#[serde(untagged)]
2198pub enum EquipItem {
2199    /// A bare item id — no enchantments.
2200    Plain(String),
2201    /// An item id plus its enchantments.
2202    Enchanted(EnchantedItem),
2203}
2204
2205impl EquipItem {
2206    /// The item id, whichever form was authored.
2207    pub fn item(&self) -> &str {
2208        match self {
2209            EquipItem::Plain(s) => s,
2210            EquipItem::Enchanted(e) => &e.item,
2211        }
2212    }
2213
2214    /// The enchantments on this piece — empty for the plain form. `BTreeMap`
2215    /// ordered, so emission order is the id order and never hash order
2216    /// (ADR-0006).
2217    pub fn enchantments(&self) -> &BTreeMap<String, u32> {
2218        static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
2219            std::sync::LazyLock::new(BTreeMap::new);
2220        match self {
2221            EquipItem::Plain(_) => &EMPTY,
2222            EquipItem::Enchanted(e) => &e.enchantments,
2223        }
2224    }
2225}
2226
2227/// The enchanted form of [`EquipItem`].
2228#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2229#[serde(deny_unknown_fields)]
2230pub struct EnchantedItem {
2231    /// Item id (e.g. `minecraft:netherite_chestplate`).
2232    pub item: String,
2233    /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
2234    /// the 1.21 `minecraft:enchantments` item component.
2235    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2236    pub enchantments: BTreeMap<String, u32>,
2237}
2238
2239/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
2240/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
2241#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2242#[serde(deny_unknown_fields)]
2243pub struct MobAttributes {
2244    /// `minecraft:max_health` base value.
2245    #[serde(default, skip_serializing_if = "Option::is_none")]
2246    pub max_health: Option<f64>,
2247    /// `minecraft:attack_damage` base value.
2248    #[serde(default, skip_serializing_if = "Option::is_none")]
2249    pub attack_damage: Option<f64>,
2250    /// `minecraft:movement_speed` base value.
2251    #[serde(default, skip_serializing_if = "Option::is_none")]
2252    pub movement_speed: Option<f64>,
2253    /// `minecraft:follow_range` base value.
2254    #[serde(default, skip_serializing_if = "Option::is_none")]
2255    pub follow_range: Option<f64>,
2256}
2257
2258/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
2259/// non-expiring `effect give`.
2260#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2261#[serde(deny_unknown_fields)]
2262pub struct MobEffect {
2263    /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
2264    /// pinned registry (`DW0192`).
2265    pub effect: String,
2266    /// Amplifier (0 = level I).
2267    pub amplifier: u32,
2268}
2269
2270/// One expanded quest.
2271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2272#[serde(deny_unknown_fields)]
2273pub struct Quest {
2274    /// Quest id (matches a stage-4 planned quest).
2275    pub id: QuestId,
2276    /// What starts the quest.
2277    pub trigger: Trigger,
2278    /// Ordered objectives (intra-quest DAG via `after`).
2279    pub objectives: Vec<Objective>,
2280    /// Effects fired when a given objective completes.
2281    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2282    pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
2283    /// Effects fired when the whole quest completes.
2284    pub on_complete: Vec<QuestEffect>,
2285    /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
2286    /// live during this quest — spawned and not explicitly removed — where it
2287    /// stands, what it is doing, and what its right-click offers *for this
2288    /// quest's duration*.
2289    ///
2290    /// The ledger exists because an NPC's dialogue used to be one tree for the
2291    /// whole campaign: after the climactic escape a crew member still offered
2292    /// "Tell me what he is." — a premise question absurd once the story moved on.
2293    /// Declaring the scene per quest makes the compiler able to check it
2294    /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
2295    /// emitted right-click shows the root this quest declares, so a stale root
2296    /// retires *because the ledger says so*, not because an author remembered a
2297    /// flag.
2298    ///
2299    /// Empty/absent before v0.7 (`DW0465` deprecation warning for one version
2300    /// window), so a campaign that declares none compiles byte-identically.
2301    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2302    pub cast: BTreeMap<NpcId, CastEntry>,
2303    /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
2304    /// `DW0481`; reserved `DW0141` earlier).
2305    #[serde(default, skip_serializing_if = "Option::is_none")]
2306    pub happening: Option<Happening>,
2307}
2308
2309/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
2310///
2311/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
2312/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
2313/// per-branch casts, each gated by the flags that select its branch (spec-0020
2314/// proof 4: where the effect history is branch-dependent, a single flat
2315/// declaration cannot hold on every reachable branch, and `DW0462` says so).
2316#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2317#[serde(untagged)]
2318pub enum CastEntry {
2319    /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
2320    /// placement whose `at` is that keyword and which carries no `doing` or
2321    /// `dialogue` — a character who is not in the world has no business and
2322    /// answers no right-click.
2323    Absent(CastAbsence),
2324    /// A single placement that must hold on every reachable branch.
2325    Placement(CastPlacement),
2326    /// Per-branch placements (spec-0020 proof 4). Each carries the
2327    /// `requires_flags`/`forbids_flags` that select its branch.
2328    Branches(Vec<CastPlacement>),
2329}
2330
2331impl CastEntry {
2332    /// Every placement this entry declares, in declared order. The bare-keyword
2333    /// form yields none — there is no scene to check.
2334    pub fn placements(&self) -> Vec<&CastPlacement> {
2335        match self {
2336            CastEntry::Absent(_) => Vec::new(),
2337            CastEntry::Placement(p) => vec![p],
2338            CastEntry::Branches(ps) => ps.iter().collect(),
2339        }
2340    }
2341
2342    /// Every placement this entry declares, mutably (the l10n traversal).
2343    pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
2344        match self {
2345            CastEntry::Absent(_) => Vec::new(),
2346            CastEntry::Placement(p) => vec![p],
2347            CastEntry::Branches(ps) => ps.iter_mut().collect(),
2348        }
2349    }
2350
2351    /// The bare-keyword absence this entry declares, if it is that form.
2352    pub fn absence(&self) -> Option<CastAbsence> {
2353        match self {
2354            CastEntry::Absent(a) => Some(*a),
2355            _ => None,
2356        }
2357    }
2358
2359    /// True if this entry declares the NPC out of the world entirely — the bare
2360    /// keyword, or every placement's `at` being a keyword.
2361    pub fn is_absent(&self) -> bool {
2362        match self {
2363            CastEntry::Absent(_) => true,
2364            CastEntry::Placement(p) => p.at.absence().is_some(),
2365            CastEntry::Branches(ps) => {
2366                !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
2367            }
2368        }
2369    }
2370}
2371
2372/// A declared absence: the NPC is deliberately not in the world.
2373#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2374#[serde(rename_all = "kebab-case")]
2375pub enum CastAbsence {
2376    /// Removed from the story for good (must match a `despawn-npc` with no later
2377    /// `spawn-npc`).
2378    Dead,
2379    /// Not in the world for this quest, but may return (must match a
2380    /// `despawn-npc`).
2381    Offstage,
2382}
2383
2384impl CastAbsence {
2385    /// The authored keyword (`dead` / `offstage`).
2386    pub fn token(self) -> &'static str {
2387        match self {
2388            CastAbsence::Dead => "dead",
2389            CastAbsence::Offstage => "offstage",
2390        }
2391    }
2392}
2393
2394/// Where a cast entry puts an NPC: a prefab anchor, or a declared absence.
2395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2396#[serde(untagged)]
2397pub enum CastPlace {
2398    /// `"dead"` or `"offstage"` — explicitly not in the world.
2399    Absent(CastAbsence),
2400    /// The anchor the NPC stands on for this quest's duration. Must equal the
2401    /// position the effect history actually produces (`DW0461`): declaring an
2402    /// anchor does not teleport anybody.
2403    Anchor(AnchorId),
2404}
2405
2406impl CastPlace {
2407    /// The anchor this place names, if it is an anchor.
2408    pub fn anchor(&self) -> Option<&AnchorId> {
2409        match self {
2410            CastPlace::Anchor(a) => Some(a),
2411            CastPlace::Absent(_) => None,
2412        }
2413    }
2414
2415    /// The declared absence, if this place is one.
2416    pub fn absence(&self) -> Option<CastAbsence> {
2417        match self {
2418            CastPlace::Absent(a) => Some(*a),
2419            CastPlace::Anchor(_) => None,
2420        }
2421    }
2422
2423    /// The authored token, for diagnostics.
2424    pub fn token(&self) -> &str {
2425        match self {
2426            CastPlace::Absent(a) => a.token(),
2427            CastPlace::Anchor(a) => a.as_str(),
2428        }
2429    }
2430}
2431
2432/// One declared scene for one NPC in one quest.
2433#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2434#[serde(deny_unknown_fields)]
2435pub struct CastPlacement {
2436    /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
2437    pub at: CastPlace,
2438    /// What the character is *doing* — free prose, never machine-checked.
2439    ///
2440    /// This field is the forcing function, which is the whole reason it is
2441    /// required (`DW0463`) despite being unverifiable: you cannot fill it in
2442    /// without deciding the character's business in this beat, and stage 6
2443    /// receives it as the context the NPC's lines are written against.
2444    #[serde(default, skip_serializing_if = "Option::is_none")]
2445    pub doing: Option<String>,
2446    /// What right-click offers during this quest. Required for an on-stage
2447    /// placement (`DW0463`) — including the explicit `"none"`.
2448    #[serde(default, skip_serializing_if = "Option::is_none")]
2449    pub dialogue: Option<CastDialogue>,
2450    /// Branch gate (per-branch casts): this placement describes the world only
2451    /// once every listed flag is set. Mirrors an option's `requires_flags`.
2452    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2453    pub requires_flags: Vec<FlagId>,
2454    /// Negative branch gate: this placement describes the world only while no
2455    /// listed flag is set. Mirrors an option's `forbids_flags`.
2456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2457    pub forbids_flags: Vec<FlagId>,
2458}
2459
2460/// What an NPC's right-click offers for a quest's duration.
2461///
2462/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
2463/// `{"barks": […]}` pool, then a dialogue root id.
2464#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2465#[serde(untagged)]
2466pub enum CastDialogue {
2467    /// A keyword: `"none"` or `"unchanged"`.
2468    Keyword(CastDialogueKeyword),
2469    /// A bark pool: right-click yields one inconsequential in-character line —
2470    /// no tree, no options, no consequences.
2471    Barks(CastBarks),
2472    /// A dialogue root id: right-click opens this node of the NPC's stage-6
2473    /// tree. Must be a node of *that* NPC's tree (`DW0464`).
2474    Root(DialogueId),
2475}
2476
2477/// The keyword forms of [`CastDialogue`].
2478#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2479#[serde(rename_all = "kebab-case")]
2480pub enum CastDialogueKeyword {
2481    /// Genuinely no reaction: the right-click is recorded and consumed, and
2482    /// nothing opens. Legal, but a last resort — if a body is clickable, the
2483    /// world should answer; prefer a bark.
2484    None,
2485    /// Carry forward whatever this NPC's dialogue was at its **previous**
2486    /// appearance in the quest-DAG ordering (owner amendment, 2026-08-03).
2487    ///
2488    /// The point is that carrying dialogue forward is a *conscious, declared
2489    /// act*. It is never an implicit default — an omitted `dialogue` is
2490    /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
2491    /// intent without re-spelling a root id that then drifts out of sync. It
2492    /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
2493    /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
2494    ///
2495    /// Emission is a **no-op** — no root swap is emitted for that NPC at that
2496    /// quest — which is what makes the sugar cheap and byte-stable.
2497    Unchanged,
2498}
2499
2500/// A bark pool: inconsequential in-character lines, cycled deterministically.
2501#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2502#[serde(deny_unknown_fields)]
2503pub struct CastBarks {
2504    /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
2505    /// they enter the l10n inventory like any narrate text.
2506    pub barks: Vec<String>,
2507}
2508
2509/// What triggers a quest.
2510#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2511#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
2512pub enum Trigger {
2513    /// Fires when the campaign starts.
2514    CampaignStart,
2515    /// Fires when another quest completes.
2516    QuestComplete {
2517        /// The prerequisite quest.
2518        quest: QuestId,
2519    },
2520}
2521
2522/// A quest objective.
2523///
2524/// `kill`, `collect` and `interact` are **implemented in DSL v0.3**; in a v0.2
2525/// campaign they are still reserved and rejected with `DW0141` (see
2526/// [`Objective::v03_verb`]). Every variant may carry `requires_flags` (v0.3):
2527/// flag-gated activation, satisfied only once each referenced flag has been set
2528/// by a `set-flag` effect.
2529#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2530#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
2531pub enum Objective {
2532    /// Completed by a dialogue option's `complete-objective` effect.
2533    TalkTo {
2534        /// Objective id.
2535        id: ObjectiveId,
2536        /// Short player-facing objective name (v0.3, optional).
2537        #[serde(default, skip_serializing_if = "Option::is_none")]
2538        title: Option<String>,
2539        /// One-line location/direction hint (v0.3, optional).
2540        #[serde(default, skip_serializing_if = "Option::is_none")]
2541        hint: Option<String>,
2542        /// The NPC to talk to.
2543        npc: NpcId,
2544        /// Prerequisite objectives (intra-quest ordering).
2545        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2546        after: Vec<ObjectiveId>,
2547        /// Flags that must be set before this objective activates (v0.3).
2548        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2549        requires_flags: Vec<FlagId>,
2550        /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2551        /// objective is suppressed (cannot activate or complete) while ANY listed
2552        /// flag is set for the player — the dual of `requires_flags`.
2553        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2554        forbids_flags: Vec<FlagId>,
2555        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2556        /// bot should traverse sneaking (sprint disabled). Emitted into
2557        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2558        /// hint; no datapack effect.
2559        #[serde(default, skip_serializing_if = "is_false")]
2560        stealth: bool,
2561        /// What this objective does to the story (DSL v0.8, spec-0025; required
2562        /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2563        #[serde(default, skip_serializing_if = "Option::is_none")]
2564        happening: Option<Happening>,
2565    },
2566    /// Completed by reaching an anchor once prerequisites are met.
2567    ReachAnchor {
2568        /// Objective id.
2569        id: ObjectiveId,
2570        /// Short player-facing objective name (v0.3, optional).
2571        #[serde(default, skip_serializing_if = "Option::is_none")]
2572        title: Option<String>,
2573        /// One-line location/direction hint (v0.3, optional).
2574        #[serde(default, skip_serializing_if = "Option::is_none")]
2575        hint: Option<String>,
2576        /// The anchor to reach.
2577        anchor: AnchorId,
2578        /// Completion radius (blocks).
2579        radius: u32,
2580        /// Prerequisite objectives (intra-quest ordering).
2581        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2582        after: Vec<ObjectiveId>,
2583        /// Flags that must be set before this objective activates (v0.3).
2584        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2585        requires_flags: Vec<FlagId>,
2586        /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2587        /// objective is suppressed (cannot activate or complete) while ANY listed
2588        /// flag is set for the player — the dual of `requires_flags`.
2589        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2590        forbids_flags: Vec<FlagId>,
2591        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2592        /// bot should traverse sneaking (sprint disabled). Emitted into
2593        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2594        /// hint; no datapack effect.
2595        #[serde(default, skip_serializing_if = "is_false")]
2596        stealth: bool,
2597        /// What this objective does to the story (DSL v0.8, spec-0025; required
2598        /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2599        #[serde(default, skip_serializing_if = "Option::is_none")]
2600        happening: Option<Happening>,
2601    },
2602    /// Completed when the referenced wave is fully slain (v0.3).
2603    Kill {
2604        /// Objective id.
2605        id: ObjectiveId,
2606        /// Short player-facing objective name (v0.3, optional).
2607        #[serde(default, skip_serializing_if = "Option::is_none")]
2608        title: Option<String>,
2609        /// One-line location/direction hint (v0.3, optional).
2610        #[serde(default, skip_serializing_if = "Option::is_none")]
2611        hint: Option<String>,
2612        /// The wave (stage-5 `waves` ref) whose mobs must be slain.
2613        wave: WaveId,
2614        /// Prerequisite objectives.
2615        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2616        after: Vec<ObjectiveId>,
2617        /// Flags that must be set before this objective activates (v0.3).
2618        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2619        requires_flags: Vec<FlagId>,
2620        /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2621        /// objective is suppressed (cannot activate or complete) while ANY listed
2622        /// flag is set for the player — the dual of `requires_flags`.
2623        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2624        forbids_flags: Vec<FlagId>,
2625        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2626        /// bot should traverse sneaking (sprint disabled). Emitted into
2627        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2628        /// hint; no datapack effect.
2629        #[serde(default, skip_serializing_if = "is_false")]
2630        stealth: bool,
2631        /// What this objective does to the story (DSL v0.8, spec-0025; required
2632        /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2633        #[serde(default, skip_serializing_if = "Option::is_none")]
2634        happening: Option<Happening>,
2635    },
2636    /// Completed when `count` of `item` have been collected (v0.3).
2637    ///
2638    /// The items are provided in a container: the compiler's own chest at
2639    /// `anchor` by default, or — since DSL v0.8 — the prefab's existing
2640    /// chest/barrel at [`Objective::Collect::container`], optionally carrying an
2641    /// [`Objective::Collect::item_name`] and padded to read full with
2642    /// [`Objective::Collect::fill_count`].
2643    Collect {
2644        /// Objective id.
2645        id: ObjectiveId,
2646        /// Short player-facing objective name (v0.3, optional).
2647        #[serde(default, skip_serializing_if = "Option::is_none")]
2648        title: Option<String>,
2649        /// One-line location/direction hint (v0.3, optional).
2650        #[serde(default, skip_serializing_if = "Option::is_none")]
2651        hint: Option<String>,
2652        /// Vanilla item id to collect (validated against the registry).
2653        item: String,
2654        /// How many are required.
2655        count: u32,
2656        /// The anchor items are provided at (chest / pickup).
2657        anchor: AnchorId,
2658        /// **Adopt the container the prefab already placed** (DSL v0.8, task #95;
2659        /// reserved `DW0141` earlier): the anchor whose assembled-world cell holds
2660        /// a `chest` / `trapped_chest` / `barrel` this collect fills instead of
2661        /// conjuring its own chest at [`Objective::Collect::anchor`].
2662        ///
2663        /// Same division of labour a `loot` entry and a trap's dispenser already
2664        /// keep with the prefab: furniture belongs in the piece. A beach camp's
2665        /// barrel is scenery the player has been walking past since minute one —
2666        /// having the compiler `setblock` a *second*, floating chest beside it to
2667        /// hold the quest item is exactly the downstream workaround the no-hack
2668        /// rule forbids. A `container` whose cell holds no container is a build
2669        /// error (`DW0438`), never a silent fill into a wall.
2670        ///
2671        /// The critical-path step's position follows the container (the bot opens
2672        /// *this* block), and no chest is placed at `anchor` when it is set.
2673        #[serde(default, skip_serializing_if = "Option::is_none")]
2674        container: Option<AnchorId>,
2675        /// **The item comes off a body, not out of a box** (DSL v0.9, task #179;
2676        /// reserved `DW0141` earlier): the wave whose declared `drops[]` yield
2677        /// this objective's item. No container is placed — not the compiler's own
2678        /// chest at `anchor`, not a prefab one — and `container` is therefore
2679        /// mutually exclusive with it (`DW0100`-adjacent; `DW0492`).
2680        ///
2681        /// This is what makes "kill the boss → pick up its key → open the door"
2682        /// a *proved* chain rather than an authoring intention. The compiler
2683        /// requires (a) that the named wave really declares an `{item}` drop of
2684        /// this item (`DW0492`), and (b) that a `kill` objective for that wave
2685        /// precedes this collect in the objective graph (`DW0493`). The existing
2686        /// flow machinery then carries the ordering the rest of the way: the
2687        /// door's `requires_flags` hangs off this collect exactly as it would off
2688        /// a chest one.
2689        ///
2690        /// **Waves only.** An actor's death is not observable by any objective —
2691        /// there is no vanilla-side signal the flow machinery could consume — so
2692        /// an actor-gated collect would be an unprovable claim, and per the
2693        /// no-hack doctrine it is excluded rather than approximated. An actor may
2694        /// still declare `drops[]`; those drops just cannot gate a quest.
2695        #[serde(default, skip_serializing_if = "Option::is_none")]
2696        dropped_by: Option<WaveId>,
2697        /// Display name for the collected item (DSL v0.8, task #95; reserved
2698        /// `DW0141` earlier), emitted as the vanilla `custom_name` item component.
2699        ///
2700        /// A quest item is a *named thing* in the story ("Cheese", "Tide
2701        /// Ledger"), and a player who opens the barrel must read that name — an
2702        /// unnamed `minecraft:pumpkin_pie` says nothing about what the quest asked
2703        /// for. Player-visible, so it enters the l10n string inventory
2704        /// (`obj.<quest>.<obj>.item_name`) and translates like any other line.
2705        ///
2706        /// Naming changes nothing about adjudication: the completion advancement
2707        /// and the per-tick held check both match on the ITEM ID, which a named
2708        /// stack still carries.
2709        #[serde(default, skip_serializing_if = "Option::is_none")]
2710        item_name: Option<String>,
2711        /// Padding stacks that make the container **read full** (DSL v0.8, task
2712        /// #95; reserved `DW0141` earlier). Default `0` = the single required
2713        /// stack, exactly as every pre-0.8 campaign emits.
2714        ///
2715        /// A barrel of cheese that opens on one lonely wheel reads as a bug, and
2716        /// vanilla's notion of "full" is *occupied slots*, not stack size — so
2717        /// this counts SLOTS: the objective's own stack lands in `container.0` and
2718        /// each padding stack repeats it in `container.1`, `container.2`, … Slot
2719        /// assignment is positional and total, the same determinism story `loot`
2720        /// tells (ADR-0006): no RNG, no loot tables, nothing to reseed.
2721        ///
2722        /// The padding is the same item, so taking the whole barrel still
2723        /// completes the objective and never over- or under-counts it.
2724        #[serde(default, skip_serializing_if = "is_zero")]
2725        fill_count: u32,
2726        /// Prerequisite objectives.
2727        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2728        after: Vec<ObjectiveId>,
2729        /// Flags that must be set before this objective activates (v0.3).
2730        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2731        requires_flags: Vec<FlagId>,
2732        /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2733        /// objective is suppressed (cannot activate or complete) while ANY listed
2734        /// flag is set for the player — the dual of `requires_flags`.
2735        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2736        forbids_flags: Vec<FlagId>,
2737        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2738        /// bot should traverse sneaking (sprint disabled). Emitted into
2739        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2740        /// hint; no datapack effect.
2741        #[serde(default, skip_serializing_if = "is_false")]
2742        stealth: bool,
2743        /// What this objective does to the story (DSL v0.8, spec-0025; required
2744        /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2745        #[serde(default, skip_serializing_if = "Option::is_none")]
2746        happening: Option<Happening>,
2747    },
2748    /// Completed by interacting with an entity at `anchor`; if `requires_item` is
2749    /// set, the item must be **held in the main hand** (v0.3; held semantics since
2750    /// DSL v0.7 — see [`Objective::Interact::requires_item`]).
2751    Interact {
2752        /// Objective id.
2753        id: ObjectiveId,
2754        /// Short player-facing objective name (v0.3, optional).
2755        #[serde(default, skip_serializing_if = "Option::is_none")]
2756        title: Option<String>,
2757        /// One-line location/direction hint (v0.3, optional).
2758        #[serde(default, skip_serializing_if = "Option::is_none")]
2759        hint: Option<String>,
2760        /// The anchor the interaction entity stands at.
2761        anchor: AnchorId,
2762        /// Item the player must be **holding in the main hand** for the
2763        /// interaction to complete (optional).
2764        ///
2765        /// Held, not merely possessed (owner ruling, 2026-08-03): presenting the
2766        /// item IS the action — a player who right-clicks a sleeping giant with a
2767        /// sharpened stake buried in their backpack has not stabbed anything.
2768        /// Before this ruling the gate read the whole inventory, which made every
2769        /// `requires_item` interaction fire the moment the item was picked up
2770        /// anywhere, whatever the player was actually doing with their hands.
2771        #[serde(default, skip_serializing_if = "Option::is_none")]
2772        requires_item: Option<String>,
2773        /// Diegetic feedback for a click that arrives without the required item in
2774        /// hand (DSL v0.7, reserved `DW0141` earlier): narrated to that player in
2775        /// chat instead of the silence the gate used to answer with. Requires
2776        /// `requires_item` (`DW0437`).
2777        ///
2778        /// Only fires while the objective is genuinely open — same activation gate
2779        /// as the affordance itself — so a finished or not-yet-active interaction
2780        /// stays quiet.
2781        #[serde(default, skip_serializing_if = "Option::is_none")]
2782        missing_item_hint: Option<String>,
2783        /// Prop block that IS the interaction affordance (DSL v0.4, spec-0008
2784        /// §2): the compiler `setblock`s it at the anchor on activation (exactly
2785        /// as `collect` uses a real chest). Omitted = the glowing-lantern
2786        /// hologram marker (the v0.3 fallback).
2787        #[serde(default, skip_serializing_if = "Option::is_none")]
2788        prop: Option<Prop>,
2789        /// Prerequisite objectives.
2790        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2791        after: Vec<ObjectiveId>,
2792        /// Flags that must be set before this objective activates (v0.3).
2793        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2794        requires_flags: Vec<FlagId>,
2795        /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2796        /// objective is suppressed (cannot activate or complete) while ANY listed
2797        /// flag is set for the player — the dual of `requires_flags`.
2798        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2799        forbids_flags: Vec<FlagId>,
2800        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2801        /// bot should traverse sneaking (sprint disabled). Emitted into
2802        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2803        /// hint; no datapack effect.
2804        #[serde(default, skip_serializing_if = "is_false")]
2805        stealth: bool,
2806        /// What this objective does to the story (DSL v0.8, spec-0025; required
2807        /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2808        #[serde(default, skip_serializing_if = "Option::is_none")]
2809        happening: Option<Happening>,
2810    },
2811}
2812
2813/// A prop block for an `interact` objective (DSL v0.4). The block is the
2814/// affordance the player interacts with; its id is validated against the pinned
2815/// 1.21.11 block registry (`DW0193`).
2816#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2817#[serde(deny_unknown_fields)]
2818pub struct Prop {
2819    /// Vanilla block id (e.g. `minecraft:lever`).
2820    pub block: String,
2821}
2822
2823// ---------------------------------------------------------------------------
2824// Stage 5 — scripted actors (DSL v0.6, spec-0014)
2825// ---------------------------------------------------------------------------
2826
2827/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
2828/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
2829/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
2830/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
2831/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
2832/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
2833/// by a real-AI twin with `unleash-actor`.
2834#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2835#[serde(deny_unknown_fields)]
2836pub struct Actor {
2837    /// Unique actor id (`actor/<kebab>`).
2838    pub id: ActorId,
2839    /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
2840    /// pinned 1.21.11 entity registry (`DW0173`).
2841    pub entity: String,
2842    /// Optional custom name shown above the puppet.
2843    #[serde(default, skip_serializing_if = "Option::is_none")]
2844    pub name: Option<String>,
2845    /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
2846    #[serde(default, skip_serializing_if = "Option::is_none")]
2847    pub skin: Option<NpcSkin>,
2848    /// The anchor the puppet is summoned on (resolved across areas, like an
2849    /// `open-gate` / `move-npc` destination).
2850    pub anchor: AnchorId,
2851    /// Initial facing (default `south`). The puppet spawns yawed this way.
2852    #[serde(default, skip_serializing_if = "Option::is_none")]
2853    pub facing: Option<Facing>,
2854    /// If `true`, the puppet is damageable (a tower-defense creep) but stays
2855    /// knockback-immune; default `false` (fully `Invulnerable`).
2856    #[serde(default, skip_serializing_if = "is_false")]
2857    pub vulnerable: bool,
2858    /// Gear the actor wears and holds, in the same shape a wave mob uses
2859    /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
2860    /// unleashed twin, so the dormant elite the player has been circling is
2861    /// visibly the same armoured thing that stands up. Drop chances are zero —
2862    /// wave gear and actor gear are never farmable (no-grind constitution).
2863    #[serde(default, skip_serializing_if = "Option::is_none")]
2864    pub equipment: Option<MobEquipment>,
2865    /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
2866    /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
2867    /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
2868    /// elite the party fights is the elite the author tuned; without it an actor
2869    /// was stuck at vanilla base values while every wave mob could be tuned,
2870    /// which is what blocked elite authoring. A `vulnerable` actor's
2871    /// knockback-immunity is emitted first and is not authorable.
2872    #[serde(default, skip_serializing_if = "Option::is_none")]
2873    pub attributes: Option<MobAttributes>,
2874    /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
2875    /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
2876    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
2877    ///
2878    /// A wave is not the only shape an elite takes. The set-piece souls fight —
2879    /// the armoured thing kneeling among the graves that stands up when you hit
2880    /// it — is an **actor**: staged by `spawn-actor`, given AI by
2881    /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
2882    /// this field the validation ladder's inverted floor gate could only see
2883    /// `waves[].tier`, so such a boss was *structurally invisible* to it and an
2884    /// empty finding list read as a pass while covering nothing.
2885    ///
2886    /// Like the wave field this is a **declaration, not a knob**: the compiler
2887    /// never scales an actor from it, and emission is unchanged whichever tier is
2888    /// declared. What it buys is scrutiny — the actor enters
2889    /// `validation/combat-plan.json`, and the compiler states, per tiered actor,
2890    /// whether the floor gate can measure it and why not when it cannot
2891    /// (`DW0477`).
2892    #[serde(default, skip_serializing_if = "Option::is_none")]
2893    pub tier: Option<EncounterTier>,
2894    /// What this actor leaves behind when a player kills it (DSL v0.9, task
2895    /// #179; reserved `DW0141` earlier). Only an `elite`/`boss` actor may
2896    /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
2897    /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
2898    /// not to one of its two lifecycles. A `despawn-actor` strips the
2899    /// declaration off the body before removing it, so re-caging an elite (a
2900    /// souls re-seat) never scatters its axe.
2901    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2902    pub drops: Vec<MobDrop>,
2903}
2904
2905/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
2906/// (MC: yaw 0 = +z/south).
2907#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2908#[serde(rename_all = "kebab-case")]
2909pub enum Facing {
2910    /// Facing +z (yaw 0) — the default.
2911    South,
2912    /// Facing -z (yaw 180).
2913    North,
2914    /// Facing -x (yaw 90).
2915    West,
2916    /// Facing +x (yaw 270).
2917    East,
2918}
2919
2920impl Facing {
2921    /// The kebab token (`south` / `north` / `west` / `east`).
2922    pub fn token(self) -> &'static str {
2923        match self {
2924            Facing::South => "south",
2925            Facing::North => "north",
2926            Facing::West => "west",
2927            Facing::East => "east",
2928        }
2929    }
2930}
2931
2932/// How a `despawn-actor` removes its puppet (DSL v0.6).
2933#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2934#[serde(rename_all = "kebab-case")]
2935pub enum DespawnStyle {
2936    /// Silent removal (`kill @e` with no death animation is not possible; the
2937    /// compiler removes the entity via `/kill` on an `Invulnerable` puppet, or a
2938    /// data-driven removal — see the emitter — so no death particles/sound show).
2939    Vanish,
2940    /// Plays the vanilla death animation (a cutscene death).
2941    Kill,
2942}
2943
2944impl DespawnStyle {
2945    /// The kebab token (`vanish` / `kill`).
2946    pub fn token(self) -> &'static str {
2947        match self {
2948            DespawnStyle::Vanish => "vanish",
2949            DespawnStyle::Kill => "kill",
2950        }
2951    }
2952}
2953
2954/// One step of a [`QuestEffect::Sequence`] (DSL v0.6): a group of effects fired at
2955/// an exact tick offset from the sequence's start.
2956#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2957#[serde(deny_unknown_fields)]
2958pub struct SequenceStep {
2959    /// Tick offset from the sequence start at which `effects` fire.
2960    pub at_ticks: u32,
2961    /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
2962    /// `sequence` (rejected with `DW0329`).
2963    pub effects: Vec<QuestEffect>,
2964}
2965
2966impl Objective {
2967    /// This objective's id.
2968    pub fn id(&self) -> &ObjectiveId {
2969        match self {
2970            Objective::TalkTo { id, .. }
2971            | Objective::ReachAnchor { id, .. }
2972            | Objective::Kill { id, .. }
2973            | Objective::Collect { id, .. }
2974            | Objective::Interact { id, .. } => id,
2975        }
2976    }
2977
2978    /// This objective's prerequisites.
2979    pub fn after(&self) -> &[ObjectiveId] {
2980        match self {
2981            Objective::TalkTo { after, .. }
2982            | Objective::ReachAnchor { after, .. }
2983            | Objective::Kill { after, .. }
2984            | Objective::Collect { after, .. }
2985            | Objective::Interact { after, .. } => after,
2986        }
2987    }
2988
2989    /// The short player-facing objective title (v0.3, optional).
2990    pub fn title(&self) -> Option<&str> {
2991        match self {
2992            Objective::TalkTo { title, .. }
2993            | Objective::ReachAnchor { title, .. }
2994            | Objective::Kill { title, .. }
2995            | Objective::Collect { title, .. }
2996            | Objective::Interact { title, .. } => title.as_deref(),
2997        }
2998    }
2999
3000    /// The one-line location/direction hint (v0.3, optional).
3001    pub fn hint(&self) -> Option<&str> {
3002        match self {
3003            Objective::TalkTo { hint, .. }
3004            | Objective::ReachAnchor { hint, .. }
3005            | Objective::Kill { hint, .. }
3006            | Objective::Collect { hint, .. }
3007            | Objective::Interact { hint, .. } => hint.as_deref(),
3008        }
3009    }
3010
3011    /// Mutable access to the optional player-facing title (i18n localization).
3012    pub fn title_mut(&mut self) -> &mut Option<String> {
3013        match self {
3014            Objective::TalkTo { title, .. }
3015            | Objective::ReachAnchor { title, .. }
3016            | Objective::Kill { title, .. }
3017            | Objective::Collect { title, .. }
3018            | Objective::Interact { title, .. } => title,
3019        }
3020    }
3021
3022    /// Mutable access to the optional one-line hint (i18n localization).
3023    pub fn hint_mut(&mut self) -> &mut Option<String> {
3024        match self {
3025            Objective::TalkTo { hint, .. }
3026            | Objective::ReachAnchor { hint, .. }
3027            | Objective::Kill { hint, .. }
3028            | Objective::Collect { hint, .. }
3029            | Objective::Interact { hint, .. } => hint,
3030        }
3031    }
3032
3033    /// The flags that must be set before this objective activates (v0.3).
3034    pub fn requires_flags(&self) -> &[FlagId] {
3035        match self {
3036            Objective::TalkTo { requires_flags, .. }
3037            | Objective::ReachAnchor { requires_flags, .. }
3038            | Objective::Kill { requires_flags, .. }
3039            | Objective::Collect { requires_flags, .. }
3040            | Objective::Interact { requires_flags, .. } => requires_flags,
3041        }
3042    }
3043
3044    /// The negative flag gate (DSL v0.6): flags whose being set **suppresses**
3045    /// this objective. The dual of [`Objective::requires_flags`].
3046    pub fn forbids_flags(&self) -> &[FlagId] {
3047        match self {
3048            Objective::TalkTo { forbids_flags, .. }
3049            | Objective::ReachAnchor { forbids_flags, .. }
3050            | Objective::Kill { forbids_flags, .. }
3051            | Objective::Collect { forbids_flags, .. }
3052            | Objective::Interact { forbids_flags, .. } => forbids_flags,
3053        }
3054    }
3055
3056    /// What this objective does to the story (DSL v0.8, spec-0025).
3057    pub fn happening(&self) -> Option<&Happening> {
3058        match self {
3059            Objective::TalkTo { happening, .. }
3060            | Objective::ReachAnchor { happening, .. }
3061            | Objective::Kill { happening, .. }
3062            | Objective::Collect { happening, .. }
3063            | Objective::Interact { happening, .. } => happening.as_ref(),
3064        }
3065    }
3066
3067    /// The bot stealth hint (DSL v0.4): traverse this leg sneaking.
3068    pub fn stealth(&self) -> bool {
3069        match self {
3070            Objective::TalkTo { stealth, .. }
3071            | Objective::ReachAnchor { stealth, .. }
3072            | Objective::Kill { stealth, .. }
3073            | Objective::Collect { stealth, .. }
3074            | Objective::Interact { stealth, .. } => *stealth,
3075        }
3076    }
3077
3078    /// The container this objective ADOPTS (DSL v0.8), if it is a `collect` that
3079    /// declares one: the anchor whose prefab-placed chest/barrel it fills instead
3080    /// of conjuring its own chest. `None` on every other objective and on a
3081    /// `collect` that keeps the compiler-placed chest.
3082    pub fn collect_container(&self) -> Option<&AnchorId> {
3083        match self {
3084            Objective::Collect { container, .. } => container.as_ref(),
3085            _ => None,
3086        }
3087    }
3088
3089    /// The wave whose declared drops provide this objective's item (DSL v0.9),
3090    /// if it is a `collect` that declares one. `None` on every other objective
3091    /// and on a `collect` fed by a container.
3092    pub fn collect_dropped_by(&self) -> Option<&WaveId> {
3093        match self {
3094            Objective::Collect { dropped_by, .. } => dropped_by.as_ref(),
3095            _ => None,
3096        }
3097    }
3098
3099    /// The padding-stack count of a `collect` (DSL v0.8); `0` for every other
3100    /// objective and for a `collect` that fills the single required stack only.
3101    pub fn collect_fill_count(&self) -> u32 {
3102        match self {
3103            Objective::Collect { fill_count, .. } => *fill_count,
3104            _ => 0,
3105        }
3106    }
3107
3108    /// The `interact` prop block (DSL v0.4), if this is an `interact` with a prop.
3109    pub fn prop(&self) -> Option<&Prop> {
3110        match self {
3111            Objective::Interact { prop, .. } => prop.as_ref(),
3112            _ => None,
3113        }
3114    }
3115
3116    /// The kebab type tag.
3117    pub fn kind(&self) -> &'static str {
3118        match self {
3119            Objective::TalkTo { .. } => "talk-to",
3120            Objective::ReachAnchor { .. } => "reach-anchor",
3121            Objective::Kill { .. } => "kill",
3122            Objective::Collect { .. } => "collect",
3123            Objective::Interact { .. } => "interact",
3124        }
3125    }
3126
3127    /// The v0.3 verb name if this objective is one of the verbs introduced in
3128    /// DSL v0.3 (`kill`/`collect`/`interact`). These validate in v0.3 campaigns
3129    /// and are reserved (`DW0141`) in v0.2 campaigns.
3130    pub fn v03_verb(&self) -> Option<&'static str> {
3131        match self {
3132            Objective::Kill { .. } => Some("kill"),
3133            Objective::Collect { .. } => Some("collect"),
3134            Objective::Interact { .. } => Some("interact"),
3135            Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => None,
3136        }
3137    }
3138}
3139
3140/// An effect fired by quest progress.
3141///
3142/// `give-item`, `set-flag` and `spawn-wave` are **implemented in DSL v0.3**; in a
3143/// v0.2 campaign they are still reserved and rejected with `DW0141` (see
3144/// [`QuestEffect::v03_effect`]).
3145///
3146/// `Debug` is hand-written (see the impl below the enum) because it is a
3147/// **stable content-key rendering** — the compiler's `sequence_key` hashes
3148/// `{steps:?}` to name `seq_<hash>` functions, so an effect that uses none of
3149/// the v0.6 `forbids_flags` / `move-npc on_arrive` fields must render
3150/// byte-identically to the pre-addition enum (the [`CameraShot`] rule).
3151#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3152#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3153pub enum QuestEffect {
3154    /// Opens a prefab-declared gate (one-way).
3155    OpenGate {
3156        /// The gate anchor to open.
3157        anchor: AnchorId,
3158        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3159        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3160        requires_flags: Vec<FlagId>,
3161        /// Per-effect negative flag gate (DSL v0.6); see
3162        /// [`QuestEffect::forbids_flags`].
3163        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3164        forbids_flags: Vec<FlagId>,
3165        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3166        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3167        /// the hand-written `Debug` rendering below: the declaration is
3168        /// validation metadata with no emission of its own, so a content key can
3169        /// never move because a beat gained a line of prose.
3170        #[serde(default, skip_serializing_if = "Option::is_none")]
3171        happening: Option<Happening>,
3172    },
3173    /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
3174    /// fills the gate anchor's region with the block the anchor declares (e.g. the
3175    /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
3176    /// The declared fill block is prefab metadata; a gate anchor with no `block` is
3177    /// rejected (`DW0343`). The completability model treats the region as **solid**
3178    /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
3179    /// clearing is modelled) — a critical path that must cross a gate after it seals
3180    /// fails the DW0311 reachability proof.
3181    CloseGate {
3182        /// The gate anchor to seal.
3183        anchor: AnchorId,
3184        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3185        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3186        requires_flags: Vec<FlagId>,
3187        /// Per-effect negative flag gate (DSL v0.6); see
3188        /// [`QuestEffect::forbids_flags`].
3189        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3190        forbids_flags: Vec<FlagId>,
3191        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3192        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3193        /// the hand-written `Debug` rendering below: the declaration is
3194        /// validation metadata with no emission of its own, so a content key can
3195        /// never move because a beat gained a line of prose.
3196        #[serde(default, skip_serializing_if = "Option::is_none")]
3197        happening: Option<Happening>,
3198        /// What the seal *says* when a player right-clicks it (DSL v0.8; reserved
3199        /// `DW0141` earlier). A sealed gate is a wall the party will walk back to
3200        /// and press: the compiler answers that press on the actionbar. Absent, the
3201        /// compiler's canonical English is baked in (`The way is sealed.`) exactly
3202        /// as `world.boundary.message` does; authored, the line is l10n-inventoried
3203        /// under `<effect-key>.sealed_hint` and translates like every other
3204        /// player-visible string.
3205        ///
3206        /// Unlike `happening`, this **does** print in the hand-written `Debug`
3207        /// below when present — it changes emission, so two otherwise-identical
3208        /// sequences that differ only in their seal's answer are different content.
3209        #[serde(default, skip_serializing_if = "Option::is_none")]
3210        sealed_hint: Option<String>,
3211    },
3212    /// Marks the campaign complete (final advancement + credits). Terminal — not
3213    /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
3214    /// so this variant carries no `requires_flags`.
3215    CampaignComplete {
3216        /// Which ENDING this is (DSL v0.8, spec-0025; reserved `DW0141` earlier).
3217        /// A campaign with more than one `campaign-complete` has more than one
3218        /// ending, and a branch that runs to an ending names it here — so the
3219        /// terminality proof (`DW0482`) can state *which* ending a branch reached
3220        /// instead of merely that something ended. There is no separate `endings`
3221        /// section: the set of endings is exactly the set named here, the same
3222        /// rule flags follow.
3223        #[serde(default, skip_serializing_if = "Option::is_none")]
3224        ending: Option<EndingId>,
3225        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3226        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3227        /// the hand-written `Debug` rendering below: the declaration is
3228        /// validation metadata with no emission of its own, so a content key can
3229        /// never move because a beat gained a line of prose.
3230        #[serde(default, skip_serializing_if = "Option::is_none")]
3231        happening: Option<Happening>,
3232    },
3233    /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
3234    GiveItem {
3235        /// Vanilla item id to give (validated against the registry).
3236        item: String,
3237        /// How many to give.
3238        count: u32,
3239        /// Optional display name (DSL v0.4), matching [`KitItem::name`].
3240        #[serde(default, skip_serializing_if = "Option::is_none")]
3241        name: Option<String>,
3242        /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
3243        /// party member. `one` hands a single copy to the player whose action fired
3244        /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
3245        /// has no acting player.
3246        #[serde(default, skip_serializing_if = "Option::is_none")]
3247        carrier: Option<Carrier>,
3248        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3249        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3250        requires_flags: Vec<FlagId>,
3251        /// Per-effect negative flag gate (DSL v0.6); see
3252        /// [`QuestEffect::forbids_flags`].
3253        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3254        forbids_flags: Vec<FlagId>,
3255    },
3256    /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
3257    SetFlag {
3258        /// The flag to set.
3259        flag: FlagId,
3260        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3261        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3262        requires_flags: Vec<FlagId>,
3263        /// Per-effect negative flag gate (DSL v0.6); see
3264        /// [`QuestEffect::forbids_flags`].
3265        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3266        forbids_flags: Vec<FlagId>,
3267    },
3268    /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
3269    SpawnWave {
3270        /// The wave (stage-5 `waves` ref) to spawn.
3271        wave: WaveId,
3272        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3273        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3274        requires_flags: Vec<FlagId>,
3275        /// Per-effect negative flag gate (DSL v0.6); see
3276        /// [`QuestEffect::forbids_flags`].
3277        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3278        forbids_flags: Vec<FlagId>,
3279        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3280        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3281        /// the hand-written `Debug` rendering below: the declaration is
3282        /// validation metadata with no emission of its own, so a content key can
3283        /// never move because a beat gained a line of prose.
3284        #[serde(default, skip_serializing_if = "Option::is_none")]
3285        happening: Option<Happening>,
3286    },
3287    /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
3288    /// l10n key inventory like any player-visible string.
3289    Narrate {
3290        /// The line shown to the player.
3291        text: String,
3292        /// Presentation channel (default `chat`).
3293        #[serde(default, skip_serializing_if = "Option::is_none")]
3294        style: Option<NarrateStyle>,
3295        /// Optional sound id played alongside the line.
3296        #[serde(default, skip_serializing_if = "Option::is_none")]
3297        sound: Option<String>,
3298        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3299        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3300        requires_flags: Vec<FlagId>,
3301        /// Per-effect negative flag gate (DSL v0.6); see
3302        /// [`QuestEffect::forbids_flags`].
3303        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3304        forbids_flags: Vec<FlagId>,
3305    },
3306    /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
3307    /// placement. Block id validated against the pinned 1.21.11 block registry;
3308    /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
3309    /// accepted and passed through verbatim (DSL v0.6).
3310    SetBlock {
3311        /// The anchor to place the block at.
3312        anchor: AnchorId,
3313        /// Vanilla block id to place.
3314        block: String,
3315        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3316        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3317        requires_flags: Vec<FlagId>,
3318        /// Per-effect negative flag gate (DSL v0.6); see
3319        /// [`QuestEffect::forbids_flags`].
3320        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3321        forbids_flags: Vec<FlagId>,
3322    },
3323    /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5).
3324    DespawnNpc {
3325        /// The NPC (stage-2 ref) to remove.
3326        npc: NpcId,
3327        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3328        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3329        requires_flags: Vec<FlagId>,
3330        /// Per-effect negative flag gate (DSL v0.6); see
3331        /// [`QuestEffect::forbids_flags`].
3332        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3333        forbids_flags: Vec<FlagId>,
3334        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3335        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3336        /// the hand-written `Debug` rendering below: the declaration is
3337        /// validation metadata with no emission of its own, so a content key can
3338        /// never move because a beat gained a line of prose.
3339        #[serde(default, skip_serializing_if = "Option::is_none")]
3340        happening: Option<Happening>,
3341    },
3342    /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
3343    /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
3344    /// path** by A* over the solved voxel grid and emits per-tick teleport
3345    /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
3346    /// a solid affordance. An unroutable move is a compile error (`DW0307`).
3347    MoveNpc {
3348        /// The NPC (stage-2 ref) to move.
3349        npc: NpcId,
3350        /// The destination anchor.
3351        to_anchor: AnchorId,
3352        /// Optional travel speed in blocks/tick (defaults to ~0.15).
3353        #[serde(default, skip_serializing_if = "Option::is_none")]
3354        speed: Option<f64>,
3355        /// Effects fired once the NPC arrives at the destination cell (DSL v0.6,
3356        /// reserved `DW0141` earlier) — exact parity with [`QuestEffect::MoveActor`]
3357        /// `on_arrive`: same arrival detection (the walk driver's final tick), same
3358        /// execution context, and every deep effect walker recurses into it via
3359        /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
3360        /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
3361        /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
3362        /// mark).
3363        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3364        on_arrive: Vec<QuestEffect>,
3365        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3366        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3367        requires_flags: Vec<FlagId>,
3368        /// Per-effect negative flag gate (DSL v0.6); see
3369        /// [`QuestEffect::forbids_flags`].
3370        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3371        forbids_flags: Vec<FlagId>,
3372        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3373        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3374        /// the hand-written `Debug` rendering below: the declaration is
3375        /// validation metadata with no emission of its own, so a content key can
3376        /// never move because a beat gained a line of prose.
3377        #[serde(default, skip_serializing_if = "Option::is_none")]
3378        happening: Option<Happening>,
3379    },
3380    /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
3381    /// gamemode+position, spectator, then dolly two co-located cameras along a
3382    /// straight-line lerp between waypoints and alternate `spectate` between them
3383    /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
3384    /// no-op and is never emitted), and restore on completion. The compiler
3385    /// validates the dolly path passes only through non-solid blocks — cameras
3386    /// fly but must not clip a solid (`DW0308`).
3387    ///
3388    /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
3389    /// emission to face that world point from its own position, so the shot keeps
3390    /// its subject framed for the whole move; without it, the camera faces along
3391    /// the direction of travel (the segment it is currently traversing).
3392    ///
3393    /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
3394    /// inside one save/restore bracket (hard cut between shots). Two accepted,
3395    /// mutually exclusive spellings, both normalized by
3396    /// [`QuestEffect::cutscene_shots`]:
3397    /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
3398    /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
3399    ///   exactly equivalent to a one-entry `shots` list.
3400    ///
3401    /// Mixing or omitting both is `DW0199`.
3402    Cutscene {
3403        /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
3404        /// with the single-shot `path`/`seconds` fields (`DW0199`).
3405        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3406        shots: Vec<CameraShot>,
3407        /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
3408        /// lerp between them).
3409        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3410        path: Vec<CameraWaypoint>,
3411        /// Single-shot form (DSL v0.4): shot duration in seconds.
3412        #[serde(default, skip_serializing_if = "Option::is_none")]
3413        seconds: Option<u32>,
3414        /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
3415        /// Absent = face along the direction of travel.
3416        #[serde(default, skip_serializing_if = "Option::is_none")]
3417        look_at: Option<CameraTarget>,
3418        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3419        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3420        requires_flags: Vec<FlagId>,
3421        /// Per-effect negative flag gate (DSL v0.6); see
3422        /// [`QuestEffect::forbids_flags`].
3423        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3424        forbids_flags: Vec<FlagId>,
3425    },
3426    /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
3427    /// Instantaneous (vanilla has no gradual transition); the state persists
3428    /// because the daylight cycle is frozen by sealing.
3429    SetTime {
3430        /// The time state to cut to.
3431        time: WorldTime,
3432        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3433        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3434        requires_flags: Vec<FlagId>,
3435        /// Per-effect negative flag gate (DSL v0.6); see
3436        /// [`QuestEffect::forbids_flags`].
3437        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3438        forbids_flags: Vec<FlagId>,
3439    },
3440    /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
3441    /// Instantaneous; persists because the weather cycle is frozen by sealing.
3442    SetWeather {
3443        /// The weather state to cut to.
3444        weather: WorldWeather,
3445        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3446        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3447        requires_flags: Vec<FlagId>,
3448        /// Per-effect negative flag gate (DSL v0.6); see
3449        /// [`QuestEffect::forbids_flags`].
3450        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3451        forbids_flags: Vec<FlagId>,
3452    },
3453    /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
3454    /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
3455    /// sound-event registry (`DW0326` unknown). `at` selects where the sound
3456    /// originates (default: each player's own position); `volume`/`pitch` map to
3457    /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
3458    /// vanilla).
3459    PlaySound {
3460        /// The vanilla sound-event id (`minecraft:` prefix optional).
3461        sound: String,
3462        /// Where the sound plays from (default: `players`).
3463        #[serde(default, skip_serializing_if = "Option::is_none")]
3464        at: Option<SoundAt>,
3465        /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
3466        #[serde(default, skip_serializing_if = "Option::is_none")]
3467        volume: Option<f64>,
3468        /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
3469        #[serde(default, skip_serializing_if = "Option::is_none")]
3470        pitch: Option<f64>,
3471        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3472        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3473        requires_flags: Vec<FlagId>,
3474        /// Per-effect negative flag gate (DSL v0.6); see
3475        /// [`QuestEffect::forbids_flags`].
3476        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3477        forbids_flags: Vec<FlagId>,
3478    },
3479    /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
3480    /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
3481    /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
3482    /// acting player: at top level it damages every player once; inside a stealth
3483    /// `on_caught` it damages the caught player (the "caught → death → respawn at
3484    /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
3485    /// is lethal through golden apples / absorption. `within` (JSON `in`) narrows to
3486    /// acting players standing inside an anchor-centred box (the same box model as a
3487    /// stealth zone), keeping the per-`@s` semantics. `damage_type` is the damage
3488    /// type — a curated set of vanilla types that all respect `keepInventory` and do
3489    /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
3490    /// (The field is `damage_type`, not `type`, because the effect enum is
3491    /// internally tagged on `type`.)
3492    DamagePlayers {
3493        /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
3494        amount: u32,
3495        /// Optional spatial filter: only damage an acting player inside this
3496        /// anchor-centred box (`anchor ± extent`). Absent = every acting player.
3497        #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
3498        within: Option<StealthZone>,
3499        /// The damage type (default [`DamageKind::Generic`]).
3500        #[serde(default, skip_serializing_if = "Option::is_none")]
3501        damage_type: Option<DamageKind>,
3502        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3503        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3504        requires_flags: Vec<FlagId>,
3505        /// Per-effect negative flag gate (DSL v0.6); see
3506        /// [`QuestEffect::forbids_flags`].
3507        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3508        forbids_flags: Vec<FlagId>,
3509    },
3510    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
3511    /// `spawnpoint @a` at the anchor cell and mirrors the coords into
3512    /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
3513    /// `set-checkpoint` always replaces an earlier one). The compiler proves the
3514    /// cell is standable (`DW0316`) and that the remaining critical path stays
3515    /// reachable from it (`DW0315`).
3516    SetCheckpoint {
3517        /// The prefab checkpoint anchor the party respawns at.
3518        anchor: AnchorId,
3519        /// Per-player effects re-run each time a player respawns while this
3520        /// checkpoint is the active one — scene reset (e.g. re-caging an
3521        /// unleashed actor). Emitted idempotently in declared order; empty = no
3522        /// hook. Respawn is detected via the vanilla `deathCount` criterion.
3523        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3524        on_respawn: Vec<QuestEffect>,
3525    },
3526    /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
3527    /// [`QuestEffect::SetCheckpoint`] for souls-mode pacing. The effect *arms*
3528    /// the rest affordance (a `minecraft:interaction` the player right-clicks at
3529    /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
3530    /// only **when the party actually rests**. Resting fires `on_rest` — the
3531    /// scene reset that makes retry cheap: re-arming traps, re-seating waves
3532    /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
3533    /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
3534    /// so the world's answer to a death and to a rest is identical (spec-0016:
3535    /// death is an investment, never a tax).
3536    ///
3537    /// Proofs are inherited from the checkpoint machinery: the anchor must be
3538    /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
3539    /// the beat that arms the bonfire (the earliest moment a rest can happen).
3540    Bonfire {
3541        /// The prefab anchor the rest affordance stands at, and the cell the
3542        /// party respawns at once rested.
3543        anchor: AnchorId,
3544        /// Effects re-run on every rest **and** on every respawn at this bonfire
3545        /// — the scene reset. Emitted in declared order and expected to be
3546        /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
3547        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3548        on_rest: Vec<QuestEffect>,
3549        /// Title of the two-option rest dialog (DSL v0.8, owner ruling
3550        /// 2026-08-03). Absent = the compiler's canonical English `Bonfire`,
3551        /// baked at emit time (the `world.boundary.message` precedent): an
3552        /// authored line is inventoried and translates like every other
3553        /// player-visible string.
3554        #[serde(default, skip_serializing_if = "Option::is_none")]
3555        prompt: Option<String>,
3556        /// Label of the **rest and save** button. Absent = `Rest and save`.
3557        /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
3558        /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
3559        #[serde(default, skip_serializing_if = "Option::is_none")]
3560        rest_label: Option<String>,
3561        /// Label of the **save only** button. Absent = `Save only`.
3562        #[serde(default, skip_serializing_if = "Option::is_none")]
3563        save_label: Option<String>,
3564    },
3565    /// Begins a stealth beat (DSL v0.6, spec-0014; owner ruling 2026-08-01:
3566    /// zone presence alone = hidden — no sneak requirement, which collided with
3567    /// the spectator cutscene camera). While active, every player must be
3568    /// inside some `zone` each tick; a player outside every zone for
3569    /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
3570    /// Zone membership is read from the player's position. The compiler proves
3571    /// each zone is standable and reachable from the activating beat (`DW0327`).
3572    BeginStealth {
3573        /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
3574        zones: Vec<StealthZone>,
3575        /// Per-player effects fired when a player is caught (out of every zone
3576        /// for `grace_ticks`). Empty = no consequence.
3577        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3578        on_caught: Vec<QuestEffect>,
3579        /// Ticks a player may be exposed before `on_caught` fires (default 20).
3580        #[serde(default = "default_grace_ticks")]
3581        grace_ticks: u32,
3582    },
3583    /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
3584    EndStealth,
3585    /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
3586    /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
3587    /// scripted entrance a staged character needs. Idempotent: spawning an NPC
3588    /// already in the world is a no-op. Only meaningful for an NPC declared
3589    /// `deferred: true`; a non-deferred NPC is already in the world from init.
3590    SpawnNpc {
3591        /// The NPC (stage-2 ref) to summon.
3592        npc: NpcId,
3593        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3594        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3595        /// the hand-written `Debug` rendering below: the declaration is
3596        /// validation metadata with no emission of its own, so a content key can
3597        /// never move because a beat gained a line of prose.
3598        #[serde(default, skip_serializing_if = "Option::is_none")]
3599        happening: Option<Happening>,
3600    },
3601    // --- DSL v0.6 actor staging effects (spec-0014) ---
3602    /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
3603    /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
3604    SpawnActor {
3605        /// The actor (stage-5 `actors` ref) to summon.
3606        actor: ActorId,
3607        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3608        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3609        /// the hand-written `Debug` rendering below: the declaration is
3610        /// validation metadata with no emission of its own, so a content key can
3611        /// never move because a beat gained a line of prose.
3612        #[serde(default, skip_serializing_if = "Option::is_none")]
3613        happening: Option<Happening>,
3614    },
3615    /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
3616    /// animation (cutscene deaths); `vanish` is silent removal.
3617    DespawnActor {
3618        /// The actor (stage-5 `actors` ref) to remove.
3619        actor: ActorId,
3620        /// How the puppet is removed.
3621        style: DespawnStyle,
3622        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3623        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3624        /// the hand-written `Debug` rendering below: the declaration is
3625        /// validation metadata with no emission of its own, so a content key can
3626        /// never move because a beat gained a line of prose.
3627        #[serde(default, skip_serializing_if = "Option::is_none")]
3628        happening: Option<Happening>,
3629    },
3630    /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
3631    /// the assembled model, using the actor's hitbox footprint, yawed along the
3632    /// path tangent (DSL v0.6, task #46). Concurrent movers are allowed (a herded
3633    /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
3634    /// effects fire once the puppet reaches the destination cell.
3635    MoveActor {
3636        /// The actor (stage-5 `actors` ref) to move.
3637        actor: ActorId,
3638        /// The destination anchor.
3639        to_anchor: AnchorId,
3640        /// Optional travel speed in blocks/tick (defaults to ~0.15).
3641        #[serde(default, skip_serializing_if = "Option::is_none")]
3642        speed: Option<f64>,
3643        /// Effects fired once the puppet arrives at the destination cell.
3644        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3645        on_arrive: Vec<QuestEffect>,
3646        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3647        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3648        /// the hand-written `Debug` rendering below: the declaration is
3649        /// validation metadata with no emission of its own, so a content key can
3650        /// never move because a beat gained a line of prose.
3651        #[serde(default, skip_serializing_if = "Option::is_none")]
3652        happening: Option<Happening>,
3653    },
3654    /// Replaces an actor's puppet with a real-AI twin of the same type / position /
3655    /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
3656    /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
3657    /// special verb.
3658    UnleashActor {
3659        /// The actor (stage-5 `actors` ref) to unleash.
3660        actor: ActorId,
3661        /// What this beat does to the story (DSL v0.8, spec-0025; required at
3662        /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3663        /// the hand-written `Debug` rendering below: the declaration is
3664        /// validation metadata with no emission of its own, so a content key can
3665        /// never move because a beat gained a line of prose.
3666        #[serde(default, skip_serializing_if = "Option::is_none")]
3667        happening: Option<Happening>,
3668    },
3669    /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
3670    /// at exact tick offsets. Effects are any in the stage-5 set except a nested
3671    /// `sequence` (rejected with `DW0329`).
3672    Sequence {
3673        /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
3674        steps: Vec<SequenceStep>,
3675    },
3676    /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
3677    /// projectiles with real velocity vectors, fired from a gallery slot into a
3678    /// declared kill zone.
3679    ///
3680    /// The contract is **saturation, not sniping** (owner ruling 2026-08-03).
3681    /// Every salvo puts one projectile on the trajectory to **every standable
3682    /// cell of `kill_zone`**, plus one aimed at the triggering player's
3683    /// fire-time position (which punishes standing still). A player therefore
3684    /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
3685    /// decision rather than a lucky step. Coverage is proven at compile time:
3686    /// `from_anchor` must have clear line of fire to every standable kill-zone
3687    /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
3688    /// on the stairs" is a build-time fact, not a hope.
3689    ///
3690    /// Projectiles are summoned `NoGravity` so the flown path is exactly the
3691    /// straight segment the coverage proof checks — proof and runtime share one
3692    /// geometry. Drag scales speed but not direction, so the line is preserved.
3693    Volley {
3694        /// Projectile entity id (default `minecraft:arrow`; validated against
3695        /// the pinned 1.21.11 registry, `DW0441`).
3696        #[serde(default, skip_serializing_if = "Option::is_none")]
3697        projectile: Option<String>,
3698        /// The gallery slot the volley is fired FROM — a point anchor. Its cell
3699        /// must be clear (a projectile spawned inside a wall never leaves it).
3700        from_anchor: AnchorId,
3701        /// The zone the volley must blanket, as an anchor-centred box
3702        /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
3703        /// `begin-stealth`'s `zones` use. Every standable cell in it receives
3704        /// fire each salvo.
3705        ///
3706        /// Deliberately NOT a bare prefab `region` anchor: the assembled model
3707        /// clears every gate-region anchor's cells unconditionally, so
3708        /// describing a zone that way would delete the geometry it names.
3709        kill_zone: StealthZone,
3710        /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
3711        #[serde(default, skip_serializing_if = "Option::is_none")]
3712        salvos: Option<u32>,
3713        /// Ticks between salvos (default 10, `DW0443` bounds it).
3714        #[serde(default, skip_serializing_if = "Option::is_none")]
3715        interval: Option<u32>,
3716        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3717        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3718        requires_flags: Vec<FlagId>,
3719        /// Per-effect negative flag gate (DSL v0.6); see
3720        /// [`QuestEffect::forbids_flags`].
3721        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3722        forbids_flags: Vec<FlagId>,
3723    },
3724    /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
3725    /// drop them as `falling_block` entities — the buried-alive trap redstone
3726    /// cannot express at all.
3727    ///
3728    /// The post-collapse world is **modeled, not guessed**: the compiler clears
3729    /// the region, settles every dropped column through the existing gravity
3730    /// model (spec-0010), optionally paves the landing surface with
3731    /// `then_floor`, and re-runs the critical-path completability proof against
3732    /// that mutated world (`DW0445`). A collapse that buries the only route is a
3733    /// build error, exactly as a `shortcut` seal that strands the party is.
3734    Collapse {
3735        /// The volume whose blocks fall, as an anchor-centred box
3736        /// (`anchor ± extent`). Must hold blocks and sit above standable footing
3737        /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
3738        ///
3739        /// An anchor-centred box rather than a prefab `region` anchor for a
3740        /// load-bearing reason: the assembled model deletes every gate-region
3741        /// anchor's cells, so a ceiling slab declared that way would already be
3742        /// gone before the trap ever fired.
3743        region_anchor: StealthZone,
3744        /// The block the falling entities are made of (default
3745        /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
3746        #[serde(default, skip_serializing_if = "Option::is_none")]
3747        falling_block: Option<String>,
3748        /// Optional block the landing surface is paved with once the debris
3749        /// settles — the authored post-collapse floor the completability proof
3750        /// reasons over.
3751        #[serde(default, skip_serializing_if = "Option::is_none")]
3752        then_floor: Option<String>,
3753        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3754        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3755        requires_flags: Vec<FlagId>,
3756        /// Per-effect negative flag gate (DSL v0.6); see
3757        /// [`QuestEffect::forbids_flags`].
3758        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3759        forbids_flags: Vec<FlagId>,
3760    },
3761}
3762
3763/// `Debug` is hand-written because it is a **stable content-key rendering**: the
3764/// compiler's `sequence_key` (FNV over `{steps:?}`, where a step's `effects` are
3765/// `QuestEffect`s) names generated `seq_<hash>` functions from it, so an effect
3766/// that uses none of the v0.6 additions (`forbids_flags` anywhere, `on_arrive`
3767/// on `move-npc`) must render byte-identically to the pre-addition derive —
3768/// otherwise every existing sequence would silently churn its function names on
3769/// a purely additive schema change (the [`CameraShot`] precedent). Rules:
3770/// every pre-existing field prints exactly as `#[derive(Debug)]` printed it (in
3771/// declaration order); `forbids_flags` prints only when non-empty; `move-npc`'s
3772/// `on_arrive` prints only when non-empty.
3773impl std::fmt::Debug for QuestEffect {
3774    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3775        /// Append `forbids_flags` only when non-empty (see the impl doc).
3776        fn ff<'c, 'a, 'b: 'a>(
3777            d: &'c mut std::fmt::DebugStruct<'a, 'b>,
3778            forbids_flags: &[FlagId],
3779        ) -> &'c mut std::fmt::DebugStruct<'a, 'b> {
3780            if forbids_flags.is_empty() {
3781                d
3782            } else {
3783                d.field("forbids_flags", &forbids_flags)
3784            }
3785        }
3786        match self {
3787            QuestEffect::OpenGate {
3788                anchor,
3789                requires_flags,
3790                forbids_flags,
3791                ..
3792            } => ff(
3793                f.debug_struct("OpenGate")
3794                    .field("anchor", anchor)
3795                    .field("requires_flags", requires_flags),
3796                forbids_flags,
3797            )
3798            .finish(),
3799            QuestEffect::CloseGate {
3800                anchor,
3801                requires_flags,
3802                forbids_flags,
3803                sealed_hint,
3804                ..
3805            } => {
3806                let mut s = f.debug_struct("CloseGate");
3807                let d = ff(
3808                    s.field("anchor", anchor)
3809                        .field("requires_flags", requires_flags),
3810                    forbids_flags,
3811                );
3812                // Prints only when authored (the additive-field rule): a campaign
3813                // that takes the compiler's canonical seal line renders exactly as
3814                // the pre-0.8 derive did, so no existing `seq_<hash>` moves.
3815                match sealed_hint {
3816                    Some(h) => d.field("sealed_hint", h).finish(),
3817                    None => d.finish(),
3818                }
3819            }
3820            QuestEffect::CampaignComplete { .. } => f.write_str("CampaignComplete"),
3821            QuestEffect::GiveItem {
3822                item,
3823                count,
3824                name,
3825                carrier,
3826                requires_flags,
3827                forbids_flags,
3828            } => ff(
3829                f.debug_struct("GiveItem")
3830                    .field("item", item)
3831                    .field("count", count)
3832                    .field("name", name)
3833                    .field("carrier", carrier)
3834                    .field("requires_flags", requires_flags),
3835                forbids_flags,
3836            )
3837            .finish(),
3838            QuestEffect::SetFlag {
3839                flag,
3840                requires_flags,
3841                forbids_flags,
3842            } => ff(
3843                f.debug_struct("SetFlag")
3844                    .field("flag", flag)
3845                    .field("requires_flags", requires_flags),
3846                forbids_flags,
3847            )
3848            .finish(),
3849            QuestEffect::SpawnWave {
3850                wave,
3851                requires_flags,
3852                forbids_flags,
3853                ..
3854            } => ff(
3855                f.debug_struct("SpawnWave")
3856                    .field("wave", wave)
3857                    .field("requires_flags", requires_flags),
3858                forbids_flags,
3859            )
3860            .finish(),
3861            QuestEffect::Narrate {
3862                text,
3863                style,
3864                sound,
3865                requires_flags,
3866                forbids_flags,
3867            } => ff(
3868                f.debug_struct("Narrate")
3869                    .field("text", text)
3870                    .field("style", style)
3871                    .field("sound", sound)
3872                    .field("requires_flags", requires_flags),
3873                forbids_flags,
3874            )
3875            .finish(),
3876            QuestEffect::SetBlock {
3877                anchor,
3878                block,
3879                requires_flags,
3880                forbids_flags,
3881            } => ff(
3882                f.debug_struct("SetBlock")
3883                    .field("anchor", anchor)
3884                    .field("block", block)
3885                    .field("requires_flags", requires_flags),
3886                forbids_flags,
3887            )
3888            .finish(),
3889            QuestEffect::DespawnNpc {
3890                npc,
3891                requires_flags,
3892                forbids_flags,
3893                ..
3894            } => ff(
3895                f.debug_struct("DespawnNpc")
3896                    .field("npc", npc)
3897                    .field("requires_flags", requires_flags),
3898                forbids_flags,
3899            )
3900            .finish(),
3901            QuestEffect::MoveNpc {
3902                npc,
3903                to_anchor,
3904                speed,
3905                on_arrive,
3906                requires_flags,
3907                forbids_flags,
3908                ..
3909            } => {
3910                let mut d = f.debug_struct("MoveNpc");
3911                d.field("npc", npc)
3912                    .field("to_anchor", to_anchor)
3913                    .field("speed", speed);
3914                if !on_arrive.is_empty() {
3915                    d.field("on_arrive", on_arrive);
3916                }
3917                d.field("requires_flags", requires_flags);
3918                ff(&mut d, forbids_flags).finish()
3919            }
3920            QuestEffect::Cutscene {
3921                shots,
3922                path,
3923                seconds,
3924                look_at,
3925                requires_flags,
3926                forbids_flags,
3927            } => ff(
3928                f.debug_struct("Cutscene")
3929                    .field("shots", shots)
3930                    .field("path", path)
3931                    .field("seconds", seconds)
3932                    .field("look_at", look_at)
3933                    .field("requires_flags", requires_flags),
3934                forbids_flags,
3935            )
3936            .finish(),
3937            QuestEffect::SetTime {
3938                time,
3939                requires_flags,
3940                forbids_flags,
3941            } => ff(
3942                f.debug_struct("SetTime")
3943                    .field("time", time)
3944                    .field("requires_flags", requires_flags),
3945                forbids_flags,
3946            )
3947            .finish(),
3948            QuestEffect::SetWeather {
3949                weather,
3950                requires_flags,
3951                forbids_flags,
3952            } => ff(
3953                f.debug_struct("SetWeather")
3954                    .field("weather", weather)
3955                    .field("requires_flags", requires_flags),
3956                forbids_flags,
3957            )
3958            .finish(),
3959            QuestEffect::PlaySound {
3960                sound,
3961                at,
3962                volume,
3963                pitch,
3964                requires_flags,
3965                forbids_flags,
3966            } => ff(
3967                f.debug_struct("PlaySound")
3968                    .field("sound", sound)
3969                    .field("at", at)
3970                    .field("volume", volume)
3971                    .field("pitch", pitch)
3972                    .field("requires_flags", requires_flags),
3973                forbids_flags,
3974            )
3975            .finish(),
3976            QuestEffect::DamagePlayers {
3977                amount,
3978                within,
3979                damage_type,
3980                requires_flags,
3981                forbids_flags,
3982            } => ff(
3983                f.debug_struct("DamagePlayers")
3984                    .field("amount", amount)
3985                    .field("within", within)
3986                    .field("damage_type", damage_type)
3987                    .field("requires_flags", requires_flags),
3988                forbids_flags,
3989            )
3990            .finish(),
3991            QuestEffect::SetCheckpoint { anchor, on_respawn } => f
3992                .debug_struct("SetCheckpoint")
3993                .field("anchor", anchor)
3994                .field("on_respawn", on_respawn)
3995                .finish(),
3996            QuestEffect::Bonfire {
3997                anchor,
3998                on_rest,
3999                prompt,
4000                rest_label,
4001                save_label,
4002            } => f
4003                .debug_struct("Bonfire")
4004                .field("anchor", anchor)
4005                .field("on_rest", on_rest)
4006                .field("prompt", prompt)
4007                .field("rest_label", rest_label)
4008                .field("save_label", save_label)
4009                .finish(),
4010            QuestEffect::BeginStealth {
4011                zones,
4012                on_caught,
4013                grace_ticks,
4014            } => f
4015                .debug_struct("BeginStealth")
4016                .field("zones", zones)
4017                .field("on_caught", on_caught)
4018                .field("grace_ticks", grace_ticks)
4019                .finish(),
4020            QuestEffect::EndStealth => f.write_str("EndStealth"),
4021            QuestEffect::SpawnNpc { npc, .. } => {
4022                f.debug_struct("SpawnNpc").field("npc", npc).finish()
4023            }
4024            QuestEffect::SpawnActor { actor, .. } => {
4025                f.debug_struct("SpawnActor").field("actor", actor).finish()
4026            }
4027            QuestEffect::DespawnActor { actor, style, .. } => f
4028                .debug_struct("DespawnActor")
4029                .field("actor", actor)
4030                .field("style", style)
4031                .finish(),
4032            QuestEffect::MoveActor {
4033                actor,
4034                to_anchor,
4035                speed,
4036                on_arrive,
4037                ..
4038            } => f
4039                .debug_struct("MoveActor")
4040                .field("actor", actor)
4041                .field("to_anchor", to_anchor)
4042                .field("speed", speed)
4043                .field("on_arrive", on_arrive)
4044                .finish(),
4045            QuestEffect::UnleashActor { actor, .. } => f
4046                .debug_struct("UnleashActor")
4047                .field("actor", actor)
4048                .finish(),
4049            QuestEffect::Sequence { steps } => {
4050                f.debug_struct("Sequence").field("steps", steps).finish()
4051            }
4052            // spec-0022 additions. These are NEW variants, so there is no
4053            // pre-addition rendering to preserve — every field prints
4054            // unconditionally, which is the stable choice going forward.
4055            QuestEffect::Volley {
4056                projectile,
4057                from_anchor,
4058                kill_zone,
4059                salvos,
4060                interval,
4061                requires_flags,
4062                forbids_flags,
4063            } => f
4064                .debug_struct("Volley")
4065                .field("projectile", projectile)
4066                .field("from_anchor", from_anchor)
4067                .field("kill_zone", kill_zone)
4068                .field("salvos", salvos)
4069                .field("interval", interval)
4070                .field("requires_flags", requires_flags)
4071                .field("forbids_flags", forbids_flags)
4072                .finish(),
4073            QuestEffect::Collapse {
4074                region_anchor,
4075                falling_block,
4076                then_floor,
4077                requires_flags,
4078                forbids_flags,
4079            } => f
4080                .debug_struct("Collapse")
4081                .field("region_anchor", region_anchor)
4082                .field("falling_block", falling_block)
4083                .field("then_floor", then_floor)
4084                .field("requires_flags", requires_flags)
4085                .field("forbids_flags", forbids_flags)
4086                .finish(),
4087        }
4088    }
4089}
4090
4091/// Default `grace_ticks` for [`QuestEffect::BeginStealth`] (spec-0014).
4092fn default_grace_ticks() -> u32 {
4093    20
4094}
4095
4096/// Default projectile for [`QuestEffect::Volley`] (spec-0022).
4097pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
4098/// Default salvo count for [`QuestEffect::Volley`] (spec-0022).
4099pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
4100/// Default ticks between salvos for [`QuestEffect::Volley`] (spec-0022).
4101pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
4102/// Largest admissible `salvos` — beyond this a volley is an entity-count
4103/// hazard rather than a trap (`DW0443`).
4104pub const MAX_VOLLEY_SALVOS: u32 = 16;
4105/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
4106/// slower than this is no longer one event the player reads as a trap.
4107pub const MAX_VOLLEY_INTERVAL: u32 = 200;
4108/// Default falling block for [`QuestEffect::Collapse`] (spec-0022).
4109pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
4110
4111/// A stealth "shadow" region (DSL v0.6, spec-0014): an axis-aligned box centred
4112/// on `anchor`, extending `extent` blocks along each axis (so the box spans
4113/// `anchor ± extent`). Presented in-world via dark cells but judged purely by
4114/// region membership, so the check is deterministic and provable.
4115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4116#[serde(deny_unknown_fields)]
4117pub struct StealthZone {
4118    /// The prefab anchor at the centre of the zone box.
4119    pub anchor: AnchorId,
4120    /// Half-extents `[x, y, z]` in blocks from the anchor (each component ≥ 0);
4121    /// the zone AABB is `[anchor - extent, anchor + extent]`.
4122    pub extent: [u32; 3],
4123}
4124
4125/// Where a [`QuestEffect::PlaySound`] originates (DSL v0.6, spec-0014). The
4126/// `actor` variant is accepted by the schema but not yet wired — it is rejected
4127/// with `DW0335` until the actors surface (spec-0014 `actors[]`) lands, at which
4128/// point it resolves to the actor's position like `move-actor` does.
4129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4130#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
4131pub enum SoundAt {
4132    /// Play the sound positioned at a resolved anchor, audible to all players.
4133    Anchor {
4134        /// The anchor the sound plays from.
4135        anchor: AnchorId,
4136    },
4137    /// Play the sound at each player's own position (the default).
4138    Players,
4139    /// Play the sound at a scripted actor's position (deferred — `DW0335` until
4140    /// the actors surface lands).
4141    Actor {
4142        /// The actor id (stage-5 `actors[]`; not yet resolvable).
4143        actor: String,
4144    },
4145}
4146
4147/// The damage type of a [`QuestEffect::DamagePlayers`] effect (DSL v0.6). A
4148/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
4149/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
4150/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
4151/// `generic_kill` types are deliberately excluded, so a scripted consequence can
4152/// never silently void a player's held totem. Modelled as an enum (not a free
4153/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
4154/// registry / diagnostic. `generic` is the default: command damage that respects
4155/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
4156#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4157#[serde(rename_all = "kebab-case")]
4158pub enum DamageKind {
4159    /// `minecraft:generic` — armor-ignoring command damage (default).
4160    Generic,
4161    /// `minecraft:magic` — magical damage.
4162    Magic,
4163    /// `minecraft:wither` — the wither/withering effect's damage type.
4164    Wither,
4165    /// `minecraft:on_fire` — burning damage.
4166    Fire,
4167    /// `minecraft:drown` — drowning damage.
4168    Drown,
4169    /// `minecraft:freeze` — powder-snow freezing damage.
4170    Freeze,
4171    /// `minecraft:fall` — fall damage.
4172    Fall,
4173    /// `minecraft:lightning_bolt` — a lightning strike's damage type.
4174    LightningBolt,
4175    /// `minecraft:explosion` — a (non-player) explosion's damage type.
4176    Explosion,
4177}
4178
4179impl DamageKind {
4180    /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
4181    pub fn id(self) -> &'static str {
4182        match self {
4183            DamageKind::Generic => "minecraft:generic",
4184            DamageKind::Magic => "minecraft:magic",
4185            DamageKind::Wither => "minecraft:wither",
4186            DamageKind::Fire => "minecraft:on_fire",
4187            DamageKind::Drown => "minecraft:drown",
4188            DamageKind::Freeze => "minecraft:freeze",
4189            DamageKind::Fall => "minecraft:fall",
4190            DamageKind::LightningBolt => "minecraft:lightning_bolt",
4191            DamageKind::Explosion => "minecraft:explosion",
4192        }
4193    }
4194}
4195
4196/// The presentation channel for a [`QuestEffect::Narrate`] (DSL v0.4).
4197#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4198#[serde(rename_all = "kebab-case")]
4199pub enum NarrateStyle {
4200    /// A chat line (default).
4201    Chat,
4202    /// A large on-screen title.
4203    Title,
4204    /// An on-screen subtitle.
4205    Subtitle,
4206    /// An "art" title rendered through the delve's custom resource-pack pixel-banner
4207    /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
4208    /// spec-0014). Text is checked at compile time against the font's glyph inventory
4209    /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
4210    /// renders in the vanilla title slot, so it is width-checked like any title
4211    /// (`DW0330`) — roughly 15 glyphs fit on screen.
4212    Art,
4213}
4214
4215impl NarrateStyle {
4216    /// The kebab tag (`chat` / `title` / `subtitle` / `art`).
4217    pub fn token(self) -> &'static str {
4218        match self {
4219            NarrateStyle::Chat => "chat",
4220            NarrateStyle::Title => "title",
4221            NarrateStyle::Subtitle => "subtitle",
4222            NarrateStyle::Art => "art",
4223        }
4224    }
4225}
4226
4227/// One camera waypoint of a [`QuestEffect::Cutscene`] (DSL v0.4): an anchor plus
4228/// an integer block offset from it, giving the camera's world position.
4229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4230#[serde(deny_unknown_fields)]
4231pub struct CameraWaypoint {
4232    /// The anchor the waypoint is relative to.
4233    pub anchor: AnchorId,
4234    /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
4235    #[serde(default, skip_serializing_if = "is_zero3")]
4236    pub offset: [i32; 3],
4237}
4238
4239/// One shot of a [`QuestEffect::Cutscene`] (DSL v0.6): a camera dolly with its
4240/// own duration and optional subject. A cutscene plays its shots back-to-back —
4241/// a hard cut between them — inside a single gamemode/position save-restore
4242/// bracket, so a wide establishing move can be followed by an interior close-up
4243/// without the players ever leaving the cinematic.
4244#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4245#[serde(deny_unknown_fields)]
4246pub struct CameraShot {
4247    /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
4248    /// path is a static shot. Required without `shot_style`; with one, optional —
4249    /// an explicit `path` always overrides the style's expanded dolly.
4250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4251    pub path: Vec<CameraWaypoint>,
4252    /// This shot's duration in seconds. Required without `shot_style`; with one,
4253    /// optional — the style's default duration applies (see
4254    /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
4255    #[serde(default, skip_serializing_if = "Option::is_none")]
4256    pub seconds: Option<u32>,
4257    /// Optional subject the camera keeps framed for this shot. Absent = face
4258    /// along the direction of travel (or, under a `shot_style`, the style's own
4259    /// aim at its `subject`). An explicit `look_at` always overrides a style's
4260    /// aim.
4261    #[serde(default, skip_serializing_if = "Option::is_none")]
4262    pub look_at: Option<CameraTarget>,
4263    /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
4264    /// compiler expands the style deterministically into a camera dolly +
4265    /// per-keyframe aim from the `subject`'s resolved geometry. Requires
4266    /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
4267    /// the corresponding expanded part (`DW0348` polices the combinations).
4268    #[serde(default, skip_serializing_if = "Option::is_none")]
4269    pub shot_style: Option<ShotStyle>,
4270    /// The styled shot's subject — what the shot is *about*. Required with
4271    /// `shot_style`, rejected without one (`DW0348`).
4272    #[serde(default, skip_serializing_if = "Option::is_none")]
4273    pub subject: Option<CameraSubject>,
4274    /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
4275    #[serde(default, skip_serializing_if = "Option::is_none")]
4276    pub subject_b: Option<CameraSubject>,
4277    /// Styled shots only: the style's characteristic camera distance in blocks
4278    /// (its *start* distance for the dolly styles). Default per style — see the
4279    /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
4280    /// (`DW0348`).
4281    #[serde(default, skip_serializing_if = "Option::is_none")]
4282    pub dist: Option<f64>,
4283    /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
4284    /// default 90 (`DW0348` elsewhere or out of range).
4285    #[serde(default, skip_serializing_if = "Option::is_none")]
4286    pub degrees: Option<f64>,
4287    /// Styled shots only: placement bearing in degrees — where the camera sits
4288    /// (or starts) relative to the subject, measured like a Minecraft yaw
4289    /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
4290    /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
4291    /// `side-track` the bearing picks which side of the subject's travel the
4292    /// camera runs abeam (`0` = right of travel, `180` = left).
4293    #[serde(default, skip_serializing_if = "Option::is_none")]
4294    pub bearing: Option<f64>,
4295}
4296
4297/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
4298/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
4299/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
4300/// is **distance only** — vanilla has no in-game FOV control.
4301#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4302#[serde(rename_all = "kebab-case")]
4303pub enum ShotStyle {
4304    /// Fully static close framing of a prop/detail. The beat that always looks
4305    /// right.
4306    Insert,
4307    /// Static position; only the aim turns as the subject (ideally moving)
4308    /// passes. Rockstar's roadside "ground view".
4309    LockedOff,
4310    /// Straight dolly toward the subject along the view axis; medium → close.
4311    PushIn,
4312    /// Reverse of `push-in`: close → wide, revealing context.
4313    PullBackReveal,
4314    /// High and far, descending and closing on the subject. First sight of an
4315    /// area.
4316    EstablishingCrane,
4317    /// Constant-radius, constant-height arc around the subject.
4318    OrbitArc,
4319    /// Parallel dolly abeam a **moving** subject at constant offset — needs a
4320    /// compiler-known subject path (`DW0349`).
4321    SideTrack,
4322    /// Static placement solved so **both** subjects land on opposite thirds
4323    /// (Toric-space construction). Needs `subject_b`.
4324    TwoShot,
4325    /// Low, close, trailing a **moving** subject near ground level — needs a
4326    /// compiler-known subject path (`DW0349`).
4327    LowFollow,
4328}
4329
4330impl ShotStyle {
4331    /// The kebab token (diagnostics, digests).
4332    pub fn token(self) -> &'static str {
4333        match self {
4334            ShotStyle::Insert => "insert",
4335            ShotStyle::LockedOff => "locked-off",
4336            ShotStyle::PushIn => "push-in",
4337            ShotStyle::PullBackReveal => "pull-back-reveal",
4338            ShotStyle::EstablishingCrane => "establishing-crane",
4339            ShotStyle::OrbitArc => "orbit-arc",
4340            ShotStyle::SideTrack => "side-track",
4341            ShotStyle::TwoShot => "two-shot",
4342            ShotStyle::LowFollow => "low-follow",
4343        }
4344    }
4345
4346    /// Default shot duration in seconds, from the dossier's per-style duration
4347    /// ranges (§2, anchored on the film-editing ASL literature) — the value an
4348    /// omitted `seconds` resolves to.
4349    pub fn default_seconds(self) -> u32 {
4350        match self {
4351            ShotStyle::Insert => 2,
4352            ShotStyle::LockedOff => 6,
4353            ShotStyle::PushIn => 4,
4354            ShotStyle::PullBackReveal => 6,
4355            ShotStyle::EstablishingCrane => 8,
4356            ShotStyle::OrbitArc => 8,
4357            ShotStyle::SideTrack => 8,
4358            ShotStyle::TwoShot => 5,
4359            ShotStyle::LowFollow => 5,
4360        }
4361    }
4362
4363    /// `true` for the styles whose subject must be *moving* on a compiler-known
4364    /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
4365    /// `side-track` and `low-follow` (`DW0349` otherwise).
4366    pub fn needs_moving_subject(self) -> bool {
4367        matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
4368    }
4369}
4370
4371/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
4372/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
4373/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
4374/// **plus one block up** (torso height, so a close shot does not frame feet)
4375/// before `offset` is applied; an `anchor` subject aims at the block centre
4376/// exactly like a [`CameraTarget`].
4377/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
4378/// (task #78). Serde has no variant-level `deny_unknown_fields`, so an untagged
4379/// enum with inline struct variants silently *ignores* any key it does not
4380/// recognise: `{"npc": …, "ofset": [0,1,0]}` deserialized happily with the offset
4381/// dropped, and `{"anchor": …, "npc": …}` quietly matched `Anchor` and discarded
4382/// the NPC. Lifting each variant into its own type restores the repo-wide
4383/// deny-unknown rule for both serde and the published JSON Schema
4384/// (`additionalProperties: false`): a mistyped shot subject now fails the schema
4385/// instead of rendering a shot pointed somewhere the author never asked for.
4386#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4387#[serde(untagged)]
4388pub enum CameraSubject {
4389    /// A fixed world point: prefab anchor + offset.
4390    Anchor(AnchorSubject),
4391    /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
4392    /// group / sequence, else static at its declared (or spawn) anchor.
4393    Npc(NpcSubject),
4394    /// A stage-5 actor — moving if a `move-actor` for it runs in the same
4395    /// effect group / sequence, else static at its declared anchor.
4396    Actor(ActorSubject),
4397}
4398
4399/// A [`CameraSubject::Anchor`] payload: a fixed world point.
4400#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4401#[serde(deny_unknown_fields)]
4402pub struct AnchorSubject {
4403    /// The anchor the subject sits at.
4404    pub anchor: AnchorId,
4405    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
4406    #[serde(default, skip_serializing_if = "is_zero3")]
4407    pub offset: [i32; 3],
4408}
4409
4410/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
4411#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4412#[serde(deny_unknown_fields)]
4413pub struct NpcSubject {
4414    /// The NPC (stage-2 ref).
4415    pub npc: NpcId,
4416    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
4417    #[serde(default, skip_serializing_if = "is_zero3")]
4418    pub offset: [i32; 3],
4419}
4420
4421/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
4422#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4423#[serde(deny_unknown_fields)]
4424pub struct ActorSubject {
4425    /// The actor (stage-5 `actors` ref).
4426    pub actor: ActorId,
4427    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
4428    #[serde(default, skip_serializing_if = "is_zero3")]
4429    pub offset: [i32; 3],
4430}
4431
4432impl CameraSubject {
4433    /// The subject's integer offset, whichever variant.
4434    pub fn offset(&self) -> [i32; 3] {
4435        match self {
4436            CameraSubject::Anchor(s) => s.offset,
4437            CameraSubject::Npc(s) => s.offset,
4438            CameraSubject::Actor(s) => s.offset,
4439        }
4440    }
4441
4442    /// A short canonical rendering for digests/diagnostics.
4443    pub fn canon(&self) -> String {
4444        let (kind, id, o) = match self {
4445            CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
4446            CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
4447            CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
4448        };
4449        format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
4450    }
4451}
4452
4453/// `Debug` is hand-written because it is a **stable content-key rendering**:
4454/// the compiler's `sequence_key` (FNV over `{steps:?}`) names generated
4455/// `seq_<hash>` functions from it, so a shot that uses none of the v0.6 style
4456/// fields must render byte-identically to the pre-style struct (`seconds`
4457/// prints its inner value; absent style fields print nothing) — otherwise
4458/// every existing sequence containing a cutscene would silently churn its
4459/// function names on a purely additive schema change.
4460impl std::fmt::Debug for CameraShot {
4461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4462        let mut d = f.debug_struct("CameraShot");
4463        d.field("path", &self.path);
4464        match self.seconds {
4465            Some(v) => d.field("seconds", &v),
4466            None => d.field("seconds", &self.seconds),
4467        };
4468        d.field("look_at", &self.look_at);
4469        if self.shot_style.is_some() {
4470            d.field("shot_style", &self.shot_style);
4471        }
4472        if self.subject.is_some() {
4473            d.field("subject", &self.subject);
4474        }
4475        if self.subject_b.is_some() {
4476            d.field("subject_b", &self.subject_b);
4477        }
4478        if self.dist.is_some() {
4479            d.field("dist", &self.dist);
4480        }
4481        if self.degrees.is_some() {
4482            d.field("degrees", &self.degrees);
4483        }
4484        if self.bearing.is_some() {
4485            d.field("bearing", &self.bearing);
4486        }
4487        d.finish()
4488    }
4489}
4490
4491impl CameraShot {
4492    /// The shot's resolved duration in seconds: explicit `seconds`, else the
4493    /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
4494    /// fallback only keeps downstream passes total).
4495    pub fn resolved_seconds(&self) -> u32 {
4496        self.seconds
4497            .or(self.shot_style.map(ShotStyle::default_seconds))
4498            .unwrap_or(1)
4499    }
4500}
4501
4502/// The subject a [`QuestEffect::Cutscene`] camera keeps framed (DSL v0.6): an
4503/// anchor plus an integer block offset from it, giving the world point every
4504/// dolly camera is aimed at. Same shape as a [`CameraWaypoint`] — a waypoint says
4505/// where the camera *is*, a target says what it *looks at*.
4506#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4507#[serde(deny_unknown_fields)]
4508pub struct CameraTarget {
4509    /// The anchor the look target is relative to.
4510    pub anchor: AnchorId,
4511    /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
4512    #[serde(default, skip_serializing_if = "is_zero3")]
4513    pub offset: [i32; 3],
4514}
4515
4516/// serde `skip_serializing_if` helper: skip a `[0, 0, 0]` offset.
4517fn is_zero3(v: &[i32; 3]) -> bool {
4518    *v == [0, 0, 0]
4519}
4520
4521impl QuestEffect {
4522    /// The kebab-case `type` tag this effect serializes as — the verb a
4523    /// diagnostic should name so the author can find it in the JSON.
4524    pub fn verb(&self) -> &'static str {
4525        match self {
4526            QuestEffect::OpenGate { .. } => "open-gate",
4527            QuestEffect::CloseGate { .. } => "close-gate",
4528            QuestEffect::CampaignComplete { .. } => "campaign-complete",
4529            QuestEffect::GiveItem { .. } => "give-item",
4530            QuestEffect::SetFlag { .. } => "set-flag",
4531            QuestEffect::SpawnWave { .. } => "spawn-wave",
4532            QuestEffect::Narrate { .. } => "narrate",
4533            QuestEffect::SetBlock { .. } => "set-block",
4534            QuestEffect::DespawnNpc { .. } => "despawn-npc",
4535            QuestEffect::MoveNpc { .. } => "move-npc",
4536            QuestEffect::Cutscene { .. } => "cutscene",
4537            QuestEffect::SetTime { .. } => "set-time",
4538            QuestEffect::SetWeather { .. } => "set-weather",
4539            QuestEffect::PlaySound { .. } => "play-sound",
4540            QuestEffect::DamagePlayers { .. } => "damage-players",
4541            QuestEffect::SetCheckpoint { .. } => "set-checkpoint",
4542            QuestEffect::Bonfire { .. } => "bonfire",
4543            QuestEffect::BeginStealth { .. } => "begin-stealth",
4544            QuestEffect::EndStealth => "end-stealth",
4545            QuestEffect::SpawnNpc { .. } => "spawn-npc",
4546            QuestEffect::SpawnActor { .. } => "spawn-actor",
4547            QuestEffect::DespawnActor { .. } => "despawn-actor",
4548            QuestEffect::MoveActor { .. } => "move-actor",
4549            QuestEffect::UnleashActor { .. } => "unleash-actor",
4550            QuestEffect::Sequence { .. } => "sequence",
4551            QuestEffect::Volley { .. } => "volley",
4552            QuestEffect::Collapse { .. } => "collapse",
4553        }
4554    }
4555
4556    /// The gate anchor if this is `open-gate`.
4557    pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
4558        match self {
4559            QuestEffect::OpenGate { anchor, .. } => Some(anchor),
4560            _ => None,
4561        }
4562    }
4563
4564    /// The gate anchor if this is `close-gate` (DSL v0.6).
4565    pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
4566        match self {
4567            QuestEffect::CloseGate { anchor, .. } => Some(anchor),
4568            _ => None,
4569        }
4570    }
4571
4572    /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
4573    /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
4574    /// the compiler's canonical English seal line.
4575    pub fn close_gate_sealed_hint(&self) -> Option<&str> {
4576        match self {
4577            QuestEffect::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
4578            _ => None,
4579        }
4580    }
4581
4582    /// The wave id if this is `spawn-wave` (v0.3).
4583    pub fn spawn_wave(&self) -> Option<&WaveId> {
4584        match self {
4585            QuestEffect::SpawnWave { wave, .. } => Some(wave),
4586            _ => None,
4587        }
4588    }
4589
4590    /// The flag id if this is `set-flag` (v0.3).
4591    pub fn set_flag(&self) -> Option<&FlagId> {
4592        match self {
4593            QuestEffect::SetFlag { flag, .. } => Some(flag),
4594            _ => None,
4595        }
4596    }
4597
4598    /// The item id if this is `give-item` (v0.3).
4599    pub fn give_item(&self) -> Option<&str> {
4600        match self {
4601            QuestEffect::GiveItem { item, .. } => Some(item),
4602            _ => None,
4603        }
4604    }
4605
4606    /// `true` if this is a `give-item` carrying a v0.4 display `name`.
4607    pub fn give_item_named(&self) -> bool {
4608        matches!(self, QuestEffect::GiveItem { name: Some(_), .. })
4609    }
4610
4611    /// The declared `carrier` if this is a `give-item` that states one (v0.6,
4612    /// spec-0018). `None` for any other effect **and** for a `give-item` that
4613    /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
4614    /// matters only for the pre-0.6 reserved-field gate.
4615    pub fn give_carrier(&self) -> Option<Carrier> {
4616        match self {
4617            QuestEffect::GiveItem { carrier, .. } => *carrier,
4618            _ => None,
4619        }
4620    }
4621
4622    /// Does this `give-item` hand a single copy to the acting player (v0.6,
4623    /// spec-0018)? `false` for every other effect and for the party-wide default.
4624    pub fn gives_to_one(&self) -> bool {
4625        matches!(self.give_carrier(), Some(Carrier::One))
4626    }
4627
4628    /// The `set-block` block id if this is a v0.4 `set-block` effect.
4629    pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
4630        match self {
4631            QuestEffect::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
4632            _ => None,
4633        }
4634    }
4635
4636    /// The NPC id if this is a v0.4 `despawn-npc` effect.
4637    pub fn despawn_npc(&self) -> Option<&NpcId> {
4638        match self {
4639            QuestEffect::DespawnNpc { npc, .. } => Some(npc),
4640            _ => None,
4641        }
4642    }
4643
4644    /// `(npc, to_anchor)` if this is a v0.4 `move-npc` effect.
4645    pub fn move_npc(&self) -> Option<(&NpcId, &AnchorId)> {
4646        match self {
4647            QuestEffect::MoveNpc { npc, to_anchor, .. } => Some((npc, to_anchor)),
4648            _ => None,
4649        }
4650    }
4651
4652    /// The v0.3 effect name if this effect is one introduced in DSL v0.3
4653    /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
4654    /// and are reserved (`DW0141`) in v0.2 campaigns.
4655    pub fn v03_effect(&self) -> Option<&'static str> {
4656        match self {
4657            QuestEffect::GiveItem { .. } => Some("give-item"),
4658            QuestEffect::SetFlag { .. } => Some("set-flag"),
4659            QuestEffect::SpawnWave { .. } => Some("spawn-wave"),
4660            QuestEffect::OpenGate { .. }
4661            | QuestEffect::CloseGate { .. }
4662            | QuestEffect::CampaignComplete { .. } => None,
4663            // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
4664            // are not v0.3 verbs.
4665            QuestEffect::Narrate { .. }
4666            | QuestEffect::SetBlock { .. }
4667            | QuestEffect::DespawnNpc { .. }
4668            | QuestEffect::MoveNpc { .. }
4669            | QuestEffect::Cutscene { .. }
4670            | QuestEffect::SetTime { .. }
4671            | QuestEffect::SetWeather { .. }
4672            | QuestEffect::PlaySound { .. }
4673            | QuestEffect::DamagePlayers { .. }
4674            | QuestEffect::SetCheckpoint { .. }
4675            | QuestEffect::Bonfire { .. }
4676            | QuestEffect::BeginStealth { .. }
4677            | QuestEffect::EndStealth
4678            | QuestEffect::SpawnActor { .. }
4679            | QuestEffect::DespawnActor { .. }
4680            | QuestEffect::MoveActor { .. }
4681            | QuestEffect::UnleashActor { .. }
4682            | QuestEffect::SpawnNpc { .. }
4683            | QuestEffect::Sequence { .. }
4684            // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
4685            | QuestEffect::Volley { .. }
4686            | QuestEffect::Collapse { .. } => None,
4687        }
4688    }
4689
4690    /// The v0.4 effect name if this effect is one introduced in DSL v0.4
4691    /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
4692    /// in v0.4 campaigns and are reserved (`DW0141`) earlier.
4693    pub fn v04_effect(&self) -> Option<&'static str> {
4694        match self {
4695            QuestEffect::Narrate { .. } => Some("narrate"),
4696            QuestEffect::SetBlock { .. } => Some("set-block"),
4697            QuestEffect::DespawnNpc { .. } => Some("despawn-npc"),
4698            QuestEffect::MoveNpc { .. } => Some("move-npc"),
4699            QuestEffect::Cutscene { .. } => Some("cutscene"),
4700            _ => None,
4701        }
4702    }
4703
4704    /// The v0.5 effect name if this effect is one introduced in DSL v0.5
4705    /// (`set-time`/`set-weather`, spec-0010). These validate in v0.5 campaigns and
4706    /// are reserved (`DW0141`) earlier.
4707    pub fn v05_effect(&self) -> Option<&'static str> {
4708        match self {
4709            QuestEffect::SetTime { .. } => Some("set-time"),
4710            QuestEffect::SetWeather { .. } => Some("set-weather"),
4711            _ => None,
4712        }
4713    }
4714
4715    /// The v0.6 effect name if this effect is one introduced in DSL v0.6
4716    /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
4717    /// `play-sound`, spec-0014; the scripted-actor staging verbs
4718    /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
4719    /// spec-0014). These validate in v0.6 campaigns and are reserved (`DW0141`)
4720    /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
4721    /// — see [`QuestEffect::narrate_art`] — not a new effect.)
4722    pub fn v06_effect(&self) -> Option<&'static str> {
4723        match self {
4724            QuestEffect::CloseGate { .. } => Some("close-gate"),
4725            QuestEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
4726            QuestEffect::Bonfire { .. } => Some("bonfire"),
4727            QuestEffect::BeginStealth { .. } => Some("begin-stealth"),
4728            QuestEffect::EndStealth => Some("end-stealth"),
4729            QuestEffect::PlaySound { .. } => Some("play-sound"),
4730            QuestEffect::DamagePlayers { .. } => Some("damage-players"),
4731            QuestEffect::SpawnActor { .. } => Some("spawn-actor"),
4732            QuestEffect::DespawnActor { .. } => Some("despawn-actor"),
4733            QuestEffect::MoveActor { .. } => Some("move-actor"),
4734            QuestEffect::UnleashActor { .. } => Some("unleash-actor"),
4735            QuestEffect::Sequence { .. } => Some("sequence"),
4736            QuestEffect::SpawnNpc { .. } => Some("spawn-npc"),
4737            // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
4738            QuestEffect::Volley { .. } => Some("volley"),
4739            QuestEffect::Collapse { .. } => Some("collapse"),
4740            _ => None,
4741        }
4742    }
4743
4744    /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
4745    /// `volley` (spec-0022), with the documented defaults already applied.
4746    pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
4747        match self {
4748            QuestEffect::Volley {
4749                projectile,
4750                from_anchor,
4751                kill_zone,
4752                salvos,
4753                interval,
4754                ..
4755            } => Some((
4756                projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
4757                from_anchor,
4758                kill_zone,
4759                salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
4760                interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
4761            )),
4762            _ => None,
4763        }
4764    }
4765
4766    /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
4767    /// (spec-0022), with the documented default already applied.
4768    pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
4769        match self {
4770            QuestEffect::Collapse {
4771                region_anchor,
4772                falling_block,
4773                then_floor,
4774                ..
4775            } => Some((
4776                region_anchor,
4777                falling_block
4778                    .as_deref()
4779                    .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
4780                then_floor.as_deref(),
4781            )),
4782            _ => None,
4783        }
4784    }
4785
4786    /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
4787    /// [`QuestEffect::despawn_npc`]).
4788    pub fn spawn_npc(&self) -> Option<&NpcId> {
4789        match self {
4790            QuestEffect::SpawnNpc { npc, .. } => Some(npc),
4791            _ => None,
4792        }
4793    }
4794
4795    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
4796    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
4797        match self {
4798            QuestEffect::SetCheckpoint { anchor, on_respawn } => {
4799                Some((anchor, on_respawn.as_slice()))
4800            }
4801            _ => None,
4802        }
4803    }
4804
4805    /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
4806    pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
4807        match self {
4808            QuestEffect::Bonfire {
4809                anchor, on_rest, ..
4810            } => Some((anchor, on_rest.as_slice())),
4811            _ => None,
4812        }
4813    }
4814
4815    /// The bonfire's authored rest-dialog strings, each `None` when unauthored
4816    /// (the compiler then bakes its canonical English). `None` for every other
4817    /// effect. spec-0016 §1, owner ruling 2026-08-03.
4818    pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
4819        match self {
4820            QuestEffect::Bonfire {
4821                prompt,
4822                rest_label,
4823                save_label,
4824                ..
4825            } => Some(BonfireLabels {
4826                prompt: prompt.as_deref(),
4827                rest_label: rest_label.as_deref(),
4828                save_label: save_label.as_deref(),
4829            }),
4830            _ => None,
4831        }
4832    }
4833
4834    /// The `within` filter zone if this is a v0.6 `damage-players` effect that
4835    /// declares one (the `in` spatial scope). `None` for an unscoped
4836    /// `damage-players` and for every other effect.
4837    pub fn damage_within(&self) -> Option<&StealthZone> {
4838        match self {
4839            QuestEffect::DamagePlayers { within, .. } => within.as_ref(),
4840            _ => None,
4841        }
4842    }
4843
4844    /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
4845    pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
4846        match self {
4847            QuestEffect::BeginStealth {
4848                zones,
4849                on_caught,
4850                grace_ticks,
4851            } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
4852            _ => None,
4853        }
4854    }
4855
4856    /// The target time if this is a v0.5 `set-time` effect.
4857    pub fn set_time(&self) -> Option<WorldTime> {
4858        match self {
4859            QuestEffect::SetTime { time, .. } => Some(*time),
4860            _ => None,
4861        }
4862    }
4863
4864    /// The target weather if this is a v0.5 `set-weather` effect.
4865    pub fn set_weather(&self) -> Option<WorldWeather> {
4866        match self {
4867            QuestEffect::SetWeather { weather, .. } => Some(*weather),
4868            _ => None,
4869        }
4870    }
4871
4872    /// The effect lists nested one level inside this effect (DSL v0.6): a
4873    /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
4874    /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
4875    /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
4876    ///
4877    /// This is the **single authority** on effect nesting. Every deep traversal —
4878    /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
4879    /// string inventory, and emission — walks the tree through it (see
4880    /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
4881    /// once and no walker can silently miss a list (the class of bug where a
4882    /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
4883    pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
4884        match self {
4885            QuestEffect::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
4886            QuestEffect::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
4887            QuestEffect::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
4888            QuestEffect::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
4889            QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
4890                vec![on_arrive.as_slice()]
4891            }
4892            _ => Vec::new(),
4893        }
4894    }
4895
4896    /// Visit `self` and every transitively nested effect (depth-first, pre-order),
4897    /// descending through [`Self::nested_effect_lists`].
4898    pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
4899        f(self);
4900        for list in self.nested_effect_lists() {
4901            for e in list {
4902                e.visit_deep(f);
4903            }
4904        }
4905    }
4906
4907    /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
4908    /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
4909    /// exposed mutably so the localization pass can rewrite nested player-visible
4910    /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
4911    /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
4912    /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
4913    /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
4914    /// derived segments make every derived key deterministic and stable across
4915    /// builds (ADR-0006 byte-identity).
4916    pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
4917        match self {
4918            QuestEffect::Sequence { steps } => steps
4919                .iter_mut()
4920                .enumerate()
4921                .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
4922                .collect(),
4923            QuestEffect::SetCheckpoint { on_respawn, .. } => {
4924                vec![("respawn".to_string(), on_respawn.as_mut_slice())]
4925            }
4926            QuestEffect::Bonfire { on_rest, .. } => {
4927                vec![("rest".to_string(), on_rest.as_mut_slice())]
4928            }
4929            QuestEffect::BeginStealth { on_caught, .. } => {
4930                vec![("caught".to_string(), on_caught.as_mut_slice())]
4931            }
4932            QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
4933                vec![("arrive".to_string(), on_arrive.as_mut_slice())]
4934            }
4935            _ => Vec::new(),
4936        }
4937    }
4938
4939    /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
4940    /// exposes the **JSON-pointer path segment** for each nested list, so a deep
4941    /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
4942    /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
4943    /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
4944    /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
4945    /// (same lists, same order): the l10n key segments match
4946    /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
4947    /// `arrive`), and the path segments name the real fields
4948    /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
4949    pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
4950        match self {
4951            QuestEffect::Sequence { steps } => steps
4952                .iter()
4953                .enumerate()
4954                .map(|(s, st)| {
4955                    (
4956                        format!("steps/{s}/effects"),
4957                        format!("seq.{s}"),
4958                        st.effects.as_slice(),
4959                    )
4960                })
4961                .collect(),
4962            QuestEffect::SetCheckpoint { on_respawn, .. } => vec![(
4963                "on_respawn".to_string(),
4964                "respawn".to_string(),
4965                on_respawn.as_slice(),
4966            )],
4967            QuestEffect::Bonfire { on_rest, .. } => vec![(
4968                "on_rest".to_string(),
4969                "rest".to_string(),
4970                on_rest.as_slice(),
4971            )],
4972            QuestEffect::BeginStealth { on_caught, .. } => vec![(
4973                "on_caught".to_string(),
4974                "caught".to_string(),
4975                on_caught.as_slice(),
4976            )],
4977            QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
4978                vec![(
4979                    "on_arrive".to_string(),
4980                    "arrive".to_string(),
4981                    on_arrive.as_slice(),
4982                )]
4983            }
4984            _ => Vec::new(),
4985        }
4986    }
4987
4988    /// Every world anchor this effect names **at this node** — the single
4989    /// authority on the anchor-bearing effect surface, the referential sibling of
4990    /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
4991    /// where the suffix is appended to the effect's own JSON pointer
4992    /// (`anchor`, `to_anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
4993    /// `shots/<i>/path/<j>/anchor`, …).
4994    ///
4995    /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
4996    /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
4997    /// reference scan, the compiler's build-time resolution seal (`DW0355`) —
4998    /// goes through here, so a new anchor-bearing variant (or a new anchor field
4999    /// on an existing one) is picked up by all of them at once. That closes the
5000    /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
5001    /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
5002    /// found no match) while every shallow validator looked straight past it.
5003    pub fn anchor_refs(&self) -> Vec<(String, &AnchorId)> {
5004        /// `(suffix, anchor)` for a shot's own anchor-bearing fields, under `base`.
5005        fn shot_refs<'a>(base: &str, shot: &'a CameraShot) -> Vec<(String, &'a AnchorId)> {
5006            let mut out: Vec<(String, &AnchorId)> = shot
5007                .path
5008                .iter()
5009                .enumerate()
5010                .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor))
5011                .collect();
5012            if let Some(t) = &shot.look_at {
5013                out.push((format!("{base}look_at/anchor"), &t.anchor));
5014            }
5015            for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
5016                if let Some(CameraSubject::Anchor(s)) = subject {
5017                    out.push((format!("{base}{field}/anchor"), &s.anchor));
5018                }
5019            }
5020            out
5021        }
5022        match self {
5023            QuestEffect::OpenGate { anchor, .. }
5024            | QuestEffect::CloseGate { anchor, .. }
5025            | QuestEffect::SetBlock { anchor, .. }
5026            | QuestEffect::SetCheckpoint { anchor, .. }
5027            | QuestEffect::Bonfire { anchor, .. } => vec![("anchor".to_string(), anchor)],
5028            QuestEffect::MoveNpc { to_anchor, .. } | QuestEffect::MoveActor { to_anchor, .. } => {
5029                vec![("to_anchor".to_string(), to_anchor)]
5030            }
5031            QuestEffect::DamagePlayers {
5032                within: Some(zone), ..
5033            } => vec![("in/anchor".to_string(), &zone.anchor)],
5034            QuestEffect::BeginStealth { zones, .. } => zones
5035                .iter()
5036                .enumerate()
5037                .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor))
5038                .collect(),
5039            QuestEffect::PlaySound {
5040                at: Some(SoundAt::Anchor { anchor }),
5041                ..
5042            } => vec![("at/anchor".to_string(), anchor)],
5043            // spec-0022 trap-payload verbs. Both anchors of a `volley` are
5044            // load-bearing for the coverage proof, so both register here — a
5045            // typo'd `kill_zone` must be a dangling-reference error, never a
5046            // silently zero-cell (and therefore vacuously "covered") volley.
5047            QuestEffect::Volley {
5048                from_anchor,
5049                kill_zone,
5050                ..
5051            } => vec![
5052                ("from_anchor".to_string(), from_anchor),
5053                ("kill_zone/anchor".to_string(), &kill_zone.anchor),
5054            ],
5055            QuestEffect::Collapse { region_anchor, .. } => {
5056                vec![("region_anchor/anchor".to_string(), &region_anchor.anchor)]
5057            }
5058            // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
5059            // multi-shot list, or the v0.4 single-shot fields flattened at the
5060            // effect's own level.
5061            QuestEffect::Cutscene {
5062                shots,
5063                path,
5064                look_at,
5065                ..
5066            } => {
5067                let mut out: Vec<(String, &AnchorId)> = shots
5068                    .iter()
5069                    .enumerate()
5070                    .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
5071                    .collect();
5072                out.extend(
5073                    path.iter()
5074                        .enumerate()
5075                        .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor)),
5076                );
5077                if let Some(t) = look_at {
5078                    out.push(("look_at/anchor".to_string(), &t.anchor));
5079                }
5080                out
5081            }
5082            _ => Vec::new(),
5083        }
5084    }
5085
5086    /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
5087    /// the v0.6 `look_at` field (reserved `DW0141` under a pre-0.6 campaign).
5088    pub fn cutscene_look_at(&self) -> Option<&CameraTarget> {
5089        match self {
5090            QuestEffect::Cutscene { look_at, .. } => look_at.as_ref(),
5091            _ => None,
5092        }
5093    }
5094
5095    /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
5096    /// (reserved `DW0141` under a pre-0.6 campaign).
5097    pub fn cutscene_multi_shot(&self) -> bool {
5098        matches!(self, QuestEffect::Cutscene { shots, .. } if !shots.is_empty())
5099    }
5100
5101    /// The normalized shot list of a `cutscene`, whichever spelling was used: the
5102    /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
5103    /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
5104    /// whose shape is invalid (`DW0199` reports that).
5105    pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
5106        match self {
5107            QuestEffect::Cutscene {
5108                shots,
5109                path,
5110                seconds,
5111                look_at,
5112                ..
5113            } => {
5114                if !shots.is_empty() {
5115                    return Some(shots.clone());
5116                }
5117                match seconds {
5118                    Some(secs) => Some(vec![CameraShot {
5119                        path: path.clone(),
5120                        seconds: Some(*secs),
5121                        look_at: look_at.clone(),
5122                        shot_style: None,
5123                        subject: None,
5124                        subject_b: None,
5125                        dist: None,
5126                        degrees: None,
5127                        bearing: None,
5128                    }]),
5129                    None => Some(Vec::new()),
5130                }
5131            }
5132            _ => None,
5133        }
5134    }
5135
5136    /// `true` if this is a `narrate` carrying the v0.6 `art` style (reserved
5137    /// `DW0141` under a pre-0.6 campaign; glyph-checked `DW0328`).
5138    pub fn narrate_art(&self) -> bool {
5139        matches!(
5140            self,
5141            QuestEffect::Narrate {
5142                style: Some(NarrateStyle::Art),
5143                ..
5144            }
5145        )
5146    }
5147
5148    /// The `narrate` line's text if this is a `narrate` with the `art` style.
5149    pub fn narrate_art_text(&self) -> Option<&str> {
5150        match self {
5151            QuestEffect::Narrate {
5152                text,
5153                style: Some(NarrateStyle::Art),
5154                ..
5155            } => Some(text.as_str()),
5156            _ => None,
5157        }
5158    }
5159
5160    /// The `narrate` line's style and text if this is a `narrate` rendered **on
5161    /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
5162    /// styles vanilla draws centred and unwrapped, so their rendered width is
5163    /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
5164    /// is exempt.
5165    pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
5166        match self {
5167            QuestEffect::Narrate {
5168                text,
5169                style: Some(s),
5170                ..
5171            } if matches!(
5172                s,
5173                NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
5174            ) =>
5175            {
5176                Some((*s, text.as_str()))
5177            }
5178            _ => None,
5179        }
5180    }
5181
5182    /// Every vanilla sound-event id this effect references, for registry
5183    /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
5184    /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
5185    /// within the effect (e.g. `sound`).
5186    pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
5187        match self {
5188            QuestEffect::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
5189            QuestEffect::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
5190            _ => Vec::new(),
5191        }
5192    }
5193
5194    /// The deferred `play-sound` `at: actor` id, if this effect is a `play-sound`
5195    /// targeting an actor (rejected `DW0335` until the actors surface lands).
5196    pub fn play_sound_actor(&self) -> Option<&str> {
5197        match self {
5198            QuestEffect::PlaySound {
5199                at: Some(SoundAt::Actor { actor }),
5200                ..
5201            } => Some(actor.as_str()),
5202            _ => None,
5203        }
5204    }
5205
5206    /// The per-effect flag gate (DSL v0.6, task #55): flags that must ALL be set
5207    /// (per player) for this effect to fire. Empty for an ungated effect and for
5208    /// the verbs that are not per-effect gatable — terminal `campaign-complete`
5209    /// and the party/session-global `set-checkpoint` / `begin-stealth` /
5210    /// `end-stealth`. Emission wraps a gated effect's commands in a per-player
5211    /// `execute if score @s dw.f_<flag> matches 1` guard; a pre-0.6 campaign that
5212    /// gates any effect is rejected (`DW0141`).
5213    pub fn requires_flags(&self) -> &[FlagId] {
5214        match self {
5215            QuestEffect::OpenGate { requires_flags, .. }
5216            | QuestEffect::CloseGate { requires_flags, .. }
5217            | QuestEffect::GiveItem { requires_flags, .. }
5218            | QuestEffect::SetFlag { requires_flags, .. }
5219            | QuestEffect::SpawnWave { requires_flags, .. }
5220            | QuestEffect::Narrate { requires_flags, .. }
5221            | QuestEffect::SetBlock { requires_flags, .. }
5222            | QuestEffect::DespawnNpc { requires_flags, .. }
5223            | QuestEffect::MoveNpc { requires_flags, .. }
5224            | QuestEffect::Cutscene { requires_flags, .. }
5225            | QuestEffect::SetTime { requires_flags, .. }
5226            | QuestEffect::SetWeather { requires_flags, .. }
5227            | QuestEffect::PlaySound { requires_flags, .. }
5228            | QuestEffect::DamagePlayers { requires_flags, .. }
5229            | QuestEffect::Volley { requires_flags, .. }
5230            | QuestEffect::Collapse { requires_flags, .. } => requires_flags,
5231            // Terminal / party- or session-global verbs are not per-effect
5232            // gatable: `campaign-complete` is terminal; `set-checkpoint`
5233            // (`spawnpoint @a`) / `begin-stealth` / `end-stealth` are party-wide
5234            // session state; and the actor staging verbs (`spawn-actor` /
5235            // `despawn-actor` / `move-actor` / `unleash-actor` / `sequence`) and
5236            // `spawn-npc` are world-global staging — none are per-player `@s`
5237            // effects. Gate these at the objective / dialogue-option level instead.
5238            QuestEffect::CampaignComplete { .. }
5239            | QuestEffect::SpawnNpc { .. }
5240            | QuestEffect::SetCheckpoint { .. }
5241            | QuestEffect::Bonfire { .. }
5242            | QuestEffect::BeginStealth { .. }
5243            | QuestEffect::EndStealth
5244            | QuestEffect::SpawnActor { .. }
5245            | QuestEffect::DespawnActor { .. }
5246            | QuestEffect::MoveActor { .. }
5247            | QuestEffect::UnleashActor { .. }
5248            | QuestEffect::Sequence { .. } => &[],
5249        }
5250    }
5251
5252    /// The per-effect **negative** flag gate (DSL v0.6): flags whose being set
5253    /// (per player) **suppresses** this effect — the dual of
5254    /// [`QuestEffect::requires_flags`], accepted on exactly the same verbs.
5255    /// Emission wraps a gated effect's commands in a per-player
5256    /// `execute unless score @s dw.f_<flag> matches 1` guard, so an unset score
5257    /// counts as "not set" (flag scores are never pre-initialized). Empty for an
5258    /// ungated effect and for the verbs that are not per-effect gatable (see
5259    /// `requires_flags`); a pre-0.6 campaign that uses it is rejected (`DW0141`).
5260    pub fn forbids_flags(&self) -> &[FlagId] {
5261        match self {
5262            QuestEffect::OpenGate { forbids_flags, .. }
5263            | QuestEffect::CloseGate { forbids_flags, .. }
5264            | QuestEffect::GiveItem { forbids_flags, .. }
5265            | QuestEffect::SetFlag { forbids_flags, .. }
5266            | QuestEffect::SpawnWave { forbids_flags, .. }
5267            | QuestEffect::Narrate { forbids_flags, .. }
5268            | QuestEffect::SetBlock { forbids_flags, .. }
5269            | QuestEffect::DespawnNpc { forbids_flags, .. }
5270            | QuestEffect::MoveNpc { forbids_flags, .. }
5271            | QuestEffect::Cutscene { forbids_flags, .. }
5272            | QuestEffect::SetTime { forbids_flags, .. }
5273            | QuestEffect::SetWeather { forbids_flags, .. }
5274            | QuestEffect::PlaySound { forbids_flags, .. }
5275            | QuestEffect::DamagePlayers { forbids_flags, .. }
5276            | QuestEffect::Volley { forbids_flags, .. }
5277            | QuestEffect::Collapse { forbids_flags, .. } => forbids_flags,
5278            QuestEffect::CampaignComplete { .. }
5279            | QuestEffect::SpawnNpc { .. }
5280            | QuestEffect::SetCheckpoint { .. }
5281            | QuestEffect::Bonfire { .. }
5282            | QuestEffect::BeginStealth { .. }
5283            | QuestEffect::EndStealth
5284            | QuestEffect::SpawnActor { .. }
5285            | QuestEffect::DespawnActor { .. }
5286            | QuestEffect::MoveActor { .. }
5287            | QuestEffect::UnleashActor { .. }
5288            | QuestEffect::Sequence { .. } => &[],
5289        }
5290    }
5291
5292    /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
5293    /// parity with `move-actor`). `None` for a bare `move-npc` and every other
5294    /// effect — the v0.6-reserved gate (`DW0141`) keys off `Some`.
5295    pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
5296        match self {
5297            QuestEffect::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => {
5298                Some(on_arrive.as_slice())
5299            }
5300            _ => None,
5301        }
5302    }
5303
5304    /// The actor id this effect targets, if it is one of the actor staging effects
5305    /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
5306    /// no single actor (its nested effects each carry their own).
5307    pub fn actor_ref(&self) -> Option<&ActorId> {
5308        match self {
5309            QuestEffect::SpawnActor { actor, .. }
5310            | QuestEffect::DespawnActor { actor, .. }
5311            | QuestEffect::MoveActor { actor, .. }
5312            | QuestEffect::UnleashActor { actor, .. } => Some(actor),
5313            _ => None,
5314        }
5315    }
5316}
5317
5318// ---------------------------------------------------------------------------
5319// Stage 7 — world-edits (the map editor edit script, DSL v0.6, spec-0017)
5320// ---------------------------------------------------------------------------
5321
5322/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
5323///
5324/// The artifact of record for L3 world detailing: an ordered list of edit
5325/// batches the compiler replays deterministically **after** world assembly.
5326/// The world files are never truth — same DSL + same edits + same seed →
5327/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
5328/// builds byte-identically to one from before this stage existed.
5329#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5330#[serde(deny_unknown_fields)]
5331pub struct WorldEditsContent {
5332    /// Ordered edit batches, replayed in order. After every batch the post-edit
5333    /// invariants re-prove (walkability, sealing + relight, boundary safety),
5334    /// so each batch is a valid, snapshot-reviewable world state.
5335    pub batches: Vec<EditBatch>,
5336}
5337
5338/// One edit batch: an ordered group of edit verbs applied to a single area,
5339/// checked and snapshot-rendered as a unit.
5340#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5341#[serde(deny_unknown_fields)]
5342pub struct EditBatch {
5343    /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
5344    /// snapshot name and seed-stream label — renaming a batch deliberately
5345    /// reseeds its noise.
5346    pub id: EditBatchId,
5347    /// The stage-1 area this batch edits. Frames and regions resolve against
5348    /// this area's placed pieces and anchors.
5349    pub area: AreaId,
5350    /// Authoring context (why this batch exists). Machine-ignored; **excluded**
5351    /// from l10n like `theme`/`premise`.
5352    #[serde(default, skip_serializing_if = "Option::is_none")]
5353    pub note: Option<String>,
5354    /// Ordered edit verbs. `select` defines named regions; later verbs in the
5355    /// same batch refer back to them (strictly backward, like every DSL ref).
5356    pub edits: Vec<WorldEdit>,
5357}
5358
5359/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
5360/// seeded verb derives its noise stream from the campaign seed + its script
5361/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
5362#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5363#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
5364pub enum WorldEdit {
5365    /// Define a named region (`region/<kebab>`) for later verbs in this batch.
5366    Select {
5367        /// The region name being defined (unique within the batch).
5368        name: RegionId,
5369        /// The region's shape (box, surface band, palette match, or a
5370        /// composition of earlier regions).
5371        shape: RegionShape,
5372    },
5373    /// Fill every cell of a region from a seeded palette recipe (value-noise
5374    /// keyed, so picks cluster into patches — never a uniform fill).
5375    Fill {
5376        /// The region (an earlier `select` in this batch) to fill.
5377        region: RegionId,
5378        /// The palette recipe to fill with.
5379        recipe: PaletteRecipe,
5380    },
5381    /// Like `fill`, but only rewrites cells whose current block matches one of
5382    /// `matching` (base ids; blockstate suffixes are ignored when matching).
5383    Replace {
5384        /// The region (an earlier `select` in this batch) to edit.
5385        region: RegionId,
5386        /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
5387        matching: Vec<String>,
5388        /// The palette recipe to rewrite them with.
5389        recipe: PaletteRecipe,
5390    },
5391    /// Clear a region to air. Sealing-aware: the carved region re-enters the
5392    /// sealing + relight passes and every walkability invariant re-proves.
5393    Carve {
5394        /// The region (an earlier `select` in this batch) to clear.
5395        region: RegionId,
5396    },
5397    /// Reshape terrain surface within a region (raise / lower / smooth).
5398    Morph {
5399        /// The region (an earlier `select` in this batch) whose columns to
5400        /// reshape. The region defines the footprint and where each column's
5401        /// surface is read (the highest occupied cell in the region's y-range);
5402        /// `raise`/`smooth` may add cells above the region's top — reshaping
5403        /// upward is the point — while removal only touches region cells.
5404        region: RegionId,
5405        /// The surface operation.
5406        op: MorphOp,
5407    },
5408    /// Seeded dressing scatter (spec-0017 PR 2): drop weighted single-block
5409    /// dressing (flora, rocks, props) onto standable cells of a region —
5410    /// air cells with an occupied cell directly below — honoring keep-clear
5411    /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
5412    /// speckle, not the fill verbs' clustered patches), deterministic from the
5413    /// campaign seed + script position.
5414    Scatter {
5415        /// The region (an earlier `select` in this batch) to dress.
5416        region: RegionId,
5417        /// Weighted dressing blocks (blockstate suffixes allowed).
5418        items: Vec<PaletteBlock>,
5419        /// Per-candidate placement probability in `(0, 1]`.
5420        density: f64,
5421        /// Keep-clear envelopes: earlier regions whose cells (and columns —
5422        /// matched by `(x, z)`) never receive dressing.
5423        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5424        avoid: Vec<RegionId>,
5425        /// Optional minimum spacing: when set, candidates are taken in
5426        /// descending noise order and one is rejected while another accepted
5427        /// candidate is closer than this on **both** horizontal axes (the
5428        /// generators' spread rule).
5429        #[serde(default, skip_serializing_if = "Option::is_none")]
5430        spacing: Option<u32>,
5431        /// Optional cap on how many items are placed (highest-noise first).
5432        #[serde(default, skip_serializing_if = "Option::is_none")]
5433        limit: Option<u32>,
5434    },
5435    /// Structural flora (spec-0017 PR 2): plant hand-shaped trees via the
5436    /// lean-or-grow canopy rules (#121) — a canopy that would reach a
5437    /// keep-clear (`avoid`) column first leans one block away from it; if that
5438    /// still covers the corridor the tree grows tall instead, arching its
5439    /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
5440    Plant {
5441        /// The region (an earlier `select` in this batch) to plant in. Trunk
5442        /// cells are standable region cells (air over an occupied cell).
5443        region: RegionId,
5444        /// The tree species (canopy shape rules are per-species).
5445        tree: TreeKind,
5446        /// How many trees to plant (≥ 1; highest-noise candidates first).
5447        count: u32,
5448        /// Keep-clear envelopes: trunks never stand in these columns and
5449        /// canopies lean/grow to clear them.
5450        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5451        avoid: Vec<RegionId>,
5452        /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
5453        #[serde(default, skip_serializing_if = "Option::is_none")]
5454        spacing: Option<u32>,
5455    },
5456    /// Stamp a prefab fragment (spec-0017 PR 2): copy a library prefab's
5457    /// non-air cells into the world at a frame-resolved position. The fragment
5458    /// is a first-class library prefab — its provenance/license metadata is
5459    /// recorded and validated exactly like any placed prefab (ADR-0013);
5460    /// nothing outside the library can be stamped.
5461    Fragment {
5462        /// The library prefab to stamp.
5463        prefab: PrefabId,
5464        /// The frame `at` resolves in.
5465        frame: EditFrame,
5466        /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
5467        at: [i32; 3],
5468        /// Placement rotation (default `none`), the same quarter-turn set as
5469        /// `/place template`.
5470        #[serde(default, skip_serializing_if = "Option::is_none")]
5471        rotation: Option<FragmentRotation>,
5472    },
5473    /// Explicit region relight (spec-0017 PR 2, spec-0010 machinery): run the
5474    /// deterministic fixture-placement pass over ONE region and bake the
5475    /// resulting fixtures into the edit script's writes — authorial control of
5476    /// where fixtures land, instead of the whole-area pass's greedy siting.
5477    /// (The whole-area relight still re-proves after every batch either way.)
5478    Relight {
5479        /// The region (an earlier `select` in this batch) to relight: its
5480        /// reachable walkable cells are brought to `min_light`.
5481        region: RegionId,
5482        /// Fixture override; default = the area's declared `lighting.fixture`.
5483        #[serde(default, skip_serializing_if = "Option::is_none")]
5484        fixture: Option<Fixture>,
5485        /// Target light override (1..=14); default = the area's declared
5486        /// `lighting.min_light`. Required (with `fixture`) when the area
5487        /// declares no `lighting`.
5488        #[serde(default, skip_serializing_if = "Option::is_none")]
5489        min_light: Option<u8>,
5490    },
5491    /// L2 massing (spec-0017 PR 3): replace a placed piece with another
5492    /// library prefab that re-mates every currently-mated socket at its exact
5493    /// world pose (any rotation; overlap-checked). Applied at **plan** time —
5494    /// the whole downstream ladder (anchors, gate reachability, assembly,
5495    /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
5496    /// verbs live in massing-only batches, ordered before every detailing
5497    /// batch.
5498    SwapPiece {
5499        /// The piece's placement index in its area's solved layout (0-based,
5500        /// entry first).
5501        piece: u32,
5502        /// The prefab the indexed piece must currently be (drift guard).
5503        prefab: PrefabId,
5504        /// The library prefab to swap in.
5505        with: PrefabId,
5506    },
5507    /// L2 massing (spec-0017 PR 3): attach a new piece at a specific **open**
5508    /// (unmated) socket of an existing piece — the targeted form of the
5509    /// solver's frontier attach. The socket opens (its seal becomes a
5510    /// passage); the new piece's other sockets seal.
5511    InsertPiece {
5512        /// The host piece's placement index.
5513        at_piece: u32,
5514        /// The prefab the host piece must currently be (drift guard).
5515        prefab: PrefabId,
5516        /// The host's connector index (prefab metadata `connectors` order).
5517        socket: u32,
5518        /// The library prefab to attach.
5519        insert: PrefabId,
5520    },
5521    /// L2 massing (spec-0017 PR 3): remove a **leaf** piece (exactly one
5522    /// mated socket; never the entry piece). The neighbour's socket unmates
5523    /// and re-seals. Removal shifts later placement indices — order removals
5524    /// before other index-referencing massing verbs.
5525    RemovePiece {
5526        /// The piece's placement index.
5527        piece: u32,
5528        /// The prefab the indexed piece must currently be (drift guard).
5529        prefab: PrefabId,
5530    },
5531    /// L2 massing (spec-0017 PR 3): override one socket's seal — `open`
5532    /// clears the opening to a passage, `sealed` walls it up — independent of
5533    /// its mated state (sealing a mated doorway makes a wall between joined
5534    /// pieces; opening an unmated exterior socket exposes the outside, which
5535    /// the boundary-safety proof then judges).
5536    RewireSocket {
5537        /// The piece's placement index.
5538        piece: u32,
5539        /// The prefab the indexed piece must currently be (drift guard).
5540        prefab: PrefabId,
5541        /// The connector index (prefab metadata `connectors` order).
5542        socket: u32,
5543        /// The socket's new state.
5544        state: SocketState,
5545    },
5546    /// L2 massing (spec-0017 PR 3): re-pick this piece from its area pool's
5547    /// compatible members (weighted, seeded from the campaign seed + this
5548    /// verb's script position — moving the verb deliberately re-rolls). The
5549    /// current prefab is excluded, so a reseed always changes the piece or
5550    /// errors loudly.
5551    ReseedPiece {
5552        /// The piece's placement index.
5553        piece: u32,
5554        /// The prefab the indexed piece must currently be (drift guard).
5555        prefab: PrefabId,
5556    },
5557}
5558
5559/// A socket seal state for `rewire-socket` (spec-0017 PR 3).
5560#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5561#[serde(rename_all = "kebab-case")]
5562pub enum SocketState {
5563    /// The opening is cleared to a passage.
5564    Open,
5565    /// The opening is walled up.
5566    Sealed,
5567}
5568
5569/// A tree species for the `plant` verb (spec-0017 PR 2). One species per
5570/// canopy-rule implementation; the shipped rule set is the #121 oak.
5571#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5572#[serde(rename_all = "kebab-case")]
5573pub enum TreeKind {
5574    /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
5575    /// lean-or-grow corridor rules.
5576    Oak,
5577}
5578
5579/// A `fragment` stamp rotation (spec-0017 PR 2) — the `/place template`
5580/// quarter-turn set.
5581#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5582#[serde(rename_all = "kebab-case")]
5583pub enum FragmentRotation {
5584    /// No rotation.
5585    None,
5586    /// 90° clockwise.
5587    Clockwise90,
5588    /// 180°.
5589    Clockwise180,
5590    /// 90° counterclockwise.
5591    Counterclockwise90,
5592}
5593
5594/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
5595/// frame; compositions combine earlier regions of the same batch.
5596#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5597#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5598pub enum RegionShape {
5599    /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
5600    Box {
5601        /// The coordinate frame `min`/`max` resolve in.
5602        frame: EditFrame,
5603        /// Inclusive minimum corner (frame coordinates).
5604        min: [i32; 3],
5605        /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
5606        max: [i32; 3],
5607    },
5608    /// The band of cells at `from..=to` blocks relative to each column's
5609    /// terrain surface (the highest non-air cell of the column) within an
5610    /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
5611    /// surface; `from: 0, to: 0` is the surface cells themselves; negative
5612    /// offsets reach below the surface.
5613    SurfaceBand {
5614        /// The earlier region whose columns are scanned.
5615        over: RegionId,
5616        /// Inclusive band start, relative to each column's surface y.
5617        from: i32,
5618        /// Inclusive band end (≥ `from`), relative to each column's surface y.
5619        to: i32,
5620    },
5621    /// The cells of an earlier region whose current block matches one of
5622    /// `blocks` (base ids; blockstate suffixes ignored when matching).
5623    PaletteMatch {
5624        /// The earlier region to filter.
5625        within: RegionId,
5626        /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
5627        blocks: Vec<String>,
5628    },
5629    /// The union of earlier regions.
5630    Union {
5631        /// The earlier regions to unite (≥ 2).
5632        of: Vec<RegionId>,
5633    },
5634    /// The intersection of earlier regions.
5635    Intersect {
5636        /// The earlier regions to intersect (≥ 2).
5637        of: Vec<RegionId>,
5638    },
5639    /// An earlier region minus other earlier regions.
5640    Subtract {
5641        /// The earlier region to start from.
5642        base: RegionId,
5643        /// The earlier regions to remove from it (≥ 1).
5644        remove: Vec<RegionId>,
5645    },
5646}
5647
5648/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
5649/// piece-local or anchor-relative — never raw world coordinates, so an edit
5650/// script survives a layout's world placement moving.
5651#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5652#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5653pub enum EditFrame {
5654    /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
5655    /// the piece's structure origin, axes as authored (the compiler applies
5656    /// the piece's placed rotation).
5657    PieceLocal {
5658        /// The piece's placement index in the area's solved layout (0-based,
5659        /// entry piece first — the order `delvec snapshot`'s manifest lists).
5660        piece: u32,
5661        /// The prefab the indexed piece must be (a drift guard: if a re-solve
5662        /// changed the layout, the mismatch is a loud compile error, never a
5663        /// silently misplaced edit).
5664        prefab: PrefabId,
5665    },
5666    /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
5667    /// anchor cell, axes world-aligned.
5668    AnchorRelative {
5669        /// The anchor (prefab metadata, resolved by the compiler).
5670        anchor: AnchorId,
5671    },
5672}
5673
5674/// A surface operation for the `morph` verb (spec-0017).
5675#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5676#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5677pub enum MorphOp {
5678    /// Raise each column's surface by up to `by` blocks, drawing the added
5679    /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
5680    /// as natural strata, not an extruded slab).
5681    Raise {
5682        /// How many blocks to raise each column (≥ 1).
5683        by: u32,
5684        /// The palette recipe for the added cells.
5685        recipe: PaletteRecipe,
5686    },
5687    /// Lower each column's surface by up to `by` blocks (carving the topmost
5688    /// solid cells to air).
5689    Lower {
5690        /// How many blocks to lower each column (≥ 1).
5691        by: u32,
5692    },
5693    /// Relax each column's surface toward the mean of its cardinal neighbours
5694    /// (one block per pass), turning steps into slopes. Added cells draw from
5695    /// `recipe`; removed cells carve to air. Deterministic double-buffered
5696    /// passes in fixed scan order.
5697    Smooth {
5698        /// Relaxation passes (≥ 1).
5699        passes: u32,
5700        /// The palette recipe for cells a pass adds.
5701        recipe: PaletteRecipe,
5702    },
5703}
5704
5705/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
5706/// smooth value-noise sample, the island/cave generators' proven primitive —
5707/// picks cluster into strata/patches instead of per-cell speckle, and a
5708/// single-entry recipe is the degenerate (discouraged) uniform case.
5709#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5710#[serde(deny_unknown_fields)]
5711pub struct PaletteRecipe {
5712    /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
5713    pub blocks: Vec<PaletteBlock>,
5714    /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
5715    /// across). Larger = smaller patches. Must be finite and > 0.
5716    #[serde(default, skip_serializing_if = "Option::is_none")]
5717    pub scale: Option<f64>,
5718}
5719
5720/// One weighted entry of a [`PaletteRecipe`].
5721#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5722#[serde(deny_unknown_fields)]
5723pub struct PaletteBlock {
5724    /// Vanilla block id (validated against the pinned 1.21.11 registry), with
5725    /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
5726    pub block: String,
5727    /// Relative weight (finite, > 0).
5728    pub weight: f64,
5729}
5730
5731/// Where in the campaign one effect sits — the attribution every per-branch
5732/// proof and the branch chronicle need (spec-0025).
5733#[derive(Clone, Debug, PartialEq, Eq)]
5734pub enum EffectSite {
5735    /// A quest's `on_objective_complete[<objective>]` bundle.
5736    Objective {
5737        /// The owning quest.
5738        quest: String,
5739        /// The objective whose completion fires the bundle.
5740        objective: String,
5741    },
5742    /// A quest's `on_complete` bundle.
5743    QuestComplete {
5744        /// The owning quest.
5745        quest: String,
5746    },
5747    /// An environment trigger's `effects` bundle — ambient, no DAG position.
5748    Trigger {
5749        /// The trigger id.
5750        trigger: String,
5751    },
5752    /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
5753    Trap {
5754        /// The trap id.
5755        trap: String,
5756    },
5757    /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
5758    /// DAG position, and the only site that does not live in the quests stage.
5759    ///
5760    /// This variant did not exist until the effect-root sweep, and its absence was
5761    /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
5762    /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
5763    /// (`combat::actor_beats`, `validate::difficulty_checks`,
5764    /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
5765    /// even if their authors had thought of it. Widening the type is what let the
5766    /// walk widen.
5767    DialogueRespawn {
5768        /// The NPC whose dialogue tree hosts the option.
5769        npc: String,
5770        /// The node the option sits under.
5771        node: String,
5772    },
5773}
5774
5775impl EffectSite {
5776    /// The quest this site belongs to, if it has a DAG position at all.
5777    pub fn quest(&self) -> Option<&str> {
5778        match self {
5779            EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
5780                Some(quest)
5781            }
5782            EffectSite::Trigger { .. }
5783            | EffectSite::Trap { .. }
5784            | EffectSite::DialogueRespawn { .. } => None,
5785        }
5786    }
5787}
5788
5789/// Visit **every** effect the compiler can lower — at every one of the five
5790/// effect roots, top-level and transitively nested — in a fixed deterministic
5791/// order, invoking `f(json_pointer, site, effect)`.
5792///
5793/// The roots come from [`crate::effects::for_each_effect_root`], the single
5794/// enumeration; nesting is descended through the single
5795/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
5796/// enumerated here, which is the point: this walk used to hand-list four of the
5797/// five roots (it had no `EffectSite` variant for the fifth), so every proof
5798/// defined in terms of it inherited that blind spot.
5799pub fn for_each_campaign_effect<'a>(
5800    c: &'a crate::envelope::Campaign,
5801    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
5802) {
5803    crate::effects::for_each_effect_root(c, &mut |root, list| {
5804        let site = match root.owner {
5805            crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
5806                EffectSite::Objective {
5807                    quest: quest.id.as_str().to_string(),
5808                    objective: objective.to_string(),
5809                }
5810            }
5811            crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
5812                quest: quest.id.as_str().to_string(),
5813            },
5814            crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
5815                trigger: t.id.as_str().to_string(),
5816            },
5817            crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
5818                trap: t.id.as_str().to_string(),
5819            },
5820            crate::effects::EffectRootOwner::DialogueRespawn => {
5821                // The npc and node are in the root's path; parse them back rather
5822                // than widening the root walk's owner for one consumer.
5823                let seg = |n: usize| -> String {
5824                    root.path.split('/').nth(n).unwrap_or_default().to_string()
5825                };
5826                EffectSite::DialogueRespawn {
5827                    npc: seg(3),
5828                    node: seg(5),
5829                }
5830            }
5831        };
5832        for (i, eff) in list.iter().enumerate() {
5833            campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
5834        }
5835    });
5836}
5837
5838fn campaign_effect_deep<'a>(
5839    eff: &'a QuestEffect,
5840    path: &str,
5841    site: &EffectSite,
5842    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
5843) {
5844    f(path, site, eff);
5845    for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
5846        for (j, inner) in list.iter().enumerate() {
5847            campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
5848        }
5849    }
5850}