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 (objective and
4//! effect types a campaign's `dsl_version` is too low for) parse successfully
5//! and are checked by validation ([`crate::validate`]).
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::layout::StationKind;
13
14use crate::ids::{
15    ActorId, AmbushId, AnchorId, AreaId, BranchId, BranchPointId, ClassId, DialogueId, EditBatchId,
16    EndingId, FlagId, LethalVolumeId, LootId, NpcId, ObjectiveId, PoolId, PrefabId, QuestId,
17    RegionId, ShopId, ShortcutId, StakeId, StateId, TimedGateId, TrapId, TriggerId, WaveId,
18};
19
20/// serde default helper: `true` (used by DSL v0.4 `trigger.once`).
21fn default_true() -> bool {
22    true
23}
24
25/// serde `skip_serializing_if` helper: skip a `false` bool (DSL v0.4
26/// `objective.stealth`), keeping older campaigns byte-identical.
27fn is_false(b: &bool) -> bool {
28    !*b
29}
30
31// ---------------------------------------------------------------------------
32// Stage 1 — world
33// ---------------------------------------------------------------------------
34
35/// Stage 1 payload: setting, seed and the areas that make up the delve.
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct WorldContent {
39    /// Player-facing delve title.
40    pub title: String,
41    /// One-line thematic description.
42    pub theme: String,
43    /// Short narrative premise.
44    pub premise: String,
45    /// The single downstream randomness source (ADR-0006).
46    pub seed: u64,
47    /// Informational pacing target in minutes (v0: not enforced).
48    pub target_minutes: u32,
49    /// The areas the delve is made of; each binds exactly one of `prefab` /
50    /// `prefab_pool`.
51    ///
52    /// **A campaign declares its placement in exactly one document, so this
53    /// list is empty on a site-plan campaign** (`DW0839`): where a
54    /// `site-plan.json` is present the plan is the placement authority, its one
55    /// place is `area/site`, and declaring `areas[]` as well gives every
56    /// question about where something is two answers. Empty is therefore a
57    /// legitimate and common value, not a campaign that forgot to place
58    /// anything.
59    pub areas: Vec<Area>,
60    /// Additional author-declared translation languages (BCP-47-style codes, e.g.
61    /// `["zh-cn"]`). English (`en`) is implicit, always canonical, and is **never**
62    /// listed here (spec-0001 i18n addendum). Absent or empty = English-only. Every
63    /// declared language must ship a fully-covering `l10n/<code>.json` sidecar
64    /// (`DW0180`/`DW0181`). Stage docs themselves stay pure English.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub languages: Vec<String>,
67    /// Declared initial world time (DSL v0.5, spec-0010). Dimension-global; frozen
68    /// by environment sealing (`advance_time false`) so the set state persists.
69    /// Absent = `noon` (the v0 default). Affects sky attenuation in the compiler's
70    /// assembled-light model.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub time: Option<WorldTime>,
73    /// Declared initial weather (DSL v0.5, spec-0010). Dimension-global; frozen by
74    /// environment sealing (`advance_weather false`). Absent = `clear`. Rain and
75    /// thunder attenuate effective sky brightness in the assembled-light model.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub weather: Option<WorldWeather>,
78    /// Declared combat difficulty (DSL v0.6). Absent =
79    /// the compiler's historical derivation — `easy` when the campaign fields any
80    /// wave, `peaceful` when it fields none — which is what keeps every campaign
81    /// written before this field byte-identical. Declaring it overrides the
82    /// derivation for **both** the shipped `server.properties` and a `/difficulty`
83    /// in the sealing baseline, so the declaration also holds when the datapack is
84    /// dropped into somebody else's world.
85    ///
86    /// `peaceful` is rejected (`DW0468`). Raising difficulty changes the damage
87    /// players take — easy halves it — so combat arithmetic tuned under the old
88    /// implicit `easy` must be redone.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub difficulty: Option<WorldDifficulty>,
91    /// The scenic horizon: the ground and the sky the map stands in
92    /// (spec-0026). Absent or `void` is the void world. `ocean` swaps the world
93    /// generator for a deterministic superflat sea (bedrock/stone/water, sea
94    /// level y=62) and drops the area datum to y=60 so island pieces meet the
95    /// sea at their authored waterline. `valley` rings the map in a generated
96    /// mountain annulus — the one base that builds terrain rather than picking
97    /// a generator. Either a string shorthand or the object form
98    /// `{base, …params}`; see [`Horizon`].
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub horizon: Option<Horizon>,
101    /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
102    /// derives a region from the placed geometry and a per-second clock returns any
103    /// player who leaves it to the last checkpoint. Required when `horizon` is
104    /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub boundary: Option<Boundary>,
107    /// The closing line on the campaign-completion advancement — the last
108    /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
109    /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
110    /// finale quest's `goal`, which is already both campaign-derived and
111    /// inventoried; the description was previously the hardcoded English
112    /// "You left the keep." on *every* delve, whatever its theme or language.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub outro: Option<String>,
115    /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
116    /// party of one is always legal and every pre-0.6 campaign keeps that reading.
117    /// A design whose beats genuinely need `n` players — two rooms whose switches
118    /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
119    /// lobby then refuses to start below it: the class-selection dialog stays shut
120    /// and the waiting players get a party-count actionbar instead.
121    ///
122    /// Progression is party state either way (spec-0018), so this is a *declaration
123    /// of intent*, not a mechanism: it makes a mandatory-n design first-class
124    /// and turns on the analyzer's n-agent division
125    /// proof. Out of `1..=4` is `DW0370`.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub min_players: Option<u8>,
128}
129
130/// Who a granted item goes to (DSL v0.6, spec-0018).
131///
132/// Progression is a fact about the party, so the default for every `give-item` and
133/// every class-kit entry is **all**: a quest beat that arms the party arms all of
134/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
135/// the stake): exactly one copy enters the party, handed to the player whose action
136/// earned it, and the party passes it around physically.
137#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138#[serde(rename_all = "kebab-case")]
139pub enum Carrier {
140    /// Every party member receives the item (the default).
141    #[default]
142    All,
143    /// Exactly one copy, to the player whose action fired the effect.
144    One,
145}
146
147/// A declared world time state (DSL v0.5, spec-0010). The sole difference from
148/// vanilla is that the daylight cycle is frozen (`advance_time false`), so a set
149/// state persists for the whole delve until a `set-time` effect cuts to another.
150///
151/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
152/// tick count, and the tick form is the general one — so the states worth naming
153/// for a delve's pacing are not limited to the four keywords. `dusk` and `dawn`
154/// are the tick form exposed first-class, per the
155/// no-hack rule: the DSL names the beat, the compiler emits `/time set <ticks>`.
156/// Every keyword-to-tick mapping lives in exactly one table ([`WorldTime::spec`]),
157/// and the four vanilla keywords still emit their keyword verbatim, so existing
158/// campaigns are byte-identical.
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160#[serde(rename_all = "kebab-case")]
161pub enum WorldTime {
162    /// Morning daylight (`/time set day`, 1000 ticks).
163    Day,
164    /// Midday, brightest (`/time set noon`, 6000 ticks) — the default.
165    #[default]
166    Noon,
167    /// Sunset — the sky visibly going orange and the day ending
168    /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
169    /// has finished setting, which is what the `night` keyword already sets, so
170    /// 13000 would make `dusk` a synonym rather than its own beat.
171    Dusk,
172    /// Night, sun fully down (`/time set night`, 13000 ticks).
173    Night,
174    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
175    Midnight,
176    /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
177    /// `sunrise` is accepted as a synonym on input.
178    #[serde(alias = "sunrise")]
179    Dawn,
180}
181
182impl WorldTime {
183    /// The single keyword/tick table: `(the /time set argument, daytime ticks)`.
184    ///
185    /// A state vanilla names keeps its keyword — the argument the compiler has
186    /// always emitted — so no shipped campaign's bytes move. A state vanilla does
187    /// not name emits the equivalent tick count, which is the same primitive.
188    const fn spec(self) -> (&'static str, i64) {
189        match self {
190            WorldTime::Day => ("day", 1000),
191            WorldTime::Noon => ("noon", 6000),
192            WorldTime::Dusk => ("12000", 12000),
193            WorldTime::Night => ("night", 13000),
194            WorldTime::Midnight => ("midnight", 18000),
195            WorldTime::Dawn => ("23000", 23000),
196        }
197    }
198
199    /// The vanilla `/time set` argument — a keyword for the four states vanilla
200    /// names, a tick count otherwise.
201    pub fn token(self) -> &'static str {
202        self.spec().0
203    }
204
205    /// The `daytime` tick value this state sets (the `time query daytime`
206    /// read-back). Vanilla constants: day=1000, noon=6000, dusk=12000 (sunset
207    /// onset), night=13000, midnight=18000, dawn=23000.
208    pub fn daytime_ticks(self) -> i64 {
209        self.spec().1
210    }
211}
212
213/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
214/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
215#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
216#[serde(rename_all = "kebab-case")]
217pub enum WorldWeather {
218    /// Clear sky (`/weather clear`) — the default.
219    #[default]
220    Clear,
221    /// Rain (`/weather rain`).
222    Rain,
223    /// Thunderstorm (`/weather thunder`).
224    Thunder,
225}
226
227impl WorldWeather {
228    /// The vanilla `/weather` keyword.
229    pub fn token(self) -> &'static str {
230        match self {
231            WorldWeather::Clear => "clear",
232            WorldWeather::Rain => "rain",
233            WorldWeather::Thunder => "thunder",
234        }
235    }
236}
237
238/// The declared combat difficulty of the delve (DSL v0.6). Values are the
239/// vanilla `/difficulty` keywords.
240///
241/// Difficulty is the single largest lever on how hard a delve *feels*, so the
242/// campaign declares it rather than letting the compiler choose. Easy **halves
243/// incoming player damage** — `min(dmg / 2 + 1, dmg)` — so a campaign tuned
244/// under `easy` is tuned against a halved world. A campaign that
245/// raises this must redo that arithmetic.
246///
247/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
248/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
249/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
250/// campaign would silently vanish. It is a variant only so the compiler can say
251/// that in a diagnostic instead of a serde "unknown variant" parse error.
252#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
253#[serde(rename_all = "kebab-case")]
254pub enum WorldDifficulty {
255    /// `/difficulty easy` — the compiler's historical choice for a wave
256    /// campaign, and the default reading of an absent field. Incoming player
257    /// damage is halved (`min(dmg / 2 + 1, dmg)`).
258    #[default]
259    Easy,
260    /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
261    Normal,
262    /// `/difficulty hard` — amplified damage, and zombies reinforce.
263    Hard,
264    /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
265    /// the rejection can be a diagnostic with a rationale.
266    Peaceful,
267}
268
269impl WorldDifficulty {
270    /// The vanilla `/difficulty` keyword.
271    pub fn token(self) -> &'static str {
272        match self {
273            WorldDifficulty::Peaceful => "peaceful",
274            WorldDifficulty::Easy => "easy",
275            WorldDifficulty::Normal => "normal",
276            WorldDifficulty::Hard => "hard",
277        }
278    }
279
280    /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
281    /// `/difficulty` query command returns — the only vanilla read-back path for
282    /// the setting, and so what the generated PackTest asserts on.
283    pub fn id(self) -> i32 {
284        match self {
285            WorldDifficulty::Peaceful => 0,
286            WorldDifficulty::Easy => 1,
287            WorldDifficulty::Normal => 2,
288            WorldDifficulty::Hard => 3,
289        }
290    }
291}
292
293/// A scenic horizon (spec-0026). A horizon is a **composition of orthogonal
294/// axes**, not an enum of monoliths: a **base** — what surrounds the map — and
295/// that base's params. The field accepts a plain string shorthand
296/// ([`HorizonName`]) or the object form [`HorizonSpec`] `{base, …params}`.
297///
298/// Consumers never match this wire enum. [`Horizon::resolved`] desugars both
299/// forms into one [`ResolvedHorizon`] with the pinned defaults applied, and
300/// [`horizon_base`] answers the one question most callers have.
301#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
302#[serde(untagged)]
303pub enum Horizon {
304    /// A bare base name — `"ocean"` is exactly `{base: "ocean"}`. The two
305    /// bases that predate the horizon library were spelled this way and still
306    /// are, byte-identically; a base added since is spellable this way too,
307    /// because a base with every param at its default has nothing else to say.
308    Name(HorizonBase),
309    /// The object form `{base, …params}`.
310    Spec(HorizonSpec),
311}
312
313/// What surrounds the map.
314///
315/// **One enumeration of bases, reachable two ways.** The shorthand
316/// `horizon: "ocean"` and the object form `horizon: {base: "ocean"}` name this
317/// same variant, which is what stops a base from existing in one spelling and
318/// not the other. A separate list of "names" beside this one would be two
319/// enumerations of the same thing, and the second base added would land in
320/// whichever of them its author was looking at.
321///
322/// What is deliberately NOT here is a name that stands for a base plus a set of
323/// params. Such a name reads as a thing, and the whole claim of this design is
324/// that it is not one — it is a base with params set. A spelling that hides
325/// which params it sets makes that claim unverifiable by looking at the
326/// document, and buys a few saved keystrokes for it. Each base carries its own params on
327/// [`HorizonSpec`], and a param foreign to the declared base is refused rather
328/// than ignored.
329#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330#[serde(rename_all = "kebab-case")]
331pub enum HorizonBase {
332    /// Void superflat; no surround. The default.
333    #[default]
334    Void,
335    /// Pinned water superflat, sea level 62; no surround.
336    Ocean,
337    /// A mountain annulus around a flat gap floor, with void ambient below the
338    /// tile skirt. The one base that generates terrain.
339    Valley,
340}
341
342impl HorizonBase {
343    /// The kebab wire name.
344    pub fn token(self) -> &'static str {
345        match self {
346            HorizonBase::Void => "void",
347            HorizonBase::Ocean => "ocean",
348            HorizonBase::Valley => "valley",
349        }
350    }
351
352    /// Whether this base generates a surround — compiler-built terrain outside
353    /// the map's own extent. `void` and `ocean` are pure ambient and build
354    /// nothing.
355    pub fn has_surround(self) -> bool {
356        matches!(self, HorizonBase::Valley)
357    }
358}
359
360/// The `horizon` object form: a `base` plus that base's params, all optional
361/// with pinned defaults ([`horizon_defaults`]).
362///
363/// The `valley` surround generator carries a second flora and a second surface
364/// palette (a cherry grove over `minecraft:cherry_grove`) and **this struct
365/// deliberately does not expose them yet.** Every engine surface owes a gallery
366/// element in the change that lands it, and the element a second flora needs is
367/// a second whole-map campaign — the surround only rings a map that DECLARES
368/// its extent (`DW0855`), so there is no two-file overlay that can write it.
369/// A surface whose element cannot land with it does not land. The shape is flat rather than
370/// per-base tagged, and a param foreign to the declared base is refused
371/// (`DW0853`) — so an `ocean` cannot quietly carry a `rim_height` that nothing
372/// reads.
373#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
374#[serde(deny_unknown_fields)]
375pub struct HorizonSpec {
376    /// The base — what surrounds the map.
377    pub base: HorizonBase,
378    /// `valley`: the surround's total footprint as a multiple of the map's, on
379    /// each axis (`2.0..=3.0`, default 2.5).
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub ratio: Option<f64>,
382    /// `valley`: crest height of the rim over the gap floor (`16..=128`,
383    /// default 48).
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub rim_height: Option<i32>,
386}
387
388/// Pinned horizon param defaults. One table, so the doc comments, the resolver
389/// and the diagnostics cannot drift.
390pub mod horizon_defaults {
391    /// `valley.ratio`.
392    pub const RATIO: f64 = 2.5;
393    /// `valley.ratio` lower bound — below 2.0 the annulus has no room for a
394    /// gap floor and a slope run both.
395    pub const RATIO_MIN: f64 = 2.0;
396    /// `valley.ratio` upper bound — above 3.0 the surround is mostly terrain a
397    /// body never reaches, at a cost that is all shipped bytes.
398    pub const RATIO_MAX: f64 = 3.0;
399    /// `valley.rim_height`.
400    pub const RIM_HEIGHT: i32 = 48;
401    /// `valley.rim_height` lower bound — a rim under 16 does not close the
402    /// horizon from a body standing on the gap floor.
403    pub const RIM_HEIGHT_MIN: i32 = 16;
404    /// `valley.rim_height` upper bound — the build range is 384 blocks tall and
405    /// the surround has to fit under whatever the map puts above it.
406    pub const RIM_HEIGHT_MAX: i32 = 128;
407}
408
409/// A horizon with both wire forms desugared and every default applied — the
410/// only view downstream code reads.
411#[derive(Clone, Copy, Debug, PartialEq)]
412pub struct ResolvedHorizon {
413    /// The base.
414    pub base: HorizonBase,
415    /// `valley.ratio`.
416    pub ratio: f64,
417    /// `valley.rim_height`.
418    pub rim_height: i32,
419}
420
421impl Default for ResolvedHorizon {
422    fn default() -> Self {
423        ResolvedHorizon {
424            base: HorizonBase::Void,
425            ratio: horizon_defaults::RATIO,
426            rim_height: horizon_defaults::RIM_HEIGHT,
427        }
428    }
429}
430
431impl ResolvedHorizon {
432    /// The resolved horizon of `base` with every param at its pinned default.
433    pub fn of_base(base: HorizonBase) -> Self {
434        ResolvedHorizon {
435            base,
436            ..Default::default()
437        }
438    }
439}
440
441impl Horizon {
442    /// Desugar either wire form to the one resolved view, defaults applied.
443    pub fn resolved(&self) -> ResolvedHorizon {
444        match self {
445            Horizon::Name(base) => ResolvedHorizon::of_base(*base),
446            Horizon::Spec(s) => ResolvedHorizon {
447                base: s.base,
448                ratio: s.ratio.unwrap_or(horizon_defaults::RATIO),
449                rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT),
450            },
451        }
452    }
453
454    /// The resolved base.
455    pub fn base(&self) -> HorizonBase {
456        self.resolved().base
457    }
458
459    /// True when this declaration needs the horizon-library surface: the object
460    /// form, or a bare name for a base that did not exist before it.
461    ///
462    /// The two bases that predate the library stay writable as bare names at
463    /// the version that introduced them, and their emission does not move —
464    /// which is what makes this a widening rather than a break. What is fenced
465    /// is saying something the old surface had no spelling for.
466    pub fn needs_horizon_library(&self) -> bool {
467        match self {
468            Horizon::Spec(_) => true,
469            Horizon::Name(base) => match base {
470                HorizonBase::Void | HorizonBase::Ocean => false,
471                HorizonBase::Valley => true,
472            },
473        }
474    }
475}
476
477/// The resolved base of an optional stage-1 `horizon` field — `Void` when
478/// absent. The one helper every downstream consumer (placement, the ambient
479/// model, emission) goes through, so that a new base cannot be forgotten at one
480/// of them.
481pub fn horizon_base(horizon: &Option<Horizon>) -> HorizonBase {
482    horizon.as_ref().map(|h| h.base()).unwrap_or_default()
483}
484
485/// The resolved view of an optional stage-1 `horizon` field, with defaults
486/// applied for an absent one.
487pub fn resolved_horizon(horizon: &Option<Horizon>) -> ResolvedHorizon {
488    horizon.as_ref().map(|h| h.resolved()).unwrap_or_default()
489}
490
491/// The default boundary `margin` (blocks of horizontal breathing room added
492/// around the derived region). Separate function so `serde(default = …)` and the
493/// documented literal cannot drift.
494fn default_margin() -> u16 {
495    16
496}
497
498/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
499/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
500/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
501/// placed block − 8) — never authored — so "every anchor is inside" is structural.
502/// Enforcement is a per-second clock that returns any player outside the region to
503/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
504/// damage, no items lost.
505#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
506#[serde(deny_unknown_fields)]
507pub struct Boundary {
508    /// Horizontal breathing room in blocks added around the derived region on
509    /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
510    #[serde(default = "default_margin")]
511    pub margin: u16,
512    /// Actionbar message shown on return. Absent = the compiler's English default.
513    /// When set, it is inventoried under l10n key `world.boundary.message` and is
514    /// translated like every other player-facing string.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub message: Option<String>,
517}
518
519/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
520/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
521/// compiler owns the placement rule and block-light emission.
522#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
523#[serde(rename_all = "kebab-case")]
524pub enum Fixture {
525    /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
526    Torch,
527    /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
528    Lantern,
529    /// Floor campfire (block light 15); never on or adjacent to a required path
530    /// cell (it is a damage source).
531    Campfire,
532    /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
533    Shroomlight,
534}
535
536impl Fixture {
537    /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
538    pub fn token(self) -> &'static str {
539        match self {
540            Fixture::Torch => "torch",
541            Fixture::Lantern => "lantern",
542            Fixture::Campfire => "campfire",
543            Fixture::Shroomlight => "shroomlight",
544        }
545    }
546}
547
548/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
549/// presence puts the area on the relight path: the compiler guarantees every
550/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
551/// with `DW0211` if it cannot.
552#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
553#[serde(deny_unknown_fields)]
554pub struct AreaLighting {
555    /// The fixture the relight pass places.
556    pub fixture: Fixture,
557    /// The minimum block+sky light guaranteed on reachable walkable cells
558    /// (1..=14, default 7). Range-checked (`DW0196`).
559    #[serde(default = "default_min_light")]
560    pub min_light: u8,
561}
562
563/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
564fn default_min_light() -> u8 {
565    7
566}
567
568/// A per-area **darkness mitigation** declaration (DSL v0.6).
569///
570/// The first-class answer to "this area is meant to be dark, and the players are
571/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
572/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
573/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
574/// gap between the check and the feature.
575///
576/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
577/// for `night vision`: that accepted a renamed water bottle, so the gate passed
578/// while nothing in the world granted night vision (owner, island QA).
579#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
580#[serde(rename_all = "kebab-case")]
581pub enum AreaMitigation {
582    /// Every player inside the area's placed bounds is kept under
583    /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
584    NightVision,
585}
586
587/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
588///
589/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
590/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
591/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
592/// full jigsaw layout semantics are spec-0002's.
593#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
594#[serde(deny_unknown_fields)]
595pub struct Area {
596    /// Unique area id.
597    pub id: AreaId,
598    /// Player-facing area name.
599    pub name: String,
600    /// The single prefab bound to this area (mutually exclusive with
601    /// `prefab_pool`).
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub prefab: Option<PrefabId>,
604    /// The jigsaw prefab pool bound to this area (mutually exclusive with
605    /// `prefab`); requires `pieces`.
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub prefab_pool: Option<PoolId>,
608    /// Jigsaw piece-count bounds (only with `prefab_pool`).
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub pieces: Option<Pieces>,
611    /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
612    /// present, the compiler's relight pass guarantees `min_light` on every
613    /// reachable walkable cell of this area by placing the declared fixture, or
614    /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
615    /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub lighting: Option<AreaLighting>,
618    /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
619    /// the compiler emit a clocked `effect give` over this area's placed bounds and
620    /// is the (only) declaration that satisfies `DW0210` without `lighting`.
621    /// Independent of `lighting`: an area may declare both (fixtures *and* the
622    /// effect), either, or neither.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub mitigation: Option<AreaMitigation>,
625}
626
627/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
628#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
629#[serde(deny_unknown_fields)]
630pub struct Pieces {
631    /// Minimum number of pieces to assemble.
632    pub min: u32,
633    /// Maximum number of pieces to assemble.
634    pub max: u32,
635}
636
637// ---------------------------------------------------------------------------
638// Body traversal — the declaration every body that moves carries (DSL v0.11,
639// spec-0034)
640// ---------------------------------------------------------------------------
641
642/// How a body gets around.
643///
644/// The compiler DERIVES this from the entity id for every body (spiders climb,
645/// ghasts fly, `#minecraft:aquatic` swims, and everything else — including every
646/// id the table has never heard of — is [`Locomotion::Ground`], the checked
647/// class). [`BodyTraversal`] is the author's side of the same vocabulary: one
648/// enum, so a declaration and a derivation can never mean different things.
649///
650/// The vocabulary lives in this crate rather than in the compiler because it is
651/// now DSL surface; the compiler re-exports it and owns the derivation table
652/// (`compiler::traversal`).
653#[derive(
654    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
655)]
656#[serde(rename_all = "kebab-case")]
657pub enum Locomotion {
658    /// Walks, steps and jumps — the default and the CHECKED class.
659    Ground,
660    /// Climbs sheer vertical surfaces (vanilla's `Spider` class).
661    Climber,
662    /// Leaves the ground under its own power.
663    Flier,
664    /// A member of vanilla's `#minecraft:aquatic` tag. A ledger classification
665    /// that exempts nothing, which is why it may not be **declared**
666    /// (`DW0455`) — see [`BodyTraversal`].
667    Aquatic,
668}
669
670impl Locomotion {
671    /// The stable kebab token this class is written and reported under.
672    pub fn token(self) -> &'static str {
673        match self {
674            Locomotion::Ground => "ground",
675            Locomotion::Climber => "climber",
676            Locomotion::Flier => "flier",
677            Locomotion::Aquatic => "aquatic",
678        }
679    }
680
681    /// Every class, in ledger order — so a report can never silently drop a row
682    /// when a class is added.
683    pub const ALL: [Locomotion; 4] = [
684        Locomotion::Ground,
685        Locomotion::Climber,
686        Locomotion::Flier,
687        Locomotion::Aquatic,
688    ];
689}
690
691/// What a body can do when it moves, **declared by the author** (DSL v0.11,
692/// spec-0034).
693///
694/// Carried by every object class in the DSL that has a body and a position and
695/// is walked by a compiler-emitted route — the stage-2 [`Npc`] and the stage-5
696/// [`Actor`]. It is deliberately one shared type on both rather than a field
697/// per consumer: traversal is a property of a body that moves, not of the verb
698/// that first needed it (CLAUDE.md), and a second bespoke field would be the
699/// defect rather than the fix.
700///
701/// **A declaration is a claim the build holds you to, never an opt-out.** The
702/// compiler compares the verdicts this body earns under the declared class
703/// against the ones it earns under its species' derived class; a declaration
704/// that changes no verdict is inert and is `DW0454`. So declaring `climber` on
705/// a sheep is only accepted where that sheep's route really does go over a
706/// barrier line — the exception is authored and proven, instead of happening by
707/// accident and merely rendering.
708///
709/// **What is deliberately NOT here: `opens_gates`.** Passing a closed fence gate
710/// is a right-click, a scripted walk is a compiler-emitted `tp` polyline whose
711/// puppet performs no interaction at all, and no runtime verb changes a fence
712/// gate's block state. Declaring it would not make it true, so the error tier
713/// (`DW0452`) has no authorable exemption and a declaration can never reach it.
714#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
715#[serde(deny_unknown_fields)]
716pub struct BodyTraversal {
717    /// How this body gets around, overriding what its entity id implies.
718    pub locomotion: Locomotion,
719}
720
721/// One object class that has a **body the compiler stages**: a declared
722/// position, a declared species, and a compiler-emitted route.
723///
724/// A sum type rather than a flattened tuple, so **adding a body class is a
725/// compile error at every consumer** until each one says what it does with it.
726/// The alternative — each rule walking the classes it happens to remember — is
727/// the defect class CLAUDE.md names: a hand-rolled walk that
728/// enumerated three of five effect roots.
729///
730/// **Keyed to the object, not to the verb that first needed it.** This type was
731/// introduced for [`BodyTraversal`] and read, for a while, as "one consumer of
732/// `BodyTraversal`" — which is why its only enumeration
733/// ([`body_traversal_sites`]) was filtered to bodies declaring a traversal, and
734/// why the second property a body carries, its [`NpcSkin`], had no enumeration
735/// at all: the bake walked the stage-2 npc list by hand, and an actor's skin was
736/// emitted into the summon, never baked into the pack, and never refused.
737/// A body's properties belong to the body. [`body_sites`] is the unfiltered
738/// walk; a per-property enumeration is a *filter* over it, never a second walk.
739///
740/// Deliberately NOT a member: [`WaveMob`]. A wave mob has a body and a position,
741/// but it is driven by **native vanilla AI**, never by a compiler-emitted route,
742/// so the compiler makes no claim about the moves it makes and a locomotion
743/// declaration on it could change no verdict. It declares no `skin` either — the
744/// exclusion holds for both properties, and the schema says so
745/// (`crates/dsl/tests/body_skin_sites.rs`). It becomes a member the day the lane
746/// proof reasons about how its bodies move — and it joins here, through this
747/// same type, rather than through a field of its own.
748#[derive(Clone, Copy, Debug)]
749pub enum BodyRef<'a> {
750    /// A stage-2 NPC, walked by `move-npc`.
751    Npc(&'a Npc),
752    /// A stage-5 scripted actor, walked by `move-actor`.
753    Actor(&'a Actor),
754}
755
756impl<'a> BodyRef<'a> {
757    /// The declaring stage's wire name (`npcs` / `quests`) — also the stage
758    /// whose `dsl_version` fences this body's declaration.
759    pub fn stage(self) -> &'static str {
760        match self {
761            BodyRef::Npc(_) => "npcs",
762            BodyRef::Actor(_) => "quests",
763        }
764    }
765
766    /// The body's declared id.
767    pub fn id(self) -> &'a str {
768        match self {
769            BodyRef::Npc(n) => n.id.as_str(),
770            BodyRef::Actor(a) => a.id.as_str(),
771        }
772    }
773
774    /// The entity id written on the body. **Not necessarily the body that
775    /// ships**: a `skin` re-dresses it as a `minecraft:mannequin`, which is the
776    /// compiler's rule (`nav::npc_body_entity`) and stays there.
777    pub fn declared_entity(self) -> &'a str {
778        match self {
779            BodyRef::Npc(n) => n.base_entity.as_str(),
780            BodyRef::Actor(a) => a.entity.as_str(),
781        }
782    }
783
784    /// This body's traversal declaration, if it carries one.
785    pub fn traversal(self) -> Option<&'a BodyTraversal> {
786        match self {
787            BodyRef::Npc(n) => n.traversal.as_ref(),
788            BodyRef::Actor(a) => a.traversal.as_ref(),
789        }
790    }
791
792    /// This body's skin declaration, if it carries one.
793    ///
794    /// A skinned body of **either** class ships as a `minecraft:mannequin`
795    /// whose `profile.texture` resolves to `delvewright:npc/<texture_id>`, so
796    /// either one owes the same `skins/<texture_id>.png` under the same refusal
797    /// (`DW0309`). Answering it here is what stops the bake from being a
798    /// property of one class.
799    pub fn skin(self) -> Option<&'a NpcSkin> {
800        match self {
801            BodyRef::Npc(n) => n.skin.as_ref(),
802            BodyRef::Actor(a) => a.skin.as_ref(),
803        }
804    }
805
806    /// This class's name in the JSON Schema export (`delvec schema --stage all`).
807    ///
808    /// The join between the closed Rust set and the schema, which is the only
809    /// authority on *which object classes declare what*. `body_skin_sites.rs`
810    /// compares the two.
811    pub fn class(self) -> &'static str {
812        match self {
813            BodyRef::Npc(_) => "Npc",
814            BodyRef::Actor(_) => "Actor",
815        }
816    }
817
818    /// Every body class, by schema name. The closed set, stated once.
819    pub const ALL_CLASSES: [&'static str; 2] = ["Npc", "Actor"];
820}
821
822/// A staged body declaration, with the JSON pointer at the declaration itself.
823///
824/// The pointer is at the OBJECT (`/content/npcs/3`), not at any one of its
825/// fields: a per-property site appends its own field name. A pointer built per
826/// property is how two walks of one population start disagreeing about where a
827/// thing was declared.
828#[derive(Clone, Debug)]
829pub struct BodySite<'a> {
830    /// Which object class declared it, and the object itself.
831    pub body: BodyRef<'a>,
832    /// JSON pointer at the declaration, for a diagnostic path.
833    pub path: String,
834}
835
836/// **Every** body the campaign declares, in stage order: stage-2 npcs in
837/// declaration order, then stage-5 actors in declaration order.
838///
839/// The one walk of the campaign's bodies. A rule about a property a body carries
840/// is a *filter* over this ([`body_traversal_sites`], [`body_skin_sites`]) — never
841/// a second traversal, and never a hand-written loop over one stage's list,
842/// which is exactly how an actor's skin came to be emitted but never baked.
843pub fn body_sites(c: &crate::envelope::Campaign) -> Vec<BodySite<'_>> {
844    let mut out: Vec<BodySite<'_>> = Vec::new();
845    for (i, n) in c.npcs.content.npcs.iter().enumerate() {
846        out.push(BodySite {
847            body: BodyRef::Npc(n),
848            path: format!("/content/npcs/{i}"),
849        });
850    }
851    for (i, a) in c.quests.content.actors.iter().enumerate() {
852        out.push(BodySite {
853            body: BodyRef::Actor(a),
854            path: format!("/content/actors/{i}"),
855        });
856    }
857    out
858}
859
860/// A body that carries a [`BodyTraversal`] declaration, with the JSON pointer at
861/// it.
862#[derive(Clone, Debug)]
863pub struct BodyTraversalSite<'a> {
864    /// Which object class declared it, and the object itself.
865    pub body: BodyRef<'a>,
866    /// JSON pointer at the `traversal` field, for a diagnostic path.
867    pub path: String,
868    /// The declaration.
869    pub traversal: &'a BodyTraversal,
870}
871
872/// Every body in the campaign that DECLARES a traversal, in stage order.
873///
874/// The one enumeration of the declaration's consumers, shared by the DSL's value
875/// check (`DW0455`) and by the compiler's proof (`DW0454`), so "which object
876/// classes carry this" is answered in exactly one place.
877pub fn body_traversal_sites(c: &crate::envelope::Campaign) -> Vec<BodyTraversalSite<'_>> {
878    body_sites(c)
879        .into_iter()
880        .filter_map(|s| {
881            s.body.traversal().map(|t| BodyTraversalSite {
882                body: s.body,
883                path: format!("{}/traversal", s.path),
884                traversal: t,
885            })
886        })
887        .collect()
888}
889
890/// A body that carries an [`NpcSkin`] declaration, with the JSON pointer at it.
891#[derive(Clone, Debug)]
892pub struct BodySkinSite<'a> {
893    /// Which object class declared it, and the object itself.
894    pub body: BodyRef<'a>,
895    /// JSON pointer at the `skin` field, for a diagnostic path.
896    pub path: String,
897    /// The declaration.
898    pub skin: &'a NpcSkin,
899}
900
901/// Every body in the campaign that DECLARES a skin, in stage order.
902///
903/// The one enumeration of what the resource-pack bake must serve and what
904/// `DW0309` must refuse, for every class alike — a skinned npc and a skinned
905/// actor are the same fact about two bodies.
906///
907/// **Declarations, not textures.** Two bodies may name one `texture_id` (an npc
908/// and the puppet that plays it), and the caller decides what that means; the
909/// bake reads each file once.
910pub fn body_skin_sites(c: &crate::envelope::Campaign) -> Vec<BodySkinSite<'_>> {
911    body_sites(c)
912        .into_iter()
913        .filter_map(|s| {
914            s.body.skin().map(|k| BodySkinSite {
915                body: s.body,
916                path: format!("{}/skin", s.path),
917                skin: k,
918            })
919        })
920        .collect()
921}
922
923// ---------------------------------------------------------------------------
924// Stage 2 — npcs
925// ---------------------------------------------------------------------------
926
927/// Stage 2 payload: the campaign's NPCs (casting sheets).
928#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
929#[serde(deny_unknown_fields)]
930pub struct NpcsContent {
931    /// All NPCs in the campaign.
932    pub npcs: Vec<Npc>,
933}
934
935/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
936///
937/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
938/// contract the stage-6 `dialogue` tree must honor.
939#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
940#[serde(deny_unknown_fields)]
941pub struct Npc {
942    /// Unique NPC id.
943    pub id: NpcId,
944    /// Player-facing name.
945    pub name: String,
946    /// NPC role.
947    pub role: Role,
948    /// The area this NPC stands in (stage-1 ref).
949    pub area: AreaId,
950    /// The prefab anchor this NPC stands on.
951    pub anchor: AnchorId,
952    /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
953    pub base_entity: String,
954    /// The structured persona (character contract for stage 6).
955    pub persona: Persona,
956    /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
957    /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
958    /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
959    /// Non-skinned NPCs are byte-identical to v0.3.
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub skin: Option<NpcSkin>,
962    /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
963    /// world init — its body and interaction hitbox only appear when a
964    /// [`QuestEffect::SpawnNpc`] fires, at this same `anchor`. The dual of
965    /// `despawn-npc`: a character with a scripted entrance must not stand at its
966    /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
967    /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
968    /// init, byte-identical to pre-0.6.
969    #[serde(default, skip_serializing_if = "is_false")]
970    pub deferred: bool,
971    /// What this body can do when it moves (DSL v0.11, spec-0034). Absent = the
972    /// class the compiler derives from `base_entity` (or from `minecraft:mannequin`
973    /// when `skin` is set — the body that actually ships). See [`BodyTraversal`]:
974    /// the declaration must change a verdict or it is `DW0454`, and it can never
975    /// reach the error tier.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub traversal: Option<BodyTraversal>,
978}
979
980/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG ships in the
981/// per-delve resource pack at `assets/delvewright/textures/npc/<texture_id>.png`
982/// (sourced from the campaign dir's `skins/<texture_id>.png`); the mannequin's
983/// `profile.texture` resolves to `delvewright:npc/<texture_id>`.
984#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
985#[serde(deny_unknown_fields)]
986pub struct NpcSkin {
987    /// Skin id: the PNG basename under `skins/` and the resource-pack texture
988    /// path segment (a bare kebab token; validated by `DW0190`).
989    pub texture_id: String,
990    /// Player model. **Required** (spec-0009): an omitted model renders slim, so
991    /// a wide skin on a slim model is distorted — the compiler always emits it.
992    pub model: SkinModel,
993}
994
995/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
996/// Alex). Emitted verbatim into the mannequin `profile.model`.
997#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
998#[serde(rename_all = "kebab-case")]
999pub enum SkinModel {
1000    /// Classic 4-pixel arms (Steve).
1001    Wide,
1002    /// Slim 3-pixel arms (Alex).
1003    Slim,
1004}
1005
1006impl SkinModel {
1007    /// The vanilla `profile.model` token.
1008    pub fn token(self) -> &'static str {
1009        match self {
1010            SkinModel::Wide => "wide",
1011            SkinModel::Slim => "slim",
1012        }
1013    }
1014}
1015
1016/// A structured casting sheet. Structure lives in the
1017/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
1018/// are required; the rest are optional.
1019#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1020#[serde(deny_unknown_fields)]
1021pub struct Persona {
1022    /// One-line character archetype (required).
1023    pub archetype: String,
1024    /// How the NPC speaks — register, tics, formality (required).
1025    pub speech_style: String,
1026    /// Emotional bearing toward the player (optional).
1027    #[serde(default, skip_serializing_if = "Option::is_none")]
1028    pub demeanor: Option<String>,
1029    /// What the NPC wants (required).
1030    pub motivation: String,
1031    /// Something the NPC hides (optional).
1032    #[serde(default, skip_serializing_if = "Option::is_none")]
1033    pub secret: Option<String>,
1034    /// Backstory colour (optional).
1035    #[serde(default, skip_serializing_if = "Option::is_none")]
1036    pub backstory: Option<String>,
1037    /// Attitudes toward other same-stage NPCs (optional; refs validated).
1038    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1039    pub relationships: Vec<Relationship>,
1040}
1041
1042/// One persona relationship: an attitude toward another same-stage NPC.
1043#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1044#[serde(deny_unknown_fields)]
1045pub struct Relationship {
1046    /// The other NPC (stage-2 ref, validated within stage 2).
1047    pub npc: NpcId,
1048    /// Free-text attitude toward that NPC.
1049    pub attitude: String,
1050}
1051
1052/// What a speaking part does. A schema enum offers what the engine accepts,
1053/// so there are two of them: how hard a fight is billed is [`EncounterTier`] on
1054/// the body that fights (a `waves[]` entry or a stage-5 actor), not a role here.
1055#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1056#[serde(rename_all = "kebab-case")]
1057pub enum Role {
1058    /// Gives and advances quests.
1059    QuestGiver,
1060    /// Flavor only.
1061    Flavor,
1062}
1063
1064// ---------------------------------------------------------------------------
1065// Stage 6 — dialogue
1066// ---------------------------------------------------------------------------
1067
1068/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
1069#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1070#[serde(deny_unknown_fields)]
1071pub struct DialogueContent {
1072    /// One tree per NPC (1:1 with stage 2, both directions).
1073    pub dialogues: Vec<NpcDialogue>,
1074}
1075
1076impl DialogueContent {
1077    /// The dialogue tree for an NPC id, if present.
1078    pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
1079        self.dialogues.iter().find(|t| t.npc.as_str() == npc)
1080    }
1081}
1082
1083/// One NPC's dialogue tree: a root node plus a set of nodes.
1084#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1085#[serde(deny_unknown_fields)]
1086pub struct NpcDialogue {
1087    /// The NPC this tree belongs to (stage-2 ref).
1088    pub npc: NpcId,
1089    /// The entry node id; every node must be reachable from it.
1090    pub root: DialogueId,
1091    /// The dialogue nodes.
1092    pub nodes: Vec<DialogueNode>,
1093}
1094
1095impl NpcDialogue {
1096    /// The ids of the nodes reachable from `roots` by following option `next`
1097    /// edges, ignoring every option gate.
1098    ///
1099    /// **The one authority for "what can this tree show, entered here".** A
1100    /// dialogue tree has more than one entry point — the stage-6 `root`, and
1101    /// every node a quest's `cast` ledger names as a scene — so "reachable" is
1102    /// always relative to a root SET, and each consumer supplies the set its own
1103    /// question is about. `DW0120`/`DW0123` ask about every entry point at once;
1104    /// the cast ledger's `DW0858` asks about the scenes live during one
1105    /// objective. Gates are ignored on purpose: an option's flag gate is
1106    /// `DW0191`'s question, not this one's.
1107    ///
1108    /// Root ids that name no node of this tree contribute nothing (a dangling
1109    /// scene root is `DW0464`, and a dangling stage-6 `root` is `DW0121`).
1110    pub fn reachable_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1111        let by_id: BTreeMap<&'a str, &'a DialogueNode> =
1112            self.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
1113        let mut seen: BTreeSet<&'a str> = BTreeSet::new();
1114        let mut stack: Vec<&'a str> = roots
1115            .iter()
1116            .filter_map(|r| by_id.get_key_value(*r).map(|(k, _)| *k))
1117            .collect();
1118        while let Some(cur) = stack.pop() {
1119            if !seen.insert(cur) {
1120                continue;
1121            }
1122            let Some(node) = by_id.get(cur) else { continue };
1123            for opt in &node.options {
1124                if let Some(next) = &opt.next
1125                    && let Some((k, _)) = by_id.get_key_value(next.as_str())
1126                {
1127                    stack.push(k);
1128                }
1129            }
1130        }
1131        seen
1132    }
1133
1134    /// The objective ids some option reachable from `roots` completes — what a
1135    /// player entering this tree at those scenes can actually finish.
1136    pub fn completes_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1137        let seen = self.reachable_from(roots);
1138        let mut out: BTreeSet<&'a str> = BTreeSet::new();
1139        for node in &self.nodes {
1140            if !seen.contains(node.id.as_str()) {
1141                continue;
1142            }
1143            for opt in &node.options {
1144                for eff in &opt.effects {
1145                    if let DialogueEffect::CompleteObjective { objective } = eff {
1146                        out.insert(objective.as_str());
1147                    }
1148                }
1149            }
1150        }
1151        out
1152    }
1153}
1154
1155/// One dialogue node: text plus branching options.
1156#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1157#[serde(deny_unknown_fields)]
1158pub struct DialogueNode {
1159    /// Node id (unique within this NPC's dialogue).
1160    pub id: DialogueId,
1161    /// The line the NPC speaks.
1162    pub text: String,
1163    /// Branching options; empty closes the dialog.
1164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1165    pub options: Vec<DialogueOption>,
1166}
1167
1168/// One selectable dialogue option.
1169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1170#[serde(deny_unknown_fields)]
1171pub struct DialogueOption {
1172    /// Button label.
1173    pub label: String,
1174    /// The full line this button is the caption of (DSL v0.8). Vanilla's dialog button codec
1175    /// (`CommonButtonData`) carries an optional `tooltip` component beside
1176    /// `label`, and the client hangs it on the button as a real hover tooltip —
1177    /// so a caption on the button and the sentence the character actually says
1178    /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
1179    /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
1180    /// scrolls. Player-visible, so it translates like the label
1181    /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    pub tooltip: Option<String>,
1184    /// Next node; omitted closes the dialog.
1185    #[serde(default, skip_serializing_if = "Option::is_none")]
1186    pub next: Option<DialogueId>,
1187    /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
1188    /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
1189    /// gated option is hidden until every referenced flag has been set by a
1190    /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
1191    /// option cannot make a critical-path node unreachable (`DW0191`).
1192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1193    pub requires_flags: Vec<FlagId>,
1194    /// Negative flag gate (DSL v0.6): the option is
1195    /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
1196    /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
1197    /// option counts as *gated* for the `DW0191` deadlock guard: it can be
1198    /// suppressed at any point, so it cannot be the only completing path.
1199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1200    pub forbids_flags: Vec<FlagId>,
1201    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
1202    /// hold for this gate to be open. The third field of the one gate, carried by
1203    /// every gate consumer — never by the verb that first wanted it. Default
1204    /// empty, so a pre-0.10 campaign is byte-identical.
1205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1206    pub requires_state: Vec<StateCompare>,
1207    /// Effects fired when this option is chosen.
1208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1209    pub effects: Vec<DialogueEffect>,
1210    /// What choosing this option does to the story (spec-0025). Required for a
1211    /// **story-weight** beat
1212    /// — an option carrying a `set-flag` effect, which is how a player's choice
1213    /// forks the world (`DW0481`). An option that only walks the tree or
1214    /// completes an objective needs none: the objective already declares one.
1215    #[serde(default, skip_serializing_if = "Option::is_none")]
1216    pub happening: Option<Happening>,
1217}
1218
1219/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
1220/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
1221/// dialogue choice, enabling flag-gated options/objectives/triggers).
1222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1223#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
1224pub enum DialogueEffect {
1225    /// Marks a stage-5 objective complete.
1226    CompleteObjective {
1227        /// The objective to complete (resolved at the stage-5 boundary).
1228        objective: ObjectiveId,
1229    },
1230    /// Sets a campaign flag (DSL v0.4), mirroring [`QuestEffect::SetFlag`].
1231    SetFlag {
1232        /// The flag to set.
1233        flag: FlagId,
1234    },
1235    /// Cuts the world time (DSL v0.5), mirroring [`QuestEffect::SetTime`].
1236    SetTime {
1237        /// The time state to cut to.
1238        time: WorldTime,
1239    },
1240    /// Cuts the weather (DSL v0.5), mirroring [`QuestEffect::SetWeather`].
1241    SetWeather {
1242        /// The weather state to cut to.
1243        weather: WorldWeather,
1244    },
1245    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
1246    /// [`QuestEffect::SetCheckpoint`] — usable from a dialogue outcome.
1247    SetCheckpoint {
1248        /// The prefab checkpoint anchor the party respawns at.
1249        anchor: AnchorId,
1250        /// Per-player effects re-run on respawn while this checkpoint is active
1251        /// (scene reset). Empty = no hook.
1252        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1253        on_respawn: Vec<QuestEffect>,
1254    },
1255    /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
1256    /// [`QuestEffect::SpawnNpc`] — a character who walks in mid-conversation.
1257    SpawnNpc {
1258        /// The NPC (stage-2 ref) to summon.
1259        npc: NpcId,
1260    },
1261}
1262
1263impl DialogueEffect {
1264    /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
1265    /// (`set-time`/`set-weather`, spec-0010).
1266    pub fn v05_effect(&self) -> Option<&'static str> {
1267        match self {
1268            DialogueEffect::SetTime { .. } => Some("set-time"),
1269            DialogueEffect::SetWeather { .. } => Some("set-weather"),
1270            _ => None,
1271        }
1272    }
1273
1274    /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
1275    /// (`set-checkpoint`, spec-0012; `spawn-npc`).
1276    pub fn v06_effect(&self) -> Option<&'static str> {
1277        match self {
1278            DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
1279            DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
1280            _ => None,
1281        }
1282    }
1283
1284    /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
1285    pub fn spawn_npc(&self) -> Option<&NpcId> {
1286        match self {
1287            DialogueEffect::SpawnNpc { npc } => Some(npc),
1288            _ => None,
1289        }
1290    }
1291
1292    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
1293    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
1294        match self {
1295            DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
1296                Some((anchor, on_respawn.as_slice()))
1297            }
1298            _ => None,
1299        }
1300    }
1301
1302    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
1303    /// **effect root 5** ([`crate::effects`]). Named separately from
1304    /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
1305    /// same accessor modulo mutability, which is what lets one macro body generate
1306    /// both.
1307    pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
1308        match self {
1309            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
1310            _ => None,
1311        }
1312    }
1313
1314    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
1315    /// mutably so the localization pass can rewrite the player-visible strings
1316    /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
1317    /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
1318    /// that reaches it read-only needs a way to reach it writable too.
1319    pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
1320        match self {
1321            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
1322            _ => None,
1323        }
1324    }
1325}
1326
1327impl DialogueEffect {
1328    /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
1329    pub fn set_flag(&self) -> Option<&FlagId> {
1330        match self {
1331            DialogueEffect::SetFlag { flag } => Some(flag),
1332            _ => None,
1333        }
1334    }
1335
1336    /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
1337    /// (`set-flag`).
1338    pub fn v04_effect(&self) -> Option<&'static str> {
1339        match self {
1340            DialogueEffect::SetFlag { .. } => Some("set-flag"),
1341            _ => None,
1342        }
1343    }
1344
1345    /// The target time if this is a v0.5 `set-time` dialogue effect.
1346    pub fn set_time(&self) -> Option<WorldTime> {
1347        match self {
1348            DialogueEffect::SetTime { time } => Some(*time),
1349            _ => None,
1350        }
1351    }
1352
1353    /// The target weather if this is a v0.5 `set-weather` dialogue effect.
1354    pub fn set_weather(&self) -> Option<WorldWeather> {
1355        match self {
1356            DialogueEffect::SetWeather { weather } => Some(*weather),
1357            _ => None,
1358        }
1359    }
1360}
1361
1362// ---------------------------------------------------------------------------
1363// Stage 3 — classes
1364// ---------------------------------------------------------------------------
1365
1366/// Stage 3 payload: 1..4 selectable classes.
1367#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1368#[serde(deny_unknown_fields)]
1369pub struct ClassesContent {
1370    /// The selectable classes.
1371    pub classes: Vec<Class>,
1372}
1373
1374/// A player class with a starting kit.
1375#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1376#[serde(deny_unknown_fields)]
1377pub struct Class {
1378    /// Unique class id.
1379    pub id: ClassId,
1380    /// Player-facing name.
1381    pub name: String,
1382    /// Selection-screen blurb.
1383    pub blurb: String,
1384    /// Granted items.
1385    pub kit: Vec<KitItem>,
1386}
1387
1388/// One item in a class kit.
1389///
1390/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
1391/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
1392/// document using them is rejected as an unknown field (`DW0100`).
1393#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1394#[serde(deny_unknown_fields)]
1395pub struct KitItem {
1396    /// Vanilla item id, validated against the pinned 1.21.11 registry.
1397    pub item: String,
1398    /// Stack count.
1399    pub count: u32,
1400    /// Optional display name.
1401    #[serde(default, skip_serializing_if = "Option::is_none")]
1402    pub name: Option<String>,
1403    /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
1404    /// per-player gear by construction — every player who picks the class gets the
1405    /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
1406    /// enters the party, given to the first player to pick this class.
1407    #[serde(default, skip_serializing_if = "Option::is_none")]
1408    pub carrier: Option<Carrier>,
1409    /// **The flask** (DSL v0.8, spec-0016 §1): this kit
1410    /// entry is the class's recovery item, and resting at a bonfire replenishes
1411    /// it to exactly `count`. A campaign that places a `bonfire` and declares no
1412    /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
1413    /// dying an investment, so a souls campaign without one is a build error, not
1414    /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
1415    #[serde(default, skip_serializing_if = "is_false")]
1416    pub flask: bool,
1417    /// **What is in the bottle** (DSL v0.8, spec-0016 §1): the vanilla
1418    /// `minecraft:potion_contents` component of a
1419    /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
1420    /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
1421    /// nothing — which is exactly the placeholder flask this field exists to
1422    /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
1423    /// no `contents` is `DW0487`.
1424    #[serde(default, skip_serializing_if = "Option::is_none")]
1425    pub contents: Option<PotionContents>,
1426}
1427
1428/// The items whose vanilla item definition carries a `minecraft:potion_contents`
1429/// component — the only items a kit `contents` may be declared on (`DW0486`).
1430///
1431/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
1432/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
1433/// misode/mcmeta ref `crates/compiler/data/PROVENANCE.md` pins): exactly these
1434/// four items declare the component, and on any other item the game drops the
1435/// data on the floor.
1436pub const POTION_BEARING_ITEMS: &[&str] = &[
1437    "minecraft:lingering_potion",
1438    "minecraft:potion",
1439    "minecraft:splash_potion",
1440    "minecraft:tipped_arrow",
1441];
1442
1443/// True if `item_id` (optionally un-namespaced) is one of the four
1444/// [`POTION_BEARING_ITEMS`].
1445pub fn is_potion_bearing_item(item_id: &str) -> bool {
1446    let norm = if item_id.contains(':') {
1447        item_id.to_string()
1448    } else {
1449        format!("minecraft:{item_id}")
1450    };
1451    POTION_BEARING_ITEMS.contains(&norm.as_str())
1452}
1453
1454/// The two **instantaneous** status effects: they are applied once, on the tick
1455/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
1456/// `isInstantenous` before any effect instance is ever added), so a `duration` on
1457/// one is a statement the game never reads — `DW0486` says so rather than letting
1458/// an author believe they wrote a thirty-second heal.
1459pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
1460
1461/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
1462/// modelled field for field on vanilla rather than invented: a **named** potion,
1463/// a list of **custom effects**, or both, plus the bottle-colour override.
1464///
1465/// Vanilla resolves a drink as the named potion's effects followed by the custom
1466/// ones, and derives the bottle colour from those effects unless `color`
1467/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
1468/// Potion of Healing II a player would brew, not an approximation of one.
1469#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1470#[serde(deny_unknown_fields)]
1471pub struct PotionContents {
1472    /// A named vanilla potion id (`minecraft:strong_healing`,
1473    /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
1474    /// `potion` registry (`DW0486`).
1475    #[serde(default, skip_serializing_if = "Option::is_none")]
1476    pub potion: Option<String>,
1477    /// Custom effects applied on top of (or instead of) the named potion — the
1478    /// escape hatch for a recovery item vanilla has no brew for.
1479    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1480    pub effects: Vec<PotionEffect>,
1481    /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
1482    /// colour vanilla derives from the effects themselves.
1483    #[serde(default, skip_serializing_if = "Option::is_none")]
1484    pub color: Option<String>,
1485}
1486
1487/// One entry of a potion's `custom_effects` list.
1488#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1489#[serde(deny_unknown_fields)]
1490pub struct PotionEffect {
1491    /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
1492    /// against the pinned registry (`DW0486`).
1493    pub effect: String,
1494    /// How long it lasts, in **ticks** (20 = one second). Required for every
1495    /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
1496    #[serde(default, skip_serializing_if = "Option::is_none")]
1497    pub duration: Option<u32>,
1498    /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
1499    #[serde(default, skip_serializing_if = "Option::is_none")]
1500    pub amplifier: Option<u32>,
1501}
1502
1503impl PotionEffect {
1504    /// True if this effect is applied once on drinking rather than over time.
1505    pub fn is_instant(&self) -> bool {
1506        let norm = crate::registry::namespaced_effect_id(&self.effect);
1507        INSTANT_EFFECTS.contains(&norm.as_str())
1508    }
1509}
1510
1511/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
1512/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
1513/// need is refused — while a duration typed in *milliseconds*, or one that would
1514/// overflow vanilla's int, is caught (`DW0486`).
1515pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
1516
1517/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
1518/// unsigned byte, so 255 is not a policy but the end of the field.
1519pub const MAX_POTION_AMPLIFIER: u32 = 255;
1520
1521/// The largest `seconds` a [`QuestEffect::GiveEffect`] may declare, derived from
1522/// [`MAX_POTION_DURATION_TICKS`] rather than chosen again: the two are the same
1523/// quantity in different units, and a second independently-picked ceiling is how
1524/// two limits for one fact drift apart. ≈13.9 hours, past the 10-hour delve
1525/// ceiling, so nothing a delve can legally need is refused — while a duration
1526/// typed in *ticks* or in milliseconds is caught (`DW0541`).
1527pub const MAX_EFFECT_SECONDS: u32 = MAX_POTION_DURATION_TICKS / 20;
1528
1529/// The canonical English title of the bonfire rest dialog. Baked at emit time
1530/// when the campaign authors no `prompt`, in the
1531/// `world.boundary.message` tradition: a compiler default is not inventoried, an
1532/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
1533pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
1534/// The canonical English label of the **rest and save** option.
1535pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
1536/// The canonical English label of the **save only** option.
1537pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
1538
1539/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
1540/// leaves the compiler's canonical English in place
1541/// ([`QuestEffect::bonfire_labels`]).
1542#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1543pub struct BonfireLabels<'a> {
1544    /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
1545    pub prompt: Option<&'a str>,
1546    /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
1547    pub rest_label: Option<&'a str>,
1548    /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
1549    pub save_label: Option<&'a str>,
1550}
1551
1552impl BonfireLabels<'_> {
1553    /// The dialog title actually emitted.
1554    pub fn prompt_or_default(&self) -> &str {
1555        self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
1556    }
1557    /// The **rest and save** label actually emitted.
1558    pub fn rest_or_default(&self) -> &str {
1559        self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
1560    }
1561    /// The **save only** label actually emitted.
1562    pub fn save_or_default(&self) -> &str {
1563        self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
1564    }
1565}
1566
1567// ---------------------------------------------------------------------------
1568// Stage 4 — quest-plan
1569// ---------------------------------------------------------------------------
1570
1571/// Stage 4 payload: the quest dependency plan.
1572#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1573#[serde(deny_unknown_fields)]
1574pub struct QuestPlanContent {
1575    /// Planned quests (expanded in stage 5).
1576    pub quests: Vec<PlannedQuest>,
1577    /// The quest whose completion ends the campaign.
1578    pub finale: QuestId,
1579    /// The campaign's declared **story forks** (DSL v0.8, spec-0025). Empty/absent = a campaign that claims to have no
1580    /// branch — which the compiler then *verifies* rather than assumes: any flag
1581    /// that gates casts, staging or structure and is set on some playthroughs and
1582    /// not others belongs to no declared point and is `DW0480`.
1583    ///
1584    /// Enumerated branches are the **product of the declared points**, so the
1585    /// branch set is authored and small — never a combinatorial sweep of every
1586    /// flag in the campaign.
1587    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1588    pub branch_points: Vec<BranchPoint>,
1589}
1590
1591impl QuestPlanContent {
1592    /// **The ONE authority on which quests are the spine**: the finale and every
1593    /// quest its `depends_on` chain transitively demands — the quests a body
1594    /// cannot reach the finale without.
1595    ///
1596    /// The capability belongs here, on the stage-4 document, because the spine is
1597    /// a fact about the quest plan and about nothing else. It had grown two
1598    /// derivations of the same closure in two files — one inline in
1599    /// [`crate::validate`]'s `DW0132` convergence check, one a private
1600    /// `mandatory_quests` in [`crate::layout`] read by the layout binding and by
1601    /// the critical-path spine obligation. Both were correct and neither said it
1602    /// was the authority, which is exactly the shape a later clean merge turns
1603    /// into two rules that disagree.
1604    ///
1605    /// **Why the closure is taken over the raw `depends_on` edges, unfiltered.**
1606    /// The `validate` copy first dropped every dep naming a quest the plan does
1607    /// not declare. That filtering is not this function's question: a dangling
1608    /// `depends_on` is `DW0112`'s finding, and silently pruning it here would
1609    /// make the set disagree with the document it is derived from. So an id the
1610    /// plan does not declare is reported in the spine and expands no further —
1611    /// and the one reader that could care, `DW0132`, only ever asks whether a
1612    /// **declared** quest is a member, so an undeclared member cannot change its
1613    /// verdict.
1614    ///
1615    /// Cycle-safe by construction (a quest already in the set is not expanded
1616    /// again), so a plan `DW0130` will refuse still yields a set rather than
1617    /// hanging.
1618    ///
1619    /// **Not the same question as the `mandatory` field**, and the name says so
1620    /// deliberately. Today the two sets always coincide, because `DW0132` demands
1621    /// every declared quest be a transitive dependency of the finale and `DW0866`
1622    /// demands every quest set `mandatory: true`. If `mandatory: false` ever
1623    /// becomes legal those coincide no longer, and this function keeps answering
1624    /// the graph question it has always answered.
1625    #[must_use]
1626    pub fn spine(&self) -> BTreeSet<&str> {
1627        let deps: BTreeMap<&str, &[QuestId]> = self
1628            .quests
1629            .iter()
1630            .map(|q| (q.id.as_str(), q.depends_on.as_slice()))
1631            .collect();
1632        let mut spine: BTreeSet<&str> = BTreeSet::new();
1633        let mut stack = vec![self.finale.as_str()];
1634        while let Some(q) = stack.pop() {
1635            if !spine.insert(q) {
1636                continue;
1637            }
1638            for dep in deps.get(q).copied().unwrap_or(&[]) {
1639                stack.push(dep.as_str());
1640            }
1641        }
1642        spine
1643    }
1644
1645    /// **The ONE authority on which quests are elective** (spec-0051): the
1646    /// quests declaring `mandatory: false`.
1647    ///
1648    /// The counterpart to [`Self::spine`], and deliberately a *different*
1649    /// question. `spine` asks what the graph demands; this asks what the author
1650    /// claims. `DW0866`/`DW0867` are exactly the rules that keep the two
1651    /// answers honest about each other, and they can only do that while each
1652    /// side has one derivation — which is the defect the spine function was
1653    /// created to end, and which a second private `!q.mandatory` filter in the
1654    /// compiler would re-introduce on the other half.
1655    #[must_use]
1656    pub fn optional(&self) -> BTreeSet<&str> {
1657        self.quests
1658            .iter()
1659            .filter(|q| !q.mandatory)
1660            .map(|q| q.id.as_str())
1661            .collect()
1662    }
1663}
1664
1665/// One declared story fork (DSL v0.8, spec-0025).
1666///
1667/// A branch point names the flag set the story forks on, the quest at which the
1668/// fork opens, and every branch it offers. Each branch pins the point's whole
1669/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
1670/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
1671/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
1672#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1673#[serde(deny_unknown_fields)]
1674pub struct BranchPoint {
1675    /// Unique branch-point id.
1676    pub id: BranchPointId,
1677    /// The quest at which the fork opens — every branch's divergent content is at
1678    /// or after it in the stage-4 DAG.
1679    pub opens_at: QuestId,
1680    /// The flag set the story forks on. Every branch's `flags` is a subset of
1681    /// this, and the flags it does not list are pinned **unset** on that branch.
1682    pub forks_on: Vec<FlagId>,
1683    /// The alternatives (≥ 2).
1684    pub branches: Vec<BranchDecl>,
1685}
1686
1687/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
1688#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1689#[serde(deny_unknown_fields)]
1690pub struct BranchDecl {
1691    /// Unique branch id (campaign-wide — it names the emitted chronicle file).
1692    pub id: BranchId,
1693    /// The subset of the point's `forks_on` that is SET on this branch. The rest
1694    /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
1695    /// option" branch — as long as it is genuinely reachable.
1696    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1697    pub flags: Vec<FlagId>,
1698    /// Where this branch goes: either the `quest/<kebab>` the branches converge
1699    /// at, or the `ending/<kebab>` this branch runs to.
1700    ///
1701    /// One field, not two mutually exclusive ones, because the **id prefix
1702    /// already says which it is** — the same convention every cross-stage
1703    /// reference in the DSL uses. That is what makes "exactly one of them" an
1704    /// unrepresentable state rather than a rule some diagnostic has to police: a
1705    /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
1706    /// the ordinary malformed-id `DW0110`, and one that names nothing is the
1707    /// ordinary dangling-reference `DW0112`.
1708    pub leads_to: String,
1709}
1710
1711impl BranchDecl {
1712    /// The convergence quest, if [`Self::leads_to`] names one.
1713    pub fn converges_at(&self) -> Option<QuestId> {
1714        let q = QuestId(self.leads_to.clone());
1715        q.is_valid_syntax().then_some(q)
1716    }
1717
1718    /// The ending, if [`Self::leads_to`] names one.
1719    pub fn ending(&self) -> Option<EndingId> {
1720        let e = EndingId(self.leads_to.clone());
1721        e.is_valid_syntax().then_some(e)
1722    }
1723}
1724
1725/// What a story node does to the story (DSL v0.8, spec-0025).
1726///
1727/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
1728/// design that never got written down node by node cannot compile. It is
1729/// **node-local on purpose** — there is no parallel per-branch script document
1730/// that could itself drift from the graph.
1731///
1732/// `text` is authoring/validation metadata, never shown to a player, so it is
1733/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
1734/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
1735/// chronicle is assembled from.
1736#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1737#[serde(deny_unknown_fields)]
1738pub struct Happening {
1739    /// The structured event verb — the machine-decidable part.
1740    pub verb: HappeningVerb,
1741    /// One line of prose stating what this node does to the story.
1742    pub text: String,
1743    /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
1744    /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
1745    /// story token the campaign tracks by hand. Optional, because not every beat
1746    /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
1747    /// over the beats that name one.
1748    #[serde(default, skip_serializing_if = "Option::is_none")]
1749    pub subject: Option<String>,
1750}
1751
1752/// The structured event vocabulary (DSL v0.8, spec-0025).
1753///
1754/// Deliberately small and closed. These ten verbs are what make a subset of
1755/// narrative errors machine-decidable per branch (`DW0485`); everything else a
1756/// beat means lives in [`Happening::text`], which the compiler never interprets.
1757/// Extend only when a real campaign cannot state its beat with what is here.
1758#[derive(
1759    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1760)]
1761#[serde(rename_all = "kebab-case")]
1762pub enum HappeningVerb {
1763    /// The subject is killed / destroyed. Terminal: nothing the subject does may
1764    /// follow it on the same branch.
1765    Dies,
1766    /// The subject comes through alive — the explicit counterpart of `dies`,
1767    /// which is what lets a branch state that somebody *did not* die.
1768    Survives,
1769    /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
1770    Departs,
1771    /// The subject enters the stage.
1772    Arrives,
1773    /// Somebody learns a fact — the true-information beat.
1774    Learns,
1775    /// Somebody comes to believe something (whether or not it is true) — the beat
1776    /// that carries a wrong belief forward, which is where branch drift shows.
1777    Believes,
1778    /// The party (or the subject) gains a thing.
1779    Gains,
1780    /// The party (or the subject) loses a thing. A second `loses` with no
1781    /// intervening `gains` is spending what is already spent (`DW0485`).
1782    Loses,
1783    /// A way is opened.
1784    Opens,
1785    /// A way is sealed. Cleared by `opens`.
1786    Seals,
1787}
1788
1789/// One planned quest (dependency-graph node).
1790#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1791#[serde(deny_unknown_fields)]
1792pub struct PlannedQuest {
1793    /// Unique quest id.
1794    pub id: QuestId,
1795    /// Human-readable goal.
1796    pub goal: String,
1797    /// Area this quest takes place in (stage-1 ref).
1798    pub area: AreaId,
1799    /// NPCs involved (stage-2 refs).
1800    pub npcs: Vec<NpcId>,
1801    /// Prerequisite quests; edges must form a DAG.
1802    pub depends_on: Vec<QuestId>,
1803    /// `false` declares an optional quest (spec-0051).
1804    pub mandatory: bool,
1805    /// Act number (informational).
1806    pub act: u32,
1807}
1808
1809// ---------------------------------------------------------------------------
1810// Stage 5 — quests
1811// ---------------------------------------------------------------------------
1812
1813/// Stage 5 payload: quest expansions.
1814#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1815#[serde(deny_unknown_fields)]
1816pub struct QuestsContent {
1817    /// The expanded quests (1:1 with stage 4).
1818    pub quests: Vec<Quest>,
1819    /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
1820    /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
1821    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1822    pub waves: Vec<Wave>,
1823    /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
1824    /// watches an anchor for a strike / use / approach event and fires a bundle
1825    /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
1826    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1827    pub triggers: Vec<EnvTrigger>,
1828    /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
1829    /// compiler-emitted per-tick teleport. Distinct from stage-2 NPCs
1830    /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
1831    /// staging effects. Empty/absent before v0.6.
1832    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1833    pub actors: Vec<Actor>,
1834    /// Traps (DSL v0.6, spec-0011; command payloads spec-0022): environmental
1835    /// hazards, each at one point anchor an area's prefab provides. Each says
1836    /// what springs the trap, what it then does (a command `payload`, a legacy
1837    /// dispenser `effect`, or both), how dangerous it is, how it is disarmed and
1838    /// whether it re-arms. Empty/absent in pre-0.6 campaigns, so a v0.5-or-earlier campaign that declares none stays
1839    /// byte-identical.
1840    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1841    pub traps: Vec<Trap>,
1842    /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
1843    /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
1844    /// campaigns, so a campaign that declares none stays
1845    /// byte-identical.
1846    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1847    pub shortcuts: Vec<Shortcut>,
1848    /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
1849    /// springs them". Empty/absent in pre-0.6 campaigns.
1850    ///
1851    /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
1852    /// each ambush into `triggers`, so the canonical form of a campaign is its
1853    /// **desugared** form. That is what keeps the canonical round-trip idempotent
1854    /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
1855    /// second time and duplicate the trigger ids), and it means the sugar exists
1856    /// at exactly one layer boundary — the authored `.json` — with nothing
1857    /// downstream needing to know it was ever there. The list itself is kept in
1858    /// memory so diagnostics can name the ambush the author wrote.
1859    /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
1860    /// Empty/absent in pre-0.6 campaigns.
1861    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1862    pub timed_gates: Vec<TimedGate>,
1863    /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
1864    /// given contents at world init. Empty/absent in pre-0.6 campaigns
1865    ///.
1866    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1867    pub loot: Vec<Loot>,
1868    /// Runtime state data (DSL v0.10, spec-0031): named, scoped, integer-valued
1869    /// counters the campaign sets, adds to and clears at runtime, and compares
1870    /// against in any gate. A campaign that declares none emits none of it.
1871    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1872    pub state: Vec<StateDecl>,
1873    /// **The campaign's death beat** (DSL v0.10, spec-0031): effects run at the
1874    /// moment a player dies, for that player. Effect root **R7**
1875    /// ([`crate::EffectRootKind::OnDeath`]); a campaign that declares none emits
1876    /// none of it.
1877    ///
1878    /// **Why this is campaign-wide and not a field on a checkpoint.** The engine
1879    /// already has `on_respawn`, and it hangs off a `set-checkpoint` because
1880    /// *where you come back* is a property of the checkpoint. *That you died* is
1881    /// not: it is true at every point of the delve, under every checkpoint, and a
1882    /// bundle repeated on each checkpoint would be the same content written N
1883    /// times with N chances to forget one. So death is a moment in the campaign,
1884    /// and this is the one place it is named. Anything that should only happen in
1885    /// some phase of the delve is expressed by the ordinary per-effect
1886    /// `requires_flags` / `forbids_flags` gate every other root already carries —
1887    /// no second gating surface.
1888    ///
1889    /// **Audience is the dying player** (`Audience::Solo`, the audience
1890    /// `on_respawn` and `on_caught` already use): a death is one player's, and
1891    /// re-broadcasting it to the party would duplicate their narration and their
1892    /// kit. A beat the whole party should see is a `narrate` addressed by the
1893    /// author to the party through the effect's own vocabulary, not a different
1894    /// default here.
1895    ///
1896    /// **Timing.** It fires on the death edge while the player is still a corpse
1897    /// (`Health: 0.0f`, on the death screen) — see
1898    /// `emit::emit_checkpoint_functions`. That is the difference between this and
1899    /// `on_respawn`, which deliberately waits for the player to come back.
1900    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1901    pub on_death: Vec<QuestEffect>,
1902    /// Lethal volumes (DSL v0.10, spec-0031): declared boxes that kill whatever
1903    /// enters them. Empty/absent in pre-0.10 campaigns, so a
1904    /// campaign that declares none stays byte-identical.
1905    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1906    pub lethal_volumes: Vec<LethalVolume>,
1907    /// Shops (DSL v0.10, spec-0032): interaction points that open a list of
1908    /// gated offers. Empty/absent in pre-0.10 campaigns, so a
1909    /// campaign that declares none stays byte-identical.
1910    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1911    pub shops: Vec<Shop>,
1912    /// Recovery stakes (DSL v0.10, spec-0032): what a death forfeits, where the
1913    /// marker lands, and how it comes back. Empty/absent in pre-0.10 campaigns
1914    ///, so a campaign that declares none stays byte-identical.
1915    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1916    pub stakes: Vec<Stake>,
1917    #[serde(default, skip_serializing)]
1918    pub ambushes: Vec<Ambush>,
1919    /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
1920    /// authored `ambushes` are deliberately KEPT after expansion so validation
1921    /// and the counterplay proof can attribute diagnostics to the ambush the
1922    /// author actually wrote; this flag is what makes a second expansion a no-op.
1923    #[serde(skip)]
1924    pub ambushes_expanded: bool,
1925}
1926
1927impl QuestsContent {
1928    /// Every environment trigger this stage produces: the authored `triggers`
1929    /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
1930    /// declared order.
1931    ///
1932    /// **This is the single trigger authority.** Validation, the l10n
1933    /// inventory, the flag/wave producer scans, the nav proofs and emission all
1934    /// read triggers through it, so an ambush behaves exactly like the trigger
1935    /// an author would otherwise hand-write — there is no second code path for
1936    /// the sugar to drift down. Deterministic (declaration order, no hashing).
1937    pub fn all_triggers(&self) -> Vec<EnvTrigger> {
1938        let mut out = self.triggers.clone();
1939        out.extend(self.ambushes.iter().map(Ambush::to_trigger));
1940        out
1941    }
1942
1943    /// **Does the campaign itself answer a right-click at `anchor`?**
1944    ///
1945    /// One predicate, read by both consumers of the press-answer rule, so they can
1946    /// never disagree about what "the campaign answered it" means: the compiler's
1947    /// synthesis (`plan::collect_press_answers`, which stands down where this is
1948    /// true) and the obligation on a shortcut door (`DW0429`, which fires where it
1949    /// is false). Split across the two crates they would drift, and the drift
1950    /// would read as "the compiler refused a door I answered".
1951    ///
1952    /// Deliberately the widest reading — *any* `use` trigger anchored there.
1953    /// Pressing it already does something the author chose, and the engine does
1954    /// not adjudicate whether what they chose counts as an answer.
1955    pub fn answers_press_at(&self, anchor: &str) -> bool {
1956        self.all_triggers()
1957            .iter()
1958            .any(|t| matches!(t.on, TriggerOn::Use) && t.at_anchor() == Some(anchor))
1959    }
1960
1961    /// Desugar every `ambush` into a real environment trigger and clear the
1962    /// ambush list (spec-0016 §3). Called once, by
1963    /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
1964    /// (a second call sees no ambushes left to expand).
1965    pub fn expand_ambushes(&mut self) {
1966        if self.ambushes_expanded || self.ambushes.is_empty() {
1967            return;
1968        }
1969        self.triggers = self.all_triggers();
1970        self.ambushes_expanded = true;
1971    }
1972
1973    /// The declared datum with this id, if any (DSL v0.10).
1974    pub fn state_decl(&self, id: &str) -> Option<&StateDecl> {
1975        self.state.iter().find(|s| s.id.as_str() == id)
1976    }
1977
1978    /// The declared stake with this id, if any (DSL v0.10, spec-0032).
1979    pub fn stake_decl(&self, id: &str) -> Option<&Stake> {
1980        self.stakes.iter().find(|s| s.id.as_str() == id)
1981    }
1982}
1983
1984// ---------------------------------------------------------------------------
1985// Stage 5 — runtime state (DSL v0.10, spec-0031)
1986// ---------------------------------------------------------------------------
1987
1988/// Who holds a datum's value (DSL v0.10, spec-0031).
1989///
1990/// **Declared, never inferred.** A datum's multiplayer semantics is the one
1991/// thing about it that cannot be recovered from its uses: a purse read on a
1992/// `talk-to` looks identical whether every player has their own or the party
1993/// shares one, and the difference decides the whole design. spec-0031 states it
1994/// as a rule, and the type makes it un-omittable — there is no default.
1995#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1996#[serde(rename_all = "kebab-case")]
1997pub enum StateScope {
1998    /// Each player holds their own value. Read and written against the acting
1999    /// player, so a bundle with no acting player (the scheduler) cannot touch one
2000    /// (`DW0503`).
2001    Player,
2002    /// One value the whole party shares — the same holder story flags use
2003    /// (spec-0018). Readable and writable from every audience, including the
2004    /// scheduler.
2005    Party,
2006}
2007
2008impl StateScope {
2009    /// The wire token (`player` / `party`).
2010    pub fn token(self) -> &'static str {
2011        match self {
2012            StateScope::Player => "player",
2013            StateScope::Party => "party",
2014        }
2015    }
2016}
2017
2018/// One declared runtime datum (DSL v0.10, spec-0031): a named, scoped,
2019/// integer-valued counter.
2020///
2021/// This is what [`FlagId`] is not. A flag is boolean, party-wide and
2022/// **monotonic** — no verb clears one — which is exactly right for "this has
2023/// happened" and useless for a balance, a floor number, or "a ride is in
2024/// progress". A datum clears, counts down as well as up, and states its scope.
2025#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2026#[serde(deny_unknown_fields)]
2027pub struct StateDecl {
2028    /// Unique datum id (`state/<kebab>`).
2029    pub id: StateId,
2030    /// Who holds the value. Required — see [`StateScope`].
2031    pub scope: StateScope,
2032    /// The value the datum starts at, and the value `clear-state` returns it to.
2033    /// Defaults to `0`.
2034    ///
2035    /// One field rather than a separate `initial` and `cleared`: "the value this
2036    /// datum has when nothing has happened to it yet" is one fact, and two fields
2037    /// would let a campaign declare a datum that can never be returned to its own
2038    /// starting state.
2039    #[serde(default, skip_serializing_if = "is_zero_i32")]
2040    pub initial: i32,
2041    /// Free prose: what this datum means. Never machine-checked, never shown to a
2042    /// player — the forcing function that makes an author say what the number is,
2043    /// the same role `cast[].doing` plays.
2044    #[serde(default, skip_serializing_if = "Option::is_none")]
2045    pub note: Option<String>,
2046    /// The player-visible name of this datum (DSL v0.10, spec-0032).
2047    ///
2048    /// **A named datum is a currency.** There is no separate `currencies` section
2049    /// and there deliberately is not: a purse is a runtime datum that the player
2050    /// can see, and "the player can see it" is a property of the datum, not a
2051    /// different object class. A second struct carrying `id` + `scope` +
2052    /// `initial` + `name` would be a private copy of this one, which is the defect
2053    /// CLAUDE.md names second.
2054    ///
2055    /// Present ⇒ every `set-state` / `add-state` / `clear-state` on this datum
2056    /// also states the new balance to whoever holds it, on the action bar, as
2057    /// `<name>: <value>` with the value carried by vanilla's own `score`
2058    /// component. Absent ⇒ the datum is silent bookkeeping and emission is exactly
2059    /// what it was.
2060    ///
2061    /// Player-visible, so it is inventoried under `state.<id>.name` and
2062    /// translated like any other authored line.
2063    #[serde(default, skip_serializing_if = "Option::is_none")]
2064    pub name: Option<String>,
2065}
2066
2067/// serde `skip_serializing_if` helper: skip a zero `i32` (`StateDecl.initial`).
2068fn is_zero_i32(v: &i32) -> bool {
2069    *v == 0
2070}
2071
2072/// How a [`StateCompare`] relates a datum to its operand (DSL v0.10).
2073///
2074/// Four operators, not six: over integers `less-than n` is `at-most n-1` and
2075/// `greater-than n` is `at-least n+1`, so the extra spellings would add a second
2076/// way to say one thing and a second emission path to keep honest.
2077#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2078#[serde(rename_all = "kebab-case")]
2079pub enum CompareOp {
2080    /// The datum is exactly `value`.
2081    Equals,
2082    /// The datum is anything but `value`.
2083    NotEquals,
2084    /// The datum is `value` or more.
2085    AtLeast,
2086    /// The datum is `value` or less.
2087    AtMost,
2088}
2089
2090impl CompareOp {
2091    /// The wire token (`equals` / `not-equals` / `at-least` / `at-most`).
2092    pub fn token(self) -> &'static str {
2093        match self {
2094            CompareOp::Equals => "equals",
2095            CompareOp::NotEquals => "not-equals",
2096            CompareOp::AtLeast => "at-least",
2097            CompareOp::AtMost => "at-most",
2098        }
2099    }
2100}
2101
2102/// What one of the DSL v0.10 state verbs does to a datum — the value half of
2103/// [`QuestEffect::writes_state`].
2104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2105pub enum StateWrite {
2106    /// `set-state`: the datum becomes this value.
2107    Set(i32),
2108    /// `add-state`: the datum moves by this signed amount.
2109    Add(i32),
2110    /// `clear-state`: the datum returns to its declared `initial`.
2111    Clear,
2112}
2113
2114/// One numeric term of a gate (DSL v0.10, spec-0031): *this datum compares thus
2115/// to this value*.
2116///
2117/// It rides [`Gate`](crate::gate::Gate) — the shared gate, carried by every
2118/// consumer of `requires_flags`/`forbids_flags` — and not any one verb. The
2119/// comparison's consumers are exactly the gate's consumers ("this door opens at
2120/// 500", "this line is withheld below 200", "this lever does nothing while the
2121/// car is moving"), so hanging it off the first verb that asked would leave the
2122/// second with no surface and make a second bespoke field look like the fix.
2123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2124#[serde(deny_unknown_fields)]
2125pub struct StateCompare {
2126    /// The datum to read. Must be declared in the stage-5 `state` list
2127    /// (`DW0502`).
2128    pub state: StateId,
2129    /// How to compare it.
2130    pub op: CompareOp,
2131    /// What to compare it against.
2132    pub value: i32,
2133}
2134
2135/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
2136/// barrel the prefab already placed.
2137///
2138/// The container is **hardware the prefab authored**, exactly like a trap's
2139/// dispenser: this declaration gives an already-placed, already-lit, already
2140/// composed piece of furniture its contents. The compiler never places the
2141/// container itself — if the anchor's cell does not already hold one, that is a
2142/// content defect and a build error (`DW0431`), not something to paper over by
2143/// setblock-ing a chest into a wall.
2144#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2145#[serde(deny_unknown_fields)]
2146pub struct Loot {
2147    /// Unique loot id (`loot/<kebab>`).
2148    pub id: LootId,
2149    /// The anchor whose cell holds the container to fill.
2150    pub anchor: AnchorId,
2151    /// Contents, in declaration order. Slot assignment is positional and
2152    /// deterministic — the first entry lands in `container.0`, the second in
2153    /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
2154    pub items: Vec<LootItem>,
2155}
2156
2157/// One stack inside a [`Loot`] container.
2158#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2159#[serde(deny_unknown_fields)]
2160pub struct LootItem {
2161    /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
2162    /// 1.21.11 item registry (`DW0143`).
2163    pub item: String,
2164    /// Stack size. Defaults to 1.
2165    #[serde(default = "one_u32")]
2166    pub count: u32,
2167    /// Optional custom item name. Enters the l10n string inventory exactly like
2168    /// a class kit item's name.
2169    #[serde(default, skip_serializing_if = "Option::is_none")]
2170    pub name: Option<String>,
2171    /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
2172    /// 1.21 `minecraft:enchantments` item component.
2173    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2174    pub enchantments: BTreeMap<String, u32>,
2175}
2176
2177fn one_u32() -> u32 {
2178    1
2179}
2180
2181/// A stage-5 trap (DSL v0.6, spec-0011; command payloads spec-0022): an
2182/// environmental hazard at one cell of a placed piece.
2183///
2184/// **What the prefab has to provide is one point anchor, and for most traps that
2185/// is all.** [`Trap::at`] names the trigger/hazard cell; the compiler models it
2186/// as a hazard for the completability proofs (`DW0342`) and, for a disarmable
2187/// trap, emits the disarm affordance. A [`payload`](Trap::payload) trap needs
2188/// nothing else: **the compiler owns the detection**, emitting a per-tick,
2189/// edge-latched `execute … if entity @a[<cell>]` and running the authored effect
2190/// bundle from it.
2191///
2192/// Two things a piece must pre-wire, each for one case and neither for the
2193/// common one:
2194///
2195/// * the legacy [`effect`](Trap::effect) — a `dispense` payload the prefab's own
2196///   redstone fires — needs the anchor's `dispenser` socket cell, which the
2197///   compiler fills. That is the case "harm is redstone-native" (spec-0011) was
2198///   written about, and the only one in which no detection is emitted.
2199/// * a **flag-gated** trap ([`requires_flags`](Trap::requires_flags) /
2200///   [`forbids_flags`](Trap::forbids_flags)) needs the anchor's `trigger_block`,
2201///   because gating removes the trigger block from the world while the gate is
2202///   shut and puts it back verbatim (`DW0363`).
2203///
2204/// Player-vs-mob distinguishing matters in a sealed box-garden with controlled
2205/// mobs, so `trapped-chest` (opened by a player) is called out as the only
2206/// player-distinct trigger.
2207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2208#[serde(deny_unknown_fields)]
2209pub struct Trap {
2210    /// Unique trap id (`trap/<kebab>`).
2211    pub id: TrapId,
2212    /// **The point anchor this trap sits on** — any anchor an area's prefab
2213    /// provides, whatever it is called. Its cell is the trigger/hazard cell the
2214    /// compiler models, and for a `payload` trap that cell is the whole of what
2215    /// the piece has to provide: detection is the compiler's.
2216    ///
2217    /// The anchor additionally needs a `dispenser` socket for a legacy
2218    /// [`effect`](Trap::effect) trap, and a `trigger_block` for a flag-gated one
2219    /// (`DW0363`). `anchor/trap` is the name the shipped pieces use, and a name
2220    /// is all it is.
2221    pub at: AnchorId,
2222    /// What springs the trap (all redstone-native).
2223    pub trigger: TrapTrigger,
2224    /// The **legacy** redstone consequence (spec-0011): a static dispenser
2225    /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
2226    /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
2227    /// so existing campaigns build unchanged. Optional since spec-0022; a trap
2228    /// must declare `effect`, `payload`, or both (`DW0440`).
2229    #[serde(default, skip_serializing_if = "Option::is_none")]
2230    pub effect: Option<TrapEffect>,
2231    /// The **command payload** (spec-0022): an ordered effect list in the same
2232    /// vocabulary quests use, run when the trigger fires. This is where a trap's
2233    /// consequence lives now — the compiler owns the detection tick and the
2234    /// effect vocabulary, so a trap's payload is authored like any other effect
2235    /// bundle rather than built out of dust and repeaters. Expressiveness moves
2236    /// from "what dust can carry" to "what the effect vocabulary can say":
2237    /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
2238    /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
2239    ///
2240    /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
2241    /// emitted before (byte-identical).
2242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2243    pub payload: Vec<QuestEffect>,
2244    /// How dangerous the trap is. A `lethal` trap on the forced critical path
2245    /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
2246    /// carry none. Defaults to `harmful`.
2247    #[serde(default)]
2248    pub lethality: Lethality,
2249    /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
2250    /// turn the trap off — setting a flag and emptying the dispenser — before the
2251    /// trap cell is forced.
2252    #[serde(default, skip_serializing_if = "Option::is_none")]
2253    pub disarm: Option<TrapDisarm>,
2254    /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
2255    /// spent — the survivability path); `rearm` = re-fires each trigger (default).
2256    #[serde(default)]
2257    pub reset: TrapReset,
2258    /// Flags that must be set before the trap is considered active (mirrors
2259    /// [`EnvTrigger::requires_flags`]). Default empty.
2260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2261    pub requires_flags: Vec<FlagId>,
2262    /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
2263    /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
2264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2265    pub forbids_flags: Vec<FlagId>,
2266    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2267    /// hold for this gate to be open. The third field of the one gate, carried by
2268    /// every gate consumer — never by the verb that first wanted it. Default
2269    /// empty, so a pre-0.10 campaign is byte-identical.
2270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2271    pub requires_state: Vec<StateCompare>,
2272}
2273
2274impl Trap {
2275    /// `(item, count)` if this trap declares a legacy `dispense` effect.
2276    pub fn dispense(&self) -> Option<(&str, u32)> {
2277        match &self.effect {
2278            Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
2279            None => None,
2280        }
2281    }
2282
2283    /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
2284    /// forced critical path).
2285    pub fn is_lethal(&self) -> bool {
2286        matches!(self.lethality, Lethality::Lethal)
2287    }
2288}
2289
2290/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
2291/// redstone-native — the hardware fires without any command — so the compiler
2292/// emits no detection for them; it only models the trigger cell as a hazard and
2293/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
2294/// is deliberately *not* a trap trigger: it is already fully expressible as an
2295/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
2296#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2297#[serde(rename_all = "kebab-case")]
2298pub enum TrapTrigger {
2299    /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
2300    PressurePlate,
2301    /// A tripwire line: any entity crossing it. Auto-rearms.
2302    Tripwire,
2303    /// A trapped chest: a *player opening it* (comparator pulse). The only
2304    /// player-distinct trigger — a controlled mob cannot spring it.
2305    TrappedChest,
2306}
2307
2308impl TrapTrigger {
2309    /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
2310    pub fn kind(&self) -> &'static str {
2311        match self {
2312            TrapTrigger::PressurePlate => "pressure-plate",
2313            TrapTrigger::Tripwire => "tripwire",
2314            TrapTrigger::TrappedChest => "trapped-chest",
2315        }
2316    }
2317}
2318
2319/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
2320/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
2321/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
2322/// block-destroying and unmodeled effects out of the schema by construction
2323/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
2324#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2325#[serde(rename_all = "kebab-case", deny_unknown_fields)]
2326pub enum TrapEffect {
2327    /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
2328    /// arrows, splash potions). The redstone fires it; terrain is untouched
2329    /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
2330    /// `Items` NBT — a deterministic, static payload.
2331    Dispense {
2332        /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
2333        item: String,
2334        /// How many to load into the dispenser stack.
2335        count: u32,
2336    },
2337}
2338
2339/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
2340/// forced-critical-path completability obligation (`DW0342`).
2341#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2342#[serde(rename_all = "kebab-case")]
2343pub enum Lethality {
2344    /// Can kill a full-health player — carries the `DW0342` obligation on the path.
2345    Lethal,
2346    /// Hurts but is not designed to kill (the default).
2347    #[default]
2348    Harmful,
2349    /// Cosmetic / trivial (a stumble, a scare).
2350    Nonlethal,
2351}
2352
2353/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
2354#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2355#[serde(rename_all = "kebab-case")]
2356pub enum TrapReset {
2357    /// Fires once, then is spent — the survivability path for a forced lethal trap
2358    /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
2359    /// soft-loop).
2360    Once,
2361    /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
2362    /// trap must be avoidable or disarmable, else `DW0342`.
2363    #[default]
2364    Rearm,
2365}
2366
2367/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
2368/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
2369/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
2370/// before the trap cell is forced. Discharges the `DW0342` obligation when the
2371/// affordance is reachable ahead of the trap without crossing it.
2372#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2373#[serde(deny_unknown_fields)]
2374pub struct TrapDisarm {
2375    /// The anchor the player interacts with to disarm.
2376    pub via: AnchorId,
2377    /// The flag set when the trap is disarmed (a new flag this trap produces; other
2378    /// objectives/triggers may read it via `requires_flags`).
2379    pub sets_flag: FlagId,
2380}
2381
2382/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
2383/// deterministic open/close clock, so passage is a timing read rather than a
2384/// permanent state.
2385///
2386/// **The proof is deliberately NOT all-phase passability** — a gate that
2387/// punishes bad timing is the entire point. What the
2388/// compiler requires is that the gate is *readable*: the set of entry phases from
2389/// which a walking player clears the span before it shuts must cover **≥ 20% of
2390/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
2391/// amount of learning the level makes it fair.
2392#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2393#[serde(deny_unknown_fields)]
2394pub struct TimedGate {
2395    /// Unique timed-gate id (`timed-gate/<kebab>`).
2396    pub id: TimedGateId,
2397    /// The gate anchor the clock drives. Its prefab metadata must declare a fill
2398    /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
2399    pub gate: AnchorId,
2400    /// Ticks the gate stays OPEN each cycle (> 0).
2401    pub open_ticks: u32,
2402    /// Ticks the gate stays CLOSED each cycle (> 0).
2403    pub closed_ticks: u32,
2404    /// Ticks after world init before the first open window begins (default 0) —
2405    /// how two gates in the same room are put out of step with each other. Must
2406    /// be less than the full cycle.
2407    #[serde(default, skip_serializing_if = "is_zero")]
2408    pub phase: u32,
2409    /// Whether the gate **kills** a player caught inside its region when it
2410    /// shuts (spec-0016 §4 addendum). A portcullis
2411    /// that merely shoves you aside teaches nothing; mistiming the crossing is
2412    /// supposed to be a death you learn from, which is the whole point of §4's
2413    /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
2414    /// absolute.
2415    ///
2416    /// This is a real judgement issued by command on the closing tick, not
2417    /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
2418    /// escapable, so it would make the portcullis a suggestion. `damage` at the
2419    /// closing edge is exact and unarguable.
2420    ///
2421    /// **Defaults to `false`**, so every campaign authored before this field
2422    /// existed compiles byte-identically; a delve opts its portcullis in.
2423    #[serde(default, skip_serializing_if = "is_false")]
2424    pub crush: bool,
2425    /// Optional **disarm** affordance (souls dossier §5.2): the third
2426    /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
2427    /// The real games' best timed hazards can be removed for good (Smouldering
2428    /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
2429    /// dance with is one rung short of the vocabulary.
2430    ///
2431    /// Interacting with the affordance suppresses the clock **permanently, with
2432    /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
2433    /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
2434    /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
2435    ///
2436    /// **Defaults to absent**, so every campaign authored before this field
2437    /// existed compiles byte-identically; a delve opts its portcullis in.
2438    #[serde(default, skip_serializing_if = "Option::is_none")]
2439    pub disarm: Option<TimedGateDisarm>,
2440}
2441
2442/// A [`TimedGate`]'s disarm affordance (souls dossier §5.2) — the
2443/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
2444/// grammar for every mechanism the party can switch off.
2445///
2446/// The player acts on the `via` anchor (a compiler-emitted interaction entity
2447/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
2448/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
2449/// put the gate back.
2450#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2451#[serde(deny_unknown_fields)]
2452pub struct TimedGateDisarm {
2453    /// The anchor the player interacts with to jam the gate. Must be an anchor
2454    /// some area's prefab provides, and never the gate anchor itself — the
2455    /// mechanism belongs beside the doorway, not inside the span that crushes.
2456    pub via: AnchorId,
2457    /// The flag set when the gate is disarmed (a new flag this gate produces;
2458    /// other objectives/triggers may read it via `requires_flags`).
2459    pub sets_flag: FlagId,
2460}
2461
2462/// serde `skip_serializing_if` helper: skip a `0`.
2463fn is_zero(n: &u32) -> bool {
2464    *n == 0
2465}
2466
2467/// A stage-5 **ambush** (spec-0016 §3) — one declaration for a beat that
2468/// otherwise takes a deferred actor set plus a hand-wired trigger.
2469///
2470/// **`telegraph` is optional, and that is a design ruling, not an oversight.**
2471/// The un-telegraphed ambush — the shove off the cliff you
2472/// could not have known about — is core souls vocabulary: 初见杀 is how the level
2473/// teaches. The engine does not sand that edge off.
2474///
2475/// What the engine *does* owe the player is **counterplay on the retry**: having
2476/// died once, an informed player must have something to do about it. Determinism
2477/// guarantees the second attempt meets the same ambushers in the same cells; the
2478/// compiler adds the missing half — `DW0376` proves the trigger cell is not a
2479/// sealed pocket, i.e. that with every ambusher standing where it will stand,
2480/// a route out still exists (a retreat, luring ground, a positioning line).
2481/// Dying uninformed is a lesson; dying with no play available is a broken beat.
2482#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2483#[serde(deny_unknown_fields)]
2484pub struct Ambush {
2485    /// Unique ambush id (`ambush/<kebab>`).
2486    pub id: AmbushId,
2487    /// The anchor the trigger watches — the corner, doorway or ledge the player
2488    /// walks into.
2489    pub at: AnchorId,
2490    /// The stage-5 actors that spring (1..N). Each is summoned at its own
2491    /// declared anchor and immediately unleashed to real AI.
2492    pub actors: Vec<ActorId>,
2493    /// What springs it (`approach{range}` / `strike` / `use`), exactly the v0.4
2494    /// environment-trigger vocabulary.
2495    pub trigger: TriggerOn,
2496    /// The **optional** tell, fired at the trigger before the ambushers exist:
2497    /// a sound, a shadow, a line of narration. Empty = un-telegraphed, which is
2498    /// fully legal.
2499    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2500    pub telegraph: Vec<QuestEffect>,
2501    /// What the spring does to the story (DSL v0.8, spec-0025; required at
2502    /// 0.8.0, `DW0481`).
2503    ///
2504    /// **The declaration lives here because this is the object the author
2505    /// wrote.** An ambush desugars into a `spawn-actor` plus an `unleash-actor`
2506    /// per listed actor, and every one of those is a story node `DW0481` demands
2507    /// a `happening` from — on effects the author never wrote and cannot reach.
2508    /// So for as long as this field did not exist, an `ambushes[]` entry could
2509    /// not compile at 0.8.0 or above at all: declared in the schema, accepted by
2510    /// the schema check, and refused at validation with a prescription naming a
2511    /// field that was not there. Nothing caught it because no campaign and no
2512    /// fixture had ever written an ambush at a version where the obligation was
2513    /// live — which is the case spec-0039 exists to make impossible.
2514    ///
2515    /// An ambush is **one** beat, not `2N` of them: the ambushers appearing and
2516    /// coming at you is a single dramatic moment, so one declaration covers the
2517    /// whole spring. [`Self::to_trigger`] stamps it onto the first generated
2518    /// `spawn-actor` so the chronicle carries the line at the right position,
2519    /// and `branch::check_happenings` reads the obligation off the ambush rather
2520    /// than off the beats derived from it. Repeating it on all `2N` is the
2521    /// obvious alternative and it is wrong twice over: the chronicle would gain
2522    /// `N` duplicate lines, and `DW0485` would see one subject act repeatedly
2523    /// and be right to call it a contradiction.
2524    #[serde(default, skip_serializing_if = "Option::is_none")]
2525    pub happening: Option<Happening>,
2526}
2527
2528impl Ambush {
2529    /// The environment trigger this ambush desugars to (spec-0016 §3): a
2530    /// one-shot trigger at `at` whose effects are the telegraph, then a
2531    /// `spawn-actor` + `unleash-actor` per listed actor, in declared order.
2532    ///
2533    /// This is the **single** expansion authority. Every consumer — validation,
2534    /// the l10n inventory, the flag/wave producer scans, nav, emission — reads
2535    /// triggers through [`QuestsContent::all_triggers`], so the sugar cannot
2536    /// diverge from a hand-written equivalent, and an ambush is exactly as
2537    /// debuggable as the trigger an author would otherwise type.
2538    pub fn to_trigger(&self) -> EnvTrigger {
2539        let mut effects = self.telegraph.clone();
2540        for (i, a) in self.actors.iter().enumerate() {
2541            effects.push(QuestEffect::SpawnActor {
2542                actor: a.clone(),
2543                // The ambush declaration, on the beat where the spring becomes
2544                // real. Only the FIRST, for the reason the field documents: one
2545                // ambush is one beat, and stamping the line on every generated
2546                // effect would pad the chronicle and trip `DW0485`.
2547                happening: if i == 0 { self.happening.clone() } else { None },
2548            });
2549        }
2550        for a in &self.actors {
2551            effects.push(QuestEffect::UnleashActor {
2552                actor: a.clone(),
2553                happening: None,
2554            });
2555        }
2556        EnvTrigger {
2557            id: TriggerId(format!(
2558                "trigger/{}",
2559                crate::l10n::local_id(self.id.as_str())
2560            )),
2561            at: Some(self.at.clone()),
2562            on: self.trigger.clone(),
2563            requires_flags: Vec::new(),
2564            forbids_flags: Vec::new(),
2565            requires_state: Vec::new(),
2566            once: true,
2567            // An ambush is a party beat by construction — it springs actors at
2568            // the room, not a reply to the one who walked in.
2569            audience: TriggerAudience::Party,
2570            effects,
2571        }
2572    }
2573}
2574
2575/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
2576///
2577/// The owner's definition of the pattern: between two rest points there are two
2578/// routes. The **short** one starts sealed and holds nothing; the **long** one is
2579/// full of enemies and mechanisms. You earn the far side the hard way, pull one
2580/// mechanism, and the short route opens **forever**. That moment is the design.
2581///
2582/// The compiler owns three obligations, none of them optional:
2583/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
2584///    long route genuinely exists (`DW0373`);
2585/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
2586///    pays nothing is a leak, not a shortcut (`DW0360`);
2587/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
2588///    (`DW0372`). There is no re-sealing verb to reach for.
2589///
2590/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
2591/// untouched by this — the two verbs are deliberately disjoint.
2592#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2593#[serde(deny_unknown_fields)]
2594pub struct Shortcut {
2595    /// Unique shortcut id (`shortcut/<kebab>`).
2596    pub id: ShortcutId,
2597    /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
2598    /// carries the physical fill), and its metadata must declare the fill `block`
2599    /// the compiler clears — the same requirement `close-gate` has.
2600    pub gate: AnchorId,
2601    /// The FAR-side anchor whose interaction fires the permanent open. The
2602    /// compiler summons the affordance there and polls it, reusing the v0.4
2603    /// interaction-entity `use` primitive.
2604    pub unlock: AnchorId,
2605    /// Effects fired once, when the shortcut opens — the bar lifting, the
2606    /// elevator descending, the sound of a door you will never have to earn
2607    /// again. Emitted server-source-safe (the poll lives on the tick).
2608    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2609    pub on_unlock: Vec<QuestEffect>,
2610}
2611
2612/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
2613/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
2614/// entity's attack/interaction records; `approach` is a `distance` selector on
2615/// the tick. Look-at / break-attempt detection is excluded on principle (no
2616/// vanilla primitive).
2617#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2618#[serde(deny_unknown_fields)]
2619pub struct EnvTrigger {
2620    /// Unique trigger id (`trigger/<kebab>`).
2621    pub id: TriggerId,
2622    /// The anchor this trigger watches. Required for `strike` / `use` /
2623    /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
2624    /// v0.6), which watches a *character* and names it in `on.npc` instead —
2625    /// there is no cell for the author to supply and no cell the compiler
2626    /// would use. Either mismatch is `DW0194`.
2627    #[serde(default, skip_serializing_if = "Option::is_none")]
2628    pub at: Option<AnchorId>,
2629    /// The event that fires it.
2630    pub on: TriggerOn,
2631    /// Flags that must be set before the trigger can fire (DSL v0.4).
2632    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2633    pub requires_flags: Vec<FlagId>,
2634    /// Negative flag gate (DSL v0.6): the trigger is
2635    /// **suppressed** while ANY listed flag is set (by any player — flags are
2636    /// campaign state). The dual of `requires_flags`, so an "armed between two
2637    /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
2638    /// retaliation trigger with `requires_flags: [flag/sealed]` and
2639    /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
2640    /// the moment the wake beat takes over.
2641    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2642    pub forbids_flags: Vec<FlagId>,
2643    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2644    /// hold for this gate to be open. The third field of the one gate, carried by
2645    /// every gate consumer — never by the verb that first wanted it. Default
2646    /// empty, so a pre-0.10 campaign is byte-identical.
2647    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2648    pub requires_state: Vec<StateCompare>,
2649    /// Fire at most once (default `true`, mirroring objective completion). Set
2650    /// `false` to allow re-firing every time the condition is met.
2651    #[serde(default = "default_true")]
2652    pub once: bool,
2653    /// **Who the trigger's effects address** (DSL v0.11). Default
2654    /// [`TriggerAudience::Party`], so every campaign written before this field
2655    /// existed is byte-identical.
2656    ///
2657    /// A trigger is two different things depending on what the author means by
2658    /// it. A pressure plate that opens a gate and narrates the room is a **party
2659    /// beat**: everyone should see it, and it does not matter who stepped on the
2660    /// plate. A barred door that answers *"this cannot be opened from this
2661    /// side"* is a **reply to one person**: broadcasting it tells four players
2662    /// about a door three of them are nowhere near.
2663    ///
2664    /// Until this field the second was inexpressible, which is why the two verbs
2665    /// that needed it (`close-gate`'s seal answer, and nothing at all for a
2666    /// shortcut door) grew their own private reply machinery instead. The
2667    /// capability belongs to the press, not to the verb.
2668    #[serde(default, skip_serializing_if = "TriggerAudience::is_party")]
2669    pub audience: TriggerAudience,
2670    /// Effects fired when the trigger matches.
2671    pub effects: Vec<QuestEffect>,
2672}
2673
2674impl EnvTrigger {
2675    /// The anchor this trigger watches, if it watches a place at all. `None`
2676    /// for `strike-npc`, whose target is a character.
2677    pub fn at_anchor(&self) -> Option<&str> {
2678        self.at.as_ref().map(|a| a.as_str())
2679    }
2680
2681    /// Whether this trigger's bundle is addressed to the player who pressed it.
2682    pub fn addresses_presser(&self) -> bool {
2683        self.audience == TriggerAudience::Presser
2684    }
2685}
2686
2687/// Who an [`EnvTrigger`]'s effects address (DSL v0.11).
2688///
2689/// **This is a dispatch decision, not a cosmetic one.** A `party` trigger is
2690/// polled on the tick with no executor, so `@s` does not exist and every
2691/// player-facing command addresses `@a`. A `presser` trigger is dispatched by a
2692/// `minecraft:player_interacted_with_entity` advancement — the one vanilla
2693/// primitive that runs a function *as the player who clicked* — so `@s` is the
2694/// presser and the bundle addresses them alone.
2695///
2696/// That primitive exists for **right-clicks only**. Vanilla records a left-click
2697/// on an interaction entity in NBT (which names a UUID no command can become) and
2698/// offers no criterion for it, so `presser` on a `strike` is refused (`DW0427`)
2699/// rather than approximated: per CLAUDE.md's no-hack rule, a capability with no
2700/// vanilla primitive under it is excluded, never faked downstream.
2701#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2702#[serde(rename_all = "kebab-case")]
2703pub enum TriggerAudience {
2704    /// The whole party (the default, and what every trigger did before v0.11).
2705    #[default]
2706    Party,
2707    /// The one player whose click fired it.
2708    Presser,
2709}
2710
2711impl TriggerAudience {
2712    /// Serde skip predicate: the default needs no field on the wire, so a
2713    /// canonical round-trip of a pre-0.11 campaign is byte-identical.
2714    fn is_party(&self) -> bool {
2715        *self == TriggerAudience::Party
2716    }
2717}
2718
2719/// The event an [`EnvTrigger`] watches (DSL v0.4).
2720#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2721#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
2722pub enum TriggerOn {
2723    /// The player attacks (left-clicks) the interaction entity at the anchor.
2724    Strike,
2725    /// The player uses (right-clicks) the interaction entity at the anchor.
2726    Use,
2727    /// The player comes within `range` blocks of the anchor.
2728    Approach {
2729        /// Approach radius (blocks).
2730        range: u32,
2731    },
2732    /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6).
2733    ///
2734    /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
2735    /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
2736    /// body eclipses that cell (`DW0359`), so the click never reaches the
2737    /// trigger — the owner's island round-7 finding. This form has no cell. It
2738    /// rides the interaction entity the NPC already owns, which is the entity a
2739    /// click on that NPC reaches by construction.
2740    ///
2741    /// Right-click and left-click stay separate all the way down: a
2742    /// `minecraft:interaction` records them in two distinct NBT fields
2743    /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
2744    /// and this trigger takes the left-click, on one shared hitbox.
2745    StrikeNpc {
2746        /// The NPC (stage-2 ref) whose body is the target.
2747        npc: NpcId,
2748    },
2749}
2750
2751impl TriggerOn {
2752    /// The kebab tag (`strike` / `use` / `approach` / `strike-npc`).
2753    pub fn kind(&self) -> &'static str {
2754        match self {
2755            TriggerOn::Strike => "strike",
2756            TriggerOn::Use => "use",
2757            TriggerOn::Approach { .. } => "approach",
2758            TriggerOn::StrikeNpc { .. } => "strike-npc",
2759        }
2760    }
2761
2762    /// Whether this event needs an `at` anchor — true for everything that
2763    /// watches a place, false for `strike-npc`, which watches a character.
2764    pub fn needs_anchor(&self) -> bool {
2765        !matches!(self, TriggerOn::StrikeNpc { .. })
2766    }
2767
2768    /// The NPC whose body this event watches (`strike-npc` only).
2769    pub fn npc_target(&self) -> Option<&NpcId> {
2770        match self {
2771            TriggerOn::StrikeNpc { npc } => Some(npc),
2772            _ => None,
2773        }
2774    }
2775}
2776
2777/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
2778/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
2779/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
2780/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
2781/// and the `kill` objective completes when the count reaches zero.
2782#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2783#[serde(deny_unknown_fields)]
2784pub struct Wave {
2785    /// Unique wave id.
2786    pub id: WaveId,
2787    /// The anchor the wave's mobs spawn at.
2788    pub anchor: AnchorId,
2789    /// The mobs that make up the wave (1..N).
2790    pub mobs: Vec<WaveMob>,
2791    /// Re-seat this wave every time the party rests at (or respawns from) a
2792    /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
2793    /// enemies come back. The compiler kills any survivor carrying the wave tag
2794    /// and re-runs the wave's own spawn function, so the room is restored to its
2795    /// authored composition and spawn cells.
2796    ///
2797    /// Inert without a `bonfire` in the campaign, which is a compile error
2798    /// (`DW0370`) rather than a silent no-op.
2799    #[serde(default, skip_serializing_if = "is_false")]
2800    pub respawns_on_rest: bool,
2801    /// Tower-defense lane routing (spec-0016 §6): march this wave along a
2802    /// waypoint polyline while distant, hand it to native AI the instant a
2803    /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
2804    /// stand), byte-identical.
2805    #[serde(default, skip_serializing_if = "Option::is_none")]
2806    pub lane: Option<WaveLane>,
2807    /// Where the wave's mobs materialize (spec-0016 §6). Absent =
2808    /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
2809    /// wave `anchor`.
2810    #[serde(default, skip_serializing_if = "Option::is_none")]
2811    pub summon: Option<WaveSummon>,
2812    /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
2813    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
2814    ///
2815    /// This is a **declaration, not a knob**: the compiler never scales content
2816    /// from it (spec-0023 "Out of scope"). It exists because the validation
2817    /// ladder's inverted floor gate needs to know which fights the content
2818    /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
2819    /// on its first attempt is reported as too easy for its billing. Marking it
2820    /// is how the author opts into that scrutiny; the alternative — inferring
2821    /// "elite" from how tuned a stack looks — is exactly the downstream folklore
2822    /// CLAUDE.md's no-hack rule forbids.
2823    ///
2824    /// **It does reach emission in exactly one place** (spec-0016 §1): in a
2825    /// campaign with a `bonfire`, a billed `elite`/
2826    /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
2827    /// rest *while it is still standing* — deleted and re-seated at full count
2828    /// and full health, so chipping it down one life at a time is never a path.
2829    /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
2830    /// `respawns_on_rest` at once.
2831    #[serde(default, skip_serializing_if = "Option::is_none")]
2832    pub tier: Option<EncounterTier>,
2833}
2834
2835/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
2836/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
2837/// — by one emission site: a bonfire refreshes a billed wave that is still
2838/// standing. Nothing about the encounter itself is scaled from it.
2839#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2840#[serde(rename_all = "kebab-case")]
2841pub enum EncounterTier {
2842    /// Trash / pressure. No floor expectation: a bot that wins it cold proves
2843    /// nothing either way. The default.
2844    #[default]
2845    Ordinary,
2846    /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
2847    /// and spawn-and-unleash vocabulary).
2848    Elite,
2849    /// A campaign's named fight. Same floor rule as `elite`; the distinction is
2850    /// for the run report a human reads.
2851    Boss,
2852}
2853
2854impl EncounterTier {
2855    /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
2856    pub fn token(self) -> &'static str {
2857        match self {
2858            EncounterTier::Ordinary => "ordinary",
2859            EncounterTier::Elite => "elite",
2860            EncounterTier::Boss => "boss",
2861        }
2862    }
2863
2864    /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
2865    /// content bills as hard carries an expectation the bot can measure; an
2866    /// ordinary one does not.
2867    pub fn has_floor_expectation(self) -> bool {
2868        matches!(self, EncounterTier::Elite | EncounterTier::Boss)
2869    }
2870}
2871
2872/// Where a wave's mobs materialize (spec-0016 §6).
2873#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2874#[serde(rename_all = "kebab-case")]
2875pub enum WaveSummon {
2876    /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
2877    /// behaviour and the default.
2878    Anchor,
2879    /// **Spirit-summoned at the edge of perception**:
2880    /// each mob appears on the ring at its own `attributes.follow_range` from
2881    /// the wave `anchor`, so it acquires a target the instant it exists and
2882    /// closes under pure native AI. Species without patrol AI never march a
2883    /// lane; this is what they do instead — never a spawn on top of the party,
2884    /// never a mob that brushes past.
2885    ///
2886    /// With this mode the wave `anchor` is the **defended point** (what the ring
2887    /// is drawn around), not the spawn point. Each mob stack must declare its
2888    /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
2889    /// never guessed from a vanilla defaults table the compiler cannot verify.
2890    AggroEdge,
2891}
2892
2893/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
2894/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
2895/// (`docs/notes/td-routing-spike.md`).
2896///
2897/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
2898/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
2899/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
2900/// From that instant the mob is a plain native hostile. "Combat preempts
2901/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
2902/// no target — so the owner's rule (march while distant, fight with NATIVE AI
2903/// once aggroed, never brush past) falls out of the primitive unforced.
2904///
2905/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
2906/// pillager must keep its crossbow (`DW0384`).
2907#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2908#[serde(deny_unknown_fields)]
2909pub struct WaveLane {
2910    /// The lane polyline, in march order: anchors some area's prefab provides.
2911    /// At least one; consecutive legs (counting the wave `anchor` as the origin)
2912    /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
2913    /// within 10 blocks of arrival, so a tighter lane is a lane the engine
2914    /// quietly stops following (`DW0386`).
2915    pub waypoints: Vec<AnchorId>,
2916    /// The release radius, in blocks: the compiler sets every lane mob's
2917    /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
2918    /// at the same distance. The two MUST be equal — a patrolling raider that
2919    /// targets a player it cannot engage holds ground instead of marching, so a
2920    /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
2921    pub aggro_radius: u32,
2922}
2923
2924/// One mob stack in a [`Wave`].
2925#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2926#[serde(deny_unknown_fields)]
2927pub struct WaveMob {
2928    /// Vanilla entity id, validated against the pinned 1.21.11 registry.
2929    pub entity: String,
2930    /// How many to spawn.
2931    pub count: u32,
2932    /// Optional custom name (shown above the mob).
2933    #[serde(default, skip_serializing_if = "Option::is_none")]
2934    pub name: Option<String>,
2935    /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
2936    /// components. Enables e.g. a weakened live warden as a survivable stealth
2937    /// threat. Omitted = vanilla defaults.
2938    #[serde(default, skip_serializing_if = "Option::is_none")]
2939    pub attributes: Option<MobAttributes>,
2940    /// Optional permanent, ambient status effects (DSL v0.4).
2941    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2942    pub effects: Vec<MobEffect>,
2943    /// Optional worn/held equipment (DSL v0.6). A helmet is the
2944    /// sanctioned fix for daylight-burning undead — never
2945    /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
2946    /// (`DW0143`, the give-item family); every emitted slot carries drop
2947    /// chance 0 so players can never farm wave gear (no-grind constitution).
2948    #[serde(default, skip_serializing_if = "Option::is_none")]
2949    pub equipment: Option<MobEquipment>,
2950    /// What this mob leaves behind when it dies (DSL v0.9). Only an `elite`/`boss` wave may declare it (`DW0491`)
2951    /// — an ordinary mob's kit is never farmable. Empty = drop chance 0 on
2952    /// every slot.
2953    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2954    pub drops: Vec<MobDrop>,
2955}
2956
2957/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
2958/// id for the matching vanilla equipment slot; an unset slot stays empty —
2959/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
2960/// wither-skeleton sword) still applies unless overridden. Emitted as the
2961/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
2962/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
2963#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2964#[serde(deny_unknown_fields)]
2965pub struct MobEquipment {
2966    /// Head slot.
2967    #[serde(default, skip_serializing_if = "Option::is_none")]
2968    pub head: Option<EquipItem>,
2969    /// Chest slot.
2970    #[serde(default, skip_serializing_if = "Option::is_none")]
2971    pub chest: Option<EquipItem>,
2972    /// Legs slot.
2973    #[serde(default, skip_serializing_if = "Option::is_none")]
2974    pub legs: Option<EquipItem>,
2975    /// Feet slot.
2976    #[serde(default, skip_serializing_if = "Option::is_none")]
2977    pub feet: Option<EquipItem>,
2978    /// Main-hand slot. Overrides the compiler's armed-mob default.
2979    #[serde(default, skip_serializing_if = "Option::is_none")]
2980    pub main_hand: Option<EquipItem>,
2981    /// Off-hand slot.
2982    #[serde(default, skip_serializing_if = "Option::is_none")]
2983    pub off_hand: Option<EquipItem>,
2984}
2985
2986impl MobEquipment {
2987    /// Every slot as `(dsl_field_name, piece)`, in the fixed schema order —
2988    /// the single iteration source for validation paths and emission.
2989    pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); 6] {
2990        [
2991            ("head", self.head.as_ref()),
2992            ("chest", self.chest.as_ref()),
2993            ("legs", self.legs.as_ref()),
2994            ("feet", self.feet.as_ref()),
2995            ("main_hand", self.main_hand.as_ref()),
2996            ("off_hand", self.off_hand.as_ref()),
2997        ]
2998    }
2999
3000    /// The piece this equipment declaration puts in `slot`, if any. The single
3001    /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
3002    /// actually wears (`DW0490`).
3003    pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
3004        match slot {
3005            EquipSlot::Head => self.head.as_ref(),
3006            EquipSlot::Chest => self.chest.as_ref(),
3007            EquipSlot::Legs => self.legs.as_ref(),
3008            EquipSlot::Feet => self.feet.as_ref(),
3009            EquipSlot::MainHand => self.main_hand.as_ref(),
3010            EquipSlot::OffHand => self.off_hand.as_ref(),
3011        }
3012    }
3013}
3014
3015/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
3016/// fills it (DSL v0.9). The DSL name and the summon-NBT key differ
3017/// (`main_hand` vs `mainhand`), so both live here and nowhere else.
3018#[derive(
3019    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
3020)]
3021#[serde(rename_all = "snake_case")]
3022pub enum EquipSlot {
3023    /// Head slot.
3024    Head,
3025    /// Chest slot.
3026    Chest,
3027    /// Legs slot.
3028    Legs,
3029    /// Feet slot.
3030    Feet,
3031    /// Main-hand slot.
3032    MainHand,
3033    /// Off-hand slot.
3034    OffHand,
3035}
3036
3037impl EquipSlot {
3038    /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
3039    pub fn field(self) -> &'static str {
3040        match self {
3041            EquipSlot::Head => "head",
3042            EquipSlot::Chest => "chest",
3043            EquipSlot::Legs => "legs",
3044            EquipSlot::Feet => "feet",
3045            EquipSlot::MainHand => "main_hand",
3046            EquipSlot::OffHand => "off_hand",
3047        }
3048    }
3049
3050    /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`).
3051    pub fn nbt(self) -> &'static str {
3052        match self {
3053            EquipSlot::Head => "head",
3054            EquipSlot::Chest => "chest",
3055            EquipSlot::Legs => "legs",
3056            EquipSlot::Feet => "feet",
3057            EquipSlot::MainHand => "mainhand",
3058            EquipSlot::OffHand => "offhand",
3059        }
3060    }
3061}
3062
3063/// One declared drop of an elite or boss (DSL v0.9).
3064///
3065/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
3066/// usually one piece and never automatically everything. Two forms, told apart
3067/// by which field is present:
3068///
3069/// ```json
3070/// { "slot": "main_hand" }                                  // its axe
3071/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
3072/// ```
3073///
3074/// A `slot` entry must name a slot the same entity's `equipment` really fills
3075/// (`DW0490`) — the drop is the piece the player has been *looking at* through
3076/// the whole fight, so the two declarations cannot disagree. An `item` entry is
3077/// a token the fight *yields* rather than wears, and rides the entity's own
3078/// death loot table.
3079///
3080/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
3081/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
3082#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3083#[serde(untagged)]
3084pub enum MobDrop {
3085    /// A worn/held piece, named by its equipment slot.
3086    Slot(SlotDrop),
3087    /// A quest token the fight yields (not worn).
3088    Item(ItemDrop),
3089}
3090
3091/// The worn-piece form of a [`MobDrop`].
3092#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3093#[serde(deny_unknown_fields)]
3094pub struct SlotDrop {
3095    /// The equipment slot whose piece drops. Must be filled by the entity's own
3096    /// `equipment` declaration (`DW0490`).
3097    pub slot: EquipSlot,
3098}
3099
3100/// The quest-token form of a [`MobDrop`].
3101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3102#[serde(deny_unknown_fields)]
3103pub struct ItemDrop {
3104    /// Vanilla item id, validated against the pinned 1.21.11 registry
3105    /// (`DW0143`, the give-item family).
3106    pub item: String,
3107    /// Display name the dropped stack carries as a `custom_name` component.
3108    /// Player-visible, so it enters the l10n string inventory and translates
3109    /// like any other line.
3110    #[serde(default, skip_serializing_if = "Option::is_none")]
3111    pub name: Option<String>,
3112}
3113
3114impl MobDrop {
3115    /// The equipment slot this entry names, or `None` for a quest-item drop.
3116    pub fn slot(&self) -> Option<EquipSlot> {
3117        match self {
3118            MobDrop::Slot(s) => Some(s.slot),
3119            MobDrop::Item(_) => None,
3120        }
3121    }
3122
3123    /// The item id this entry names, or `None` for a worn-piece drop.
3124    pub fn item(&self) -> Option<&str> {
3125        match self {
3126            MobDrop::Slot(_) => None,
3127            MobDrop::Item(i) => Some(&i.item),
3128        }
3129    }
3130
3131    /// The declared display name of a quest-item drop, when set.
3132    pub fn name(&self) -> Option<&String> {
3133        match self {
3134            MobDrop::Slot(_) => None,
3135            MobDrop::Item(i) => i.name.as_ref(),
3136        }
3137    }
3138
3139    /// Mutable access to the display name — the l10n traversal's hook.
3140    pub fn name_mut(&mut self) -> Option<&mut String> {
3141        match self {
3142            MobDrop::Slot(_) => None,
3143            MobDrop::Item(i) => i.name.as_mut(),
3144        }
3145    }
3146}
3147
3148/// One equipped item: either a bare item id, or an id carrying enchantments.
3149///
3150/// The plain form is the common case and stays a plain JSON string, which is
3151/// what keeps every campaign written before enchantments existed byte-identical
3152/// on re-serialisation:
3153///
3154/// ```json
3155/// "main_hand": "minecraft:netherite_sword"
3156/// "head": { "item": "minecraft:netherite_helmet",
3157///           "enchantments": { "minecraft:protection": 4 } }
3158/// ```
3159#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3160#[serde(untagged)]
3161pub enum EquipItem {
3162    /// A bare item id — no enchantments.
3163    Plain(String),
3164    /// An item id plus its enchantments.
3165    Enchanted(EnchantedItem),
3166}
3167
3168impl EquipItem {
3169    /// The item id, whichever form was authored.
3170    pub fn item(&self) -> &str {
3171        match self {
3172            EquipItem::Plain(s) => s,
3173            EquipItem::Enchanted(e) => &e.item,
3174        }
3175    }
3176
3177    /// The enchantments on this piece — empty for the plain form. `BTreeMap`
3178    /// ordered, so emission order is the id order and never hash order
3179    /// (ADR-0006).
3180    pub fn enchantments(&self) -> &BTreeMap<String, u32> {
3181        static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
3182            std::sync::LazyLock::new(BTreeMap::new);
3183        match self {
3184            EquipItem::Plain(_) => &EMPTY,
3185            EquipItem::Enchanted(e) => &e.enchantments,
3186        }
3187    }
3188}
3189
3190/// The enchanted form of [`EquipItem`].
3191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3192#[serde(deny_unknown_fields)]
3193pub struct EnchantedItem {
3194    /// Item id (e.g. `minecraft:netherite_chestplate`).
3195    pub item: String,
3196    /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
3197    /// the 1.21 `minecraft:enchantments` item component.
3198    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3199    pub enchantments: BTreeMap<String, u32>,
3200}
3201
3202/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
3203/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
3204#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3205#[serde(deny_unknown_fields)]
3206pub struct MobAttributes {
3207    /// `minecraft:max_health` base value.
3208    #[serde(default, skip_serializing_if = "Option::is_none")]
3209    pub max_health: Option<f64>,
3210    /// `minecraft:attack_damage` base value.
3211    #[serde(default, skip_serializing_if = "Option::is_none")]
3212    pub attack_damage: Option<f64>,
3213    /// `minecraft:movement_speed` base value.
3214    #[serde(default, skip_serializing_if = "Option::is_none")]
3215    pub movement_speed: Option<f64>,
3216    /// `minecraft:follow_range` base value.
3217    #[serde(default, skip_serializing_if = "Option::is_none")]
3218    pub follow_range: Option<f64>,
3219}
3220
3221/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
3222/// non-expiring `effect give`.
3223#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3224#[serde(deny_unknown_fields)]
3225pub struct MobEffect {
3226    /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
3227    /// pinned registry (`DW0192`).
3228    pub effect: String,
3229    /// Amplifier (0 = level I).
3230    pub amplifier: u32,
3231}
3232
3233/// One expanded quest.
3234#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3235#[serde(deny_unknown_fields)]
3236pub struct Quest {
3237    /// Quest id (matches a stage-4 planned quest).
3238    pub id: QuestId,
3239    /// What starts the quest.
3240    pub trigger: Trigger,
3241    /// Ordered objectives (intra-quest DAG via `after`).
3242    pub objectives: Vec<Objective>,
3243    /// Effects fired when a given objective completes.
3244    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3245    pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
3246    /// Effects fired when the whole quest completes.
3247    pub on_complete: Vec<QuestEffect>,
3248    /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
3249    /// live during this quest — spawned and not explicitly removed — where it
3250    /// stands, what it is doing, and what its right-click offers *for this
3251    /// quest's duration*.
3252    ///
3253    /// The ledger exists because an NPC's dialogue used to be one tree for the
3254    /// whole campaign: after the climactic escape a crew member still offered
3255    /// "Tell me what he is." — a premise question absurd once the story moved on.
3256    /// Declaring the scene per quest makes the compiler able to check it
3257    /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
3258    /// emitted right-click shows the root this quest declares, so a stale root
3259    /// retires *because the ledger says so*, not because an author remembered a
3260    /// flag.
3261    ///
3262    /// Every NPC live during the quest owes an entry (`DW0460`).
3263    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3264    pub cast: BTreeMap<NpcId, CastEntry>,
3265    /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
3266    /// `DW0481`).
3267    #[serde(default, skip_serializing_if = "Option::is_none")]
3268    pub happening: Option<Happening>,
3269}
3270
3271/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
3272///
3273/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
3274/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
3275/// per-branch casts, each gated by the flags that select its branch (spec-0020
3276/// proof 4: where the effect history is branch-dependent, a single flat
3277/// declaration cannot hold on every reachable branch, and `DW0462` says so).
3278#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3279#[serde(untagged)]
3280pub enum CastEntry {
3281    /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
3282    /// placement whose `at` is that keyword and which carries no `doing` or
3283    /// `dialogue` — a character who is not in the world has no business and
3284    /// answers no right-click.
3285    Absent(CastAbsence),
3286    /// A single placement that must hold on every reachable branch.
3287    Placement(CastPlacement),
3288    /// Per-branch placements (spec-0020 proof 4). Each carries the
3289    /// `requires_flags`/`forbids_flags` that select its branch.
3290    Branches(Vec<CastPlacement>),
3291}
3292
3293impl CastEntry {
3294    /// Every placement this entry declares, in declared order. The bare-keyword
3295    /// form yields none — there is no scene to check.
3296    pub fn placements(&self) -> Vec<&CastPlacement> {
3297        match self {
3298            CastEntry::Absent(_) => Vec::new(),
3299            CastEntry::Placement(p) => vec![p],
3300            CastEntry::Branches(ps) => ps.iter().collect(),
3301        }
3302    }
3303
3304    /// Every placement this entry declares, mutably (the l10n traversal).
3305    pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
3306        match self {
3307            CastEntry::Absent(_) => Vec::new(),
3308            CastEntry::Placement(p) => vec![p],
3309            CastEntry::Branches(ps) => ps.iter_mut().collect(),
3310        }
3311    }
3312
3313    /// The bare-keyword absence this entry declares, if it is that form.
3314    pub fn absence(&self) -> Option<CastAbsence> {
3315        match self {
3316            CastEntry::Absent(a) => Some(*a),
3317            _ => None,
3318        }
3319    }
3320
3321    /// True if this entry declares the NPC out of the world entirely — the bare
3322    /// keyword, or every placement's `at` being a keyword.
3323    pub fn is_absent(&self) -> bool {
3324        match self {
3325            CastEntry::Absent(_) => true,
3326            CastEntry::Placement(p) => p.at.absence().is_some(),
3327            CastEntry::Branches(ps) => {
3328                !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
3329            }
3330        }
3331    }
3332}
3333
3334/// A declared absence: the NPC is deliberately not in the world.
3335#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3336#[serde(rename_all = "kebab-case")]
3337pub enum CastAbsence {
3338    /// Removed from the story for good (must match a `despawn-npc` with no later
3339    /// `spawn-npc`).
3340    Dead,
3341    /// Not in the world for this quest, but may return (must match a
3342    /// `despawn-npc`).
3343    Offstage,
3344}
3345
3346impl CastAbsence {
3347    /// The authored keyword (`dead` / `offstage`).
3348    pub fn token(self) -> &'static str {
3349        match self {
3350            CastAbsence::Dead => "dead",
3351            CastAbsence::Offstage => "offstage",
3352        }
3353    }
3354}
3355
3356/// Where a cast entry puts an NPC: a prefab anchor, or a declared absence.
3357#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3358#[serde(untagged)]
3359pub enum CastPlace {
3360    /// `"dead"` or `"offstage"` — explicitly not in the world.
3361    Absent(CastAbsence),
3362    /// The anchor the NPC stands on for this quest's duration. Must equal the
3363    /// position the effect history actually produces (`DW0461`): declaring an
3364    /// anchor does not teleport anybody.
3365    Anchor(AnchorId),
3366}
3367
3368impl CastPlace {
3369    /// The anchor this place names, if it is an anchor.
3370    pub fn anchor(&self) -> Option<&AnchorId> {
3371        match self {
3372            CastPlace::Anchor(a) => Some(a),
3373            CastPlace::Absent(_) => None,
3374        }
3375    }
3376
3377    /// The declared absence, if this place is one.
3378    pub fn absence(&self) -> Option<CastAbsence> {
3379        match self {
3380            CastPlace::Absent(a) => Some(*a),
3381            CastPlace::Anchor(_) => None,
3382        }
3383    }
3384
3385    /// The authored token, for diagnostics.
3386    pub fn token(&self) -> &str {
3387        match self {
3388            CastPlace::Absent(a) => a.token(),
3389            CastPlace::Anchor(a) => a.as_str(),
3390        }
3391    }
3392}
3393
3394/// One declared scene for one NPC in one quest.
3395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3396#[serde(deny_unknown_fields)]
3397pub struct CastPlacement {
3398    /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
3399    pub at: CastPlace,
3400    /// What the character is *doing* — free prose, never machine-checked.
3401    ///
3402    /// This field is the forcing function, which is the whole reason it is
3403    /// required (`DW0463`) despite being unverifiable: you cannot fill it in
3404    /// without deciding the character's business in this beat, and stage 6
3405    /// receives it as the context the NPC's lines are written against.
3406    #[serde(default, skip_serializing_if = "Option::is_none")]
3407    pub doing: Option<String>,
3408    /// What right-click offers during this quest. Required for an on-stage
3409    /// placement (`DW0463`) — including the explicit `"none"`.
3410    #[serde(default, skip_serializing_if = "Option::is_none")]
3411    pub dialogue: Option<CastDialogue>,
3412    /// Branch gate (per-branch casts): this placement describes the world only
3413    /// once every listed flag is set. Mirrors an option's `requires_flags`.
3414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3415    pub requires_flags: Vec<FlagId>,
3416    /// Negative branch gate: this placement describes the world only while no
3417    /// listed flag is set. Mirrors an option's `forbids_flags`.
3418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3419    pub forbids_flags: Vec<FlagId>,
3420    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
3421    /// hold for this gate to be open. The third field of the one gate, carried by
3422    /// every gate consumer — never by the verb that first wanted it. Default
3423    /// empty, so a pre-0.10 campaign is byte-identical.
3424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3425    pub requires_state: Vec<StateCompare>,
3426}
3427
3428/// What an NPC's right-click offers for a quest's duration.
3429///
3430/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
3431/// `{"barks": […]}` pool, then a dialogue root id.
3432#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3433#[serde(untagged)]
3434pub enum CastDialogue {
3435    /// A keyword: `"none"` or `"unchanged"`.
3436    Keyword(CastDialogueKeyword),
3437    /// A bark pool: right-click yields one inconsequential in-character line —
3438    /// no tree, no options, no consequences.
3439    Barks(CastBarks),
3440    /// A dialogue root id: right-click opens this node of the NPC's stage-6
3441    /// tree. Must be a node of *that* NPC's tree (`DW0464`).
3442    Root(DialogueId),
3443}
3444
3445/// The keyword forms of [`CastDialogue`].
3446#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3447#[serde(rename_all = "kebab-case")]
3448pub enum CastDialogueKeyword {
3449    /// Genuinely no reaction: the right-click is recorded and consumed, and
3450    /// nothing opens. Legal, but a last resort — if a body is clickable, the
3451    /// world should answer; prefer a bark.
3452    None,
3453    /// Carry forward whatever this NPC's dialogue was at its **previous**
3454    /// appearance in the quest-DAG ordering.
3455    ///
3456    /// The point is that carrying dialogue forward is a *conscious, declared
3457    /// act*. It is never an implicit default — an omitted `dialogue` is
3458    /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
3459    /// intent without re-spelling a root id that then drifts out of sync. It
3460    /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
3461    /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
3462    ///
3463    /// Emission is a **no-op** — no root swap is emitted for that NPC at that
3464    /// quest — which is what makes the sugar cheap and byte-stable.
3465    Unchanged,
3466}
3467
3468/// A bark pool: inconsequential in-character lines, cycled deterministically.
3469#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3470#[serde(deny_unknown_fields)]
3471pub struct CastBarks {
3472    /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
3473    /// they enter the l10n inventory like any narrate text.
3474    pub barks: Vec<String>,
3475}
3476
3477/// What triggers a quest.
3478#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3479#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3480pub enum Trigger {
3481    /// Fires when the campaign starts.
3482    CampaignStart,
3483    /// Fires when another quest completes.
3484    QuestComplete {
3485        /// The prerequisite quest.
3486        quest: QuestId,
3487    },
3488}
3489
3490/// A quest objective.
3491///
3492/// Every variant may carry `requires_flags`:
3493/// flag-gated activation, satisfied only once each referenced flag has been set
3494/// by a `set-flag` effect.
3495#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3496#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3497pub enum Objective {
3498    /// Completed by a dialogue option's `complete-objective` effect.
3499    TalkTo {
3500        /// Objective id.
3501        id: ObjectiveId,
3502        /// Short player-facing objective name (v0.3, optional).
3503        #[serde(default, skip_serializing_if = "Option::is_none")]
3504        title: Option<String>,
3505        /// One-line location/direction hint (v0.3, optional).
3506        #[serde(default, skip_serializing_if = "Option::is_none")]
3507        hint: Option<String>,
3508        /// The NPC to talk to.
3509        npc: NpcId,
3510        /// Prerequisite objectives (intra-quest ordering).
3511        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3512        after: Vec<ObjectiveId>,
3513        /// Flags that must be set before this objective activates (v0.3).
3514        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3515        requires_flags: Vec<FlagId>,
3516        /// Negative flag gate (DSL v0.6): the
3517        /// objective is suppressed (cannot activate or complete) while ANY listed
3518        /// flag is set for the player — the dual of `requires_flags`.
3519        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3520        forbids_flags: Vec<FlagId>,
3521        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3522        /// must hold for this gate to be open. The third field of the one gate,
3523        /// carried by every gate consumer — never by the verb that first wanted
3524        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3525        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3526        requires_state: Vec<StateCompare>,
3527        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3528        /// bot should traverse sneaking (sprint disabled). Emitted into
3529        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3530        /// hint; no datapack effect.
3531        #[serde(default, skip_serializing_if = "is_false")]
3532        stealth: bool,
3533        /// What this objective does to the story (DSL v0.8, spec-0025; required
3534        /// at 0.8.0, `DW0481`).
3535        #[serde(default, skip_serializing_if = "Option::is_none")]
3536        happening: Option<Happening>,
3537    },
3538    /// Completed by reaching an anchor once prerequisites are met.
3539    ReachAnchor {
3540        /// Objective id.
3541        id: ObjectiveId,
3542        /// Short player-facing objective name (v0.3, optional).
3543        #[serde(default, skip_serializing_if = "Option::is_none")]
3544        title: Option<String>,
3545        /// One-line location/direction hint (v0.3, optional).
3546        #[serde(default, skip_serializing_if = "Option::is_none")]
3547        hint: Option<String>,
3548        /// The anchor to reach.
3549        anchor: AnchorId,
3550        /// Completion radius (blocks).
3551        radius: u32,
3552        /// Prerequisite objectives (intra-quest ordering).
3553        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3554        after: Vec<ObjectiveId>,
3555        /// Flags that must be set before this objective activates (v0.3).
3556        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3557        requires_flags: Vec<FlagId>,
3558        /// Negative flag gate (DSL v0.6): the
3559        /// objective is suppressed (cannot activate or complete) while ANY listed
3560        /// flag is set for the player — the dual of `requires_flags`.
3561        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3562        forbids_flags: Vec<FlagId>,
3563        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3564        /// must hold for this gate to be open. The third field of the one gate,
3565        /// carried by every gate consumer — never by the verb that first wanted
3566        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3567        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3568        requires_state: Vec<StateCompare>,
3569        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3570        /// bot should traverse sneaking (sprint disabled). Emitted into
3571        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3572        /// hint; no datapack effect.
3573        #[serde(default, skip_serializing_if = "is_false")]
3574        stealth: bool,
3575        /// What this objective does to the story (DSL v0.8, spec-0025; required
3576        /// at 0.8.0, `DW0481`).
3577        #[serde(default, skip_serializing_if = "Option::is_none")]
3578        happening: Option<Happening>,
3579    },
3580    /// Completed when the referenced wave is fully slain (v0.3).
3581    Kill {
3582        /// Objective id.
3583        id: ObjectiveId,
3584        /// Short player-facing objective name (v0.3, optional).
3585        #[serde(default, skip_serializing_if = "Option::is_none")]
3586        title: Option<String>,
3587        /// One-line location/direction hint (v0.3, optional).
3588        #[serde(default, skip_serializing_if = "Option::is_none")]
3589        hint: Option<String>,
3590        /// The wave (stage-5 `waves` ref) whose mobs must be slain.
3591        wave: WaveId,
3592        /// Prerequisite objectives.
3593        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3594        after: Vec<ObjectiveId>,
3595        /// Flags that must be set before this objective activates (v0.3).
3596        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3597        requires_flags: Vec<FlagId>,
3598        /// Negative flag gate (DSL v0.6): the
3599        /// objective is suppressed (cannot activate or complete) while ANY listed
3600        /// flag is set for the player — the dual of `requires_flags`.
3601        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3602        forbids_flags: Vec<FlagId>,
3603        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3604        /// must hold for this gate to be open. The third field of the one gate,
3605        /// carried by every gate consumer — never by the verb that first wanted
3606        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3607        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3608        requires_state: Vec<StateCompare>,
3609        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3610        /// bot should traverse sneaking (sprint disabled). Emitted into
3611        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3612        /// hint; no datapack effect.
3613        #[serde(default, skip_serializing_if = "is_false")]
3614        stealth: bool,
3615        /// What this objective does to the story (DSL v0.8, spec-0025; required
3616        /// at 0.8.0, `DW0481`).
3617        #[serde(default, skip_serializing_if = "Option::is_none")]
3618        happening: Option<Happening>,
3619    },
3620    /// Completed when `count` of `item` have been collected (v0.3).
3621    ///
3622    /// The items are provided in a container: the compiler's own chest at
3623    /// `anchor` by default, or — since DSL v0.8 — the prefab's existing
3624    /// chest/barrel at [`Objective::Collect::container`], optionally carrying an
3625    /// [`Objective::Collect::item_name`] and padded to read full with
3626    /// [`Objective::Collect::fill_count`].
3627    Collect {
3628        /// Objective id.
3629        id: ObjectiveId,
3630        /// Short player-facing objective name (v0.3, optional).
3631        #[serde(default, skip_serializing_if = "Option::is_none")]
3632        title: Option<String>,
3633        /// One-line location/direction hint (v0.3, optional).
3634        #[serde(default, skip_serializing_if = "Option::is_none")]
3635        hint: Option<String>,
3636        /// Vanilla item id to collect (validated against the registry).
3637        item: String,
3638        /// How many are required.
3639        count: u32,
3640        /// The anchor items are provided at (chest / pickup).
3641        anchor: AnchorId,
3642        /// **Adopt the container the prefab already placed**: the anchor whose
3643        /// assembled-world cell holds
3644        /// a `chest` / `trapped_chest` / `barrel` this collect fills instead of
3645        /// conjuring its own chest at [`Objective::Collect::anchor`].
3646        ///
3647        /// Same division of labour a `loot` entry and a trap's dispenser already
3648        /// keep with the prefab: furniture belongs in the piece. A beach camp's
3649        /// barrel is scenery the player has been walking past since minute one —
3650        /// having the compiler `setblock` a *second*, floating chest beside it to
3651        /// hold the quest item is exactly the downstream workaround the no-hack
3652        /// rule forbids. A `container` whose cell holds no container is a build
3653        /// error (`DW0438`), never a silent fill into a wall.
3654        ///
3655        /// The critical-path step's position follows the container (the bot opens
3656        /// *this* block), and no chest is placed at `anchor` when it is set.
3657        #[serde(default, skip_serializing_if = "Option::is_none")]
3658        container: Option<AnchorId>,
3659        /// **The item comes off a body, not out of a box**: the wave whose
3660        /// declared `drops[]` yield
3661        /// this objective's item. No container is placed — not the compiler's own
3662        /// chest at `anchor`, not a prefab one — and `container` is therefore
3663        /// mutually exclusive with it (`DW0100`-adjacent; `DW0492`).
3664        ///
3665        /// This is what makes "kill the boss → pick up its key → open the door"
3666        /// a *proved* chain rather than an authoring intention. The compiler
3667        /// requires (a) that the named wave really declares an `{item}` drop of
3668        /// this item (`DW0492`), and (b) that a `kill` objective for that wave
3669        /// precedes this collect in the objective graph (`DW0493`). The existing
3670        /// flow machinery then carries the ordering the rest of the way: the
3671        /// door's `requires_flags` hangs off this collect exactly as it would off
3672        /// a chest one.
3673        ///
3674        /// **Waves only.** An actor's death is not observable by any objective —
3675        /// there is no vanilla-side signal the flow machinery could consume — so
3676        /// an actor-gated collect would be an unprovable claim, and per the
3677        /// no-hack doctrine it is excluded rather than approximated. An actor may
3678        /// still declare `drops[]`; those drops just cannot gate a quest.
3679        #[serde(default, skip_serializing_if = "Option::is_none")]
3680        dropped_by: Option<WaveId>,
3681        /// Display name for the collected item (DSL v0.8), emitted as the vanilla `custom_name` item component.
3682        ///
3683        /// A quest item is a *named thing* in the story ("Cheese", "Tide
3684        /// Ledger"), and a player who opens the barrel must read that name — an
3685        /// unnamed `minecraft:pumpkin_pie` says nothing about what the quest asked
3686        /// for. Player-visible, so it enters the l10n string inventory
3687        /// (`obj.<quest>.<obj>.item_name`) and translates like any other line.
3688        ///
3689        /// Naming changes nothing about adjudication: the completion advancement
3690        /// and the per-tick held check both match on the ITEM ID, which a named
3691        /// stack still carries.
3692        #[serde(default, skip_serializing_if = "Option::is_none")]
3693        item_name: Option<String>,
3694        /// Padding stacks that make the container **read full**. Default `0` =
3695        /// the single required
3696        /// stack and nothing else.
3697        ///
3698        /// A barrel of cheese that opens on one lonely wheel reads as a bug, and
3699        /// vanilla's notion of "full" is *occupied slots*, not stack size — so
3700        /// this counts SLOTS: the objective's own stack lands in `container.0` and
3701        /// each padding stack repeats it in `container.1`, `container.2`, … Slot
3702        /// assignment is positional and total, the same determinism story `loot`
3703        /// tells (ADR-0006): no RNG, no loot tables, nothing to reseed.
3704        ///
3705        /// The padding is the same item, so taking the whole barrel still
3706        /// completes the objective and never over- or under-counts it.
3707        #[serde(default, skip_serializing_if = "is_zero")]
3708        fill_count: u32,
3709        /// Prerequisite objectives.
3710        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3711        after: Vec<ObjectiveId>,
3712        /// Flags that must be set before this objective activates (v0.3).
3713        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3714        requires_flags: Vec<FlagId>,
3715        /// Negative flag gate (DSL v0.6): the
3716        /// objective is suppressed (cannot activate or complete) while ANY listed
3717        /// flag is set for the player — the dual of `requires_flags`.
3718        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3719        forbids_flags: Vec<FlagId>,
3720        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3721        /// must hold for this gate to be open. The third field of the one gate,
3722        /// carried by every gate consumer — never by the verb that first wanted
3723        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3724        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3725        requires_state: Vec<StateCompare>,
3726        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3727        /// bot should traverse sneaking (sprint disabled). Emitted into
3728        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3729        /// hint; no datapack effect.
3730        #[serde(default, skip_serializing_if = "is_false")]
3731        stealth: bool,
3732        /// What this objective does to the story (DSL v0.8, spec-0025; required
3733        /// at 0.8.0, `DW0481`).
3734        #[serde(default, skip_serializing_if = "Option::is_none")]
3735        happening: Option<Happening>,
3736    },
3737    /// Completed by interacting with an entity at `anchor`; if `requires_item` is
3738    /// set, the item must be **held in the main hand** (v0.3; held semantics since
3739    /// DSL v0.7 — see [`Objective::Interact::requires_item`]).
3740    Interact {
3741        /// Objective id.
3742        id: ObjectiveId,
3743        /// Short player-facing objective name (v0.3, optional).
3744        #[serde(default, skip_serializing_if = "Option::is_none")]
3745        title: Option<String>,
3746        /// One-line location/direction hint (v0.3, optional).
3747        #[serde(default, skip_serializing_if = "Option::is_none")]
3748        hint: Option<String>,
3749        /// The anchor the interaction entity stands at.
3750        anchor: AnchorId,
3751        /// Item the player must be **holding in the main hand** for the
3752        /// interaction to complete (optional).
3753        ///
3754        /// Held, not merely possessed: presenting the
3755        /// item IS the action — a player who right-clicks a sleeping giant with a
3756        /// sharpened stake buried in their backpack has not stabbed anything.
3757        /// Before this ruling the gate read the whole inventory, which made every
3758        /// `requires_item` interaction fire the moment the item was picked up
3759        /// anywhere, whatever the player was actually doing with their hands.
3760        #[serde(default, skip_serializing_if = "Option::is_none")]
3761        requires_item: Option<String>,
3762        /// Diegetic feedback for a click that arrives without the required item in
3763        /// hand (DSL v0.7): narrated to that player in
3764        /// chat instead of the silence the gate used to answer with. Requires
3765        /// `requires_item` (`DW0437`).
3766        ///
3767        /// Only fires while the objective is genuinely open — same activation gate
3768        /// as the affordance itself — so a finished or not-yet-active interaction
3769        /// stays quiet.
3770        #[serde(default, skip_serializing_if = "Option::is_none")]
3771        missing_item_hint: Option<String>,
3772        /// Prop block that IS the interaction affordance (DSL v0.4, spec-0008
3773        /// §2): the compiler `setblock`s it at the anchor on activation (exactly
3774        /// as `collect` uses a real chest). Omitted = the glowing-lantern
3775        /// hologram marker (the v0.3 fallback).
3776        #[serde(default, skip_serializing_if = "Option::is_none")]
3777        prop: Option<Prop>,
3778        /// Prerequisite objectives.
3779        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3780        after: Vec<ObjectiveId>,
3781        /// Flags that must be set before this objective activates (v0.3).
3782        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3783        requires_flags: Vec<FlagId>,
3784        /// Negative flag gate (DSL v0.6): the
3785        /// objective is suppressed (cannot activate or complete) while ANY listed
3786        /// flag is set for the player — the dual of `requires_flags`.
3787        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3788        forbids_flags: Vec<FlagId>,
3789        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3790        /// must hold for this gate to be open. The third field of the one gate,
3791        /// carried by every gate consumer — never by the verb that first wanted
3792        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3793        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3794        requires_state: Vec<StateCompare>,
3795        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3796        /// bot should traverse sneaking (sprint disabled). Emitted into
3797        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3798        /// hint; no datapack effect.
3799        #[serde(default, skip_serializing_if = "is_false")]
3800        stealth: bool,
3801        /// What this objective does to the story (DSL v0.8, spec-0025; required
3802        /// at 0.8.0, `DW0481`).
3803        #[serde(default, skip_serializing_if = "Option::is_none")]
3804        happening: Option<Happening>,
3805    },
3806}
3807
3808/// A prop block for an `interact` objective (DSL v0.4). The block is the
3809/// affordance the player interacts with; its id is validated against the pinned
3810/// 1.21.11 block registry (`DW0193`).
3811#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3812#[serde(deny_unknown_fields)]
3813pub struct Prop {
3814    /// Vanilla block id (e.g. `minecraft:lever`).
3815    pub block: String,
3816}
3817
3818// ---------------------------------------------------------------------------
3819// Stage 5 — scripted actors (DSL v0.6, spec-0014)
3820// ---------------------------------------------------------------------------
3821
3822/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
3823/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
3824/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
3825/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
3826/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
3827/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
3828/// by a real-AI twin with `unleash-actor`.
3829#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3830#[serde(deny_unknown_fields)]
3831pub struct Actor {
3832    /// Unique actor id (`actor/<kebab>`).
3833    pub id: ActorId,
3834    /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
3835    /// pinned 1.21.11 entity registry (`DW0173`).
3836    pub entity: String,
3837    /// Optional custom name shown above the puppet.
3838    #[serde(default, skip_serializing_if = "Option::is_none")]
3839    pub name: Option<String>,
3840    /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
3841    #[serde(default, skip_serializing_if = "Option::is_none")]
3842    pub skin: Option<NpcSkin>,
3843    /// The anchor the puppet is summoned on (resolved across areas, like an
3844    /// `open-gate` / `move-npc` destination).
3845    pub anchor: AnchorId,
3846    /// Initial facing (default `south`). The puppet spawns yawed this way.
3847    #[serde(default, skip_serializing_if = "Option::is_none")]
3848    pub facing: Option<Facing>,
3849    /// If `true`, the puppet is damageable (a tower-defense creep) but stays
3850    /// knockback-immune; default `false` (fully `Invulnerable`).
3851    #[serde(default, skip_serializing_if = "is_false")]
3852    pub vulnerable: bool,
3853    /// Gear the actor wears and holds, in the same shape a wave mob uses
3854    /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
3855    /// unleashed twin, so the dormant elite the player has been circling is
3856    /// visibly the same armoured thing that stands up. Drop chances are zero —
3857    /// wave gear and actor gear are never farmable (no-grind constitution).
3858    #[serde(default, skip_serializing_if = "Option::is_none")]
3859    pub equipment: Option<MobEquipment>,
3860    /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
3861    /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
3862    /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
3863    /// elite the party fights is the elite the author tuned; without it an actor
3864    /// was stuck at vanilla base values while every wave mob could be tuned,
3865    /// which is what blocked elite authoring. A `vulnerable` actor's
3866    /// knockback-immunity is emitted first and is not authorable.
3867    #[serde(default, skip_serializing_if = "Option::is_none")]
3868    pub attributes: Option<MobAttributes>,
3869    /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
3870    /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
3871    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
3872    ///
3873    /// A wave is not the only shape an elite takes. The set-piece souls fight —
3874    /// the armoured thing kneeling among the graves that stands up when you hit
3875    /// it — is an **actor**: staged by `spawn-actor`, given AI by
3876    /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
3877    /// this field the validation ladder's inverted floor gate could only see
3878    /// `waves[].tier`, so such a boss was *structurally invisible* to it and an
3879    /// empty finding list read as a pass while covering nothing.
3880    ///
3881    /// Like the wave field this is a **declaration, not a knob**: the compiler
3882    /// never scales an actor from it, and emission is unchanged whichever tier is
3883    /// declared. What it buys is scrutiny — the actor enters
3884    /// `validation/combat-plan.json`, and the compiler states, per tiered actor,
3885    /// whether the floor gate can measure it and why not when it cannot
3886    /// (`DW0477`).
3887    #[serde(default, skip_serializing_if = "Option::is_none")]
3888    pub tier: Option<EncounterTier>,
3889    /// What this actor leaves behind when a player kills it. Only an
3890    /// `elite`/`boss` actor may
3891    /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
3892    /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
3893    /// not to one of its two lifecycles. A `despawn-actor` strips the
3894    /// declaration off the body before removing it, so re-caging an elite (a
3895    /// souls re-seat) never scatters its axe.
3896    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3897    pub drops: Vec<MobDrop>,
3898    /// What this body can do when it moves (DSL v0.11, spec-0034) — the same
3899    /// [`BodyTraversal`] a stage-2 [`Npc`] carries, because traversal belongs to
3900    /// the body and not to the stage that declares it. Absent = the class the
3901    /// compiler derives from `entity` (or from `minecraft:mannequin` when `skin`
3902    /// is set).
3903    #[serde(default, skip_serializing_if = "Option::is_none")]
3904    pub traversal: Option<BodyTraversal>,
3905}
3906
3907/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
3908/// (MC: yaw 0 = +z/south).
3909#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3910#[serde(rename_all = "kebab-case")]
3911pub enum Facing {
3912    /// Facing +z (yaw 0) — the default.
3913    South,
3914    /// Facing -z (yaw 180).
3915    North,
3916    /// Facing -x (yaw 90).
3917    West,
3918    /// Facing +x (yaw 270).
3919    East,
3920}
3921
3922impl Facing {
3923    /// The kebab token (`south` / `north` / `west` / `east`).
3924    pub fn token(self) -> &'static str {
3925        match self {
3926            Facing::South => "south",
3927            Facing::North => "north",
3928            Facing::West => "west",
3929            Facing::East => "east",
3930        }
3931    }
3932}
3933
3934/// How a `despawn-actor` removes its puppet (DSL v0.6).
3935#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3936#[serde(rename_all = "kebab-case")]
3937pub enum DespawnStyle {
3938    /// Silent removal (`kill @e` with no death animation is not possible; the
3939    /// compiler removes the entity via `/kill` on an `Invulnerable` puppet, or a
3940    /// data-driven removal — see the emitter — so no death particles/sound show).
3941    Vanish,
3942    /// Plays the vanilla death animation (a cutscene death).
3943    Kill,
3944}
3945
3946impl DespawnStyle {
3947    /// The kebab token (`vanish` / `kill`).
3948    pub fn token(self) -> &'static str {
3949        match self {
3950            DespawnStyle::Vanish => "vanish",
3951            DespawnStyle::Kill => "kill",
3952        }
3953    }
3954}
3955
3956/// One step of a [`QuestEffect::Sequence`] (DSL v0.6): a group of effects fired at
3957/// an exact tick offset from the sequence's start.
3958#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3959#[serde(deny_unknown_fields)]
3960pub struct SequenceStep {
3961    /// Tick offset from the sequence start at which `effects` fire.
3962    pub at_ticks: u32,
3963    /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
3964    /// `sequence` (rejected with `DW0329`).
3965    pub effects: Vec<QuestEffect>,
3966}
3967
3968impl Objective {
3969    /// This objective's id.
3970    pub fn id(&self) -> &ObjectiveId {
3971        match self {
3972            Objective::TalkTo { id, .. }
3973            | Objective::ReachAnchor { id, .. }
3974            | Objective::Kill { id, .. }
3975            | Objective::Collect { id, .. }
3976            | Objective::Interact { id, .. } => id,
3977        }
3978    }
3979
3980    /// This objective's prerequisites.
3981    pub fn after(&self) -> &[ObjectiveId] {
3982        match self {
3983            Objective::TalkTo { after, .. }
3984            | Objective::ReachAnchor { after, .. }
3985            | Objective::Kill { after, .. }
3986            | Objective::Collect { after, .. }
3987            | Objective::Interact { after, .. } => after,
3988        }
3989    }
3990
3991    /// The short player-facing objective title (v0.3, optional).
3992    pub fn title(&self) -> Option<&str> {
3993        match self {
3994            Objective::TalkTo { title, .. }
3995            | Objective::ReachAnchor { title, .. }
3996            | Objective::Kill { title, .. }
3997            | Objective::Collect { title, .. }
3998            | Objective::Interact { title, .. } => title.as_deref(),
3999        }
4000    }
4001
4002    /// The one-line location/direction hint (v0.3, optional).
4003    pub fn hint(&self) -> Option<&str> {
4004        match self {
4005            Objective::TalkTo { hint, .. }
4006            | Objective::ReachAnchor { hint, .. }
4007            | Objective::Kill { hint, .. }
4008            | Objective::Collect { hint, .. }
4009            | Objective::Interact { hint, .. } => hint.as_deref(),
4010        }
4011    }
4012
4013    /// Mutable access to the optional player-facing title (i18n localization).
4014    pub fn title_mut(&mut self) -> &mut Option<String> {
4015        match self {
4016            Objective::TalkTo { title, .. }
4017            | Objective::ReachAnchor { title, .. }
4018            | Objective::Kill { title, .. }
4019            | Objective::Collect { title, .. }
4020            | Objective::Interact { title, .. } => title,
4021        }
4022    }
4023
4024    /// Mutable access to the optional one-line hint (i18n localization).
4025    pub fn hint_mut(&mut self) -> &mut Option<String> {
4026        match self {
4027            Objective::TalkTo { hint, .. }
4028            | Objective::ReachAnchor { hint, .. }
4029            | Objective::Kill { hint, .. }
4030            | Objective::Collect { hint, .. }
4031            | Objective::Interact { hint, .. } => hint,
4032        }
4033    }
4034
4035    /// The flags that must be set before this objective activates (v0.3).
4036    pub fn requires_flags(&self) -> &[FlagId] {
4037        match self {
4038            Objective::TalkTo { requires_flags, .. }
4039            | Objective::ReachAnchor { requires_flags, .. }
4040            | Objective::Kill { requires_flags, .. }
4041            | Objective::Collect { requires_flags, .. }
4042            | Objective::Interact { requires_flags, .. } => requires_flags,
4043        }
4044    }
4045
4046    /// The negative flag gate (DSL v0.6): flags whose being set **suppresses**
4047    /// this objective. The dual of [`Objective::requires_flags`].
4048    pub fn forbids_flags(&self) -> &[FlagId] {
4049        match self {
4050            Objective::TalkTo { forbids_flags, .. }
4051            | Objective::ReachAnchor { forbids_flags, .. }
4052            | Objective::Kill { forbids_flags, .. }
4053            | Objective::Collect { forbids_flags, .. }
4054            | Objective::Interact { forbids_flags, .. } => forbids_flags,
4055        }
4056    }
4057
4058    /// The numeric gate terms (DSL v0.10, spec-0031): comparisons that must hold
4059    /// before this objective activates. See [`StateCompare`].
4060    pub fn requires_state(&self) -> &[StateCompare] {
4061        match self {
4062            Objective::TalkTo { requires_state, .. }
4063            | Objective::ReachAnchor { requires_state, .. }
4064            | Objective::Kill { requires_state, .. }
4065            | Objective::Collect { requires_state, .. }
4066            | Objective::Interact { requires_state, .. } => requires_state,
4067        }
4068    }
4069
4070    /// What this objective does to the story (DSL v0.8, spec-0025).
4071    pub fn happening(&self) -> Option<&Happening> {
4072        match self {
4073            Objective::TalkTo { happening, .. }
4074            | Objective::ReachAnchor { happening, .. }
4075            | Objective::Kill { happening, .. }
4076            | Objective::Collect { happening, .. }
4077            | Objective::Interact { happening, .. } => happening.as_ref(),
4078        }
4079    }
4080
4081    /// The bot stealth hint (DSL v0.4): traverse this leg sneaking.
4082    pub fn stealth(&self) -> bool {
4083        match self {
4084            Objective::TalkTo { stealth, .. }
4085            | Objective::ReachAnchor { stealth, .. }
4086            | Objective::Kill { stealth, .. }
4087            | Objective::Collect { stealth, .. }
4088            | Objective::Interact { stealth, .. } => *stealth,
4089        }
4090    }
4091
4092    /// The container this objective ADOPTS (DSL v0.8), if it is a `collect` that
4093    /// declares one: the anchor whose prefab-placed chest/barrel it fills instead
4094    /// of conjuring its own chest. `None` on every other objective and on a
4095    /// `collect` that keeps the compiler-placed chest.
4096    pub fn collect_container(&self) -> Option<&AnchorId> {
4097        match self {
4098            Objective::Collect { container, .. } => container.as_ref(),
4099            _ => None,
4100        }
4101    }
4102
4103    /// The wave whose declared drops provide this objective's item (DSL v0.9),
4104    /// if it is a `collect` that declares one. `None` on every other objective
4105    /// and on a `collect` fed by a container.
4106    pub fn collect_dropped_by(&self) -> Option<&WaveId> {
4107        match self {
4108            Objective::Collect { dropped_by, .. } => dropped_by.as_ref(),
4109            _ => None,
4110        }
4111    }
4112
4113    /// The padding-stack count of a `collect` (DSL v0.8); `0` for every other
4114    /// objective and for a `collect` that fills the single required stack only.
4115    pub fn collect_fill_count(&self) -> u32 {
4116        match self {
4117            Objective::Collect { fill_count, .. } => *fill_count,
4118            _ => 0,
4119        }
4120    }
4121
4122    /// The `interact` prop block (DSL v0.4), if this is an `interact` with a prop.
4123    pub fn prop(&self) -> Option<&Prop> {
4124        match self {
4125            Objective::Interact { prop, .. } => prop.as_ref(),
4126            _ => None,
4127        }
4128    }
4129
4130    /// The kebab type tag.
4131    pub fn kind(&self) -> &'static str {
4132        match self {
4133            Objective::TalkTo { .. } => "talk-to",
4134            Objective::ReachAnchor { .. } => "reach-anchor",
4135            Objective::Kill { .. } => "kill",
4136            Objective::Collect { .. } => "collect",
4137            Objective::Interact { .. } => "interact",
4138        }
4139    }
4140
4141    /// The v0.3 verb name if this objective is one of the verbs introduced in
4142    /// DSL v0.3 (`kill`/`collect`/`interact`). These validate in v0.3 campaigns
4143    ///.
4144    pub fn v03_verb(&self) -> Option<&'static str> {
4145        match self {
4146            Objective::Kill { .. } => Some("kill"),
4147            Objective::Collect { .. } => Some("collect"),
4148            Objective::Interact { .. } => Some("interact"),
4149            Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => None,
4150        }
4151    }
4152}
4153
4154/// An effect fired by quest progress.
4155///
4156/// `Debug` is hand-written (see the impl below the enum) because it is a
4157/// **stable content-key rendering** — the compiler's `sequence_key` hashes
4158/// `{steps:?}` to name `seq_<hash>` functions, so an effect that uses none of
4159/// the v0.6 `forbids_flags` / `move-npc on_arrive` fields must render
4160/// byte-identically to the pre-addition enum (the [`CameraShot`] rule).
4161#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4162#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
4163pub enum QuestEffect {
4164    /// Opens a prefab-declared gate (one-way).
4165    OpenGate {
4166        /// The gate anchor to open.
4167        anchor: AnchorId,
4168        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4169        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4170        requires_flags: Vec<FlagId>,
4171        /// Per-effect negative flag gate (DSL v0.6); see
4172        /// [`QuestEffect::forbids_flags`].
4173        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4174        forbids_flags: Vec<FlagId>,
4175        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4176        /// must hold for this gate to be open. The third field of the one gate,
4177        /// carried by every gate consumer — never by the verb that first wanted
4178        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4179        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4180        requires_state: Vec<StateCompare>,
4181        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4182        /// 0.8.0, `DW0481`). Deliberately absent from
4183        /// the hand-written `Debug` rendering below: the declaration is
4184        /// validation metadata with no emission of its own, so a content key can
4185        /// never move because a beat gained a line of prose.
4186        #[serde(default, skip_serializing_if = "Option::is_none")]
4187        happening: Option<Happening>,
4188    },
4189    /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
4190    /// fills the gate anchor's region with the block the anchor declares (e.g. the
4191    /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
4192    /// The declared fill block is prefab metadata; a gate anchor with no `block` is
4193    /// rejected (`DW0343`). The completability model treats the region as **solid**
4194    /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
4195    /// clearing is modelled) — a critical path that must cross a gate after it seals
4196    /// fails the DW0311 reachability proof.
4197    CloseGate {
4198        /// The gate anchor to seal.
4199        anchor: AnchorId,
4200        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4201        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4202        requires_flags: Vec<FlagId>,
4203        /// Per-effect negative flag gate (DSL v0.6); see
4204        /// [`QuestEffect::forbids_flags`].
4205        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4206        forbids_flags: Vec<FlagId>,
4207        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4208        /// must hold for this gate to be open. The third field of the one gate,
4209        /// carried by every gate consumer — never by the verb that first wanted
4210        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4211        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4212        requires_state: Vec<StateCompare>,
4213        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4214        /// 0.8.0, `DW0481`). Deliberately absent from
4215        /// the hand-written `Debug` rendering below: the declaration is
4216        /// validation metadata with no emission of its own, so a content key can
4217        /// never move because a beat gained a line of prose.
4218        #[serde(default, skip_serializing_if = "Option::is_none")]
4219        happening: Option<Happening>,
4220        /// What the seal *says* when a player right-clicks it (DSL v0.8). A sealed gate is a wall the party will walk back to
4221        /// and press: the compiler answers that press on the actionbar. Absent, the
4222        /// compiler's canonical English is baked in (`The way is sealed.`) exactly
4223        /// as `world.boundary.message` does; authored, the line is l10n-inventoried
4224        /// under `<effect-key>.sealed_hint` and translates like every other
4225        /// player-visible string.
4226        ///
4227        /// Unlike `happening`, this **does** print in the hand-written `Debug`
4228        /// below when present — it changes emission, so two otherwise-identical
4229        /// sequences that differ only in their seal's answer are different content.
4230        #[serde(default, skip_serializing_if = "Option::is_none")]
4231        sealed_hint: Option<String>,
4232    },
4233    /// Marks the campaign complete (final advancement + credits). Terminal — not
4234    /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
4235    /// so this variant carries no `requires_flags`.
4236    CampaignComplete {
4237        /// Which ENDING this is (DSL v0.8, spec-0025).
4238        /// A campaign with more than one `campaign-complete` has more than one
4239        /// ending, and a branch that runs to an ending names it here — so the
4240        /// terminality proof (`DW0482`) can state *which* ending a branch reached
4241        /// instead of merely that something ended. There is no separate `endings`
4242        /// section: the set of endings is exactly the set named here, the same
4243        /// rule flags follow.
4244        #[serde(default, skip_serializing_if = "Option::is_none")]
4245        ending: Option<EndingId>,
4246        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4247        /// 0.8.0, `DW0481`). Deliberately absent from
4248        /// the hand-written `Debug` rendering below: the declaration is
4249        /// validation metadata with no emission of its own, so a content key can
4250        /// never move because a beat gained a line of prose.
4251        #[serde(default, skip_serializing_if = "Option::is_none")]
4252        happening: Option<Happening>,
4253    },
4254    /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
4255    GiveItem {
4256        /// Vanilla item id to give (validated against the registry).
4257        item: String,
4258        /// How many to give.
4259        count: u32,
4260        /// Optional display name (DSL v0.4), matching [`KitItem::name`].
4261        #[serde(default, skip_serializing_if = "Option::is_none")]
4262        name: Option<String>,
4263        /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
4264        /// party member. `one` hands a single copy to the player whose action fired
4265        /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
4266        /// has no acting player.
4267        #[serde(default, skip_serializing_if = "Option::is_none")]
4268        carrier: Option<Carrier>,
4269        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4270        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4271        requires_flags: Vec<FlagId>,
4272        /// Per-effect negative flag gate (DSL v0.6); see
4273        /// [`QuestEffect::forbids_flags`].
4274        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4275        forbids_flags: Vec<FlagId>,
4276        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4277        /// must hold for this gate to be open. The third field of the one gate,
4278        /// carried by every gate consumer — never by the verb that first wanted
4279        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4280        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4281        requires_state: Vec<StateCompare>,
4282    },
4283    /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
4284    SetFlag {
4285        /// The flag to set.
4286        flag: FlagId,
4287        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4288        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4289        requires_flags: Vec<FlagId>,
4290        /// Per-effect negative flag gate (DSL v0.6); see
4291        /// [`QuestEffect::forbids_flags`].
4292        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4293        forbids_flags: Vec<FlagId>,
4294        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4295        /// must hold for this gate to be open. The third field of the one gate,
4296        /// carried by every gate consumer — never by the verb that first wanted
4297        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4298        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4299        requires_state: Vec<StateCompare>,
4300    },
4301    /// Writes a declared datum to an absolute value (DSL v0.10, spec-0031).
4302    SetState {
4303        /// The datum to write (stage-5 `state` ref).
4304        state: StateId,
4305        /// The value to write.
4306        value: i32,
4307        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4308        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4309        requires_flags: Vec<FlagId>,
4310        /// Per-effect negative flag gate (DSL v0.6); see
4311        /// [`QuestEffect::forbids_flags`].
4312        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4313        forbids_flags: Vec<FlagId>,
4314        /// Numeric gate terms (DSL v0.10, spec-0031); see
4315        /// [`QuestEffect::requires_state`].
4316        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4317        requires_state: Vec<StateCompare>,
4318    },
4319    /// Moves a declared datum by a signed amount (DSL v0.10, spec-0031).
4320    ///
4321    /// **Signed on purpose.** A purse that a shop debits and a stake that a death
4322    /// forfeits are the same operation with the sign flipped; a separate
4323    /// `subtract-state` would be a second verb for one mechanism, and the first
4324    /// campaign to need "add a negative" would have to choose between them.
4325    AddState {
4326        /// The datum to move (stage-5 `state` ref).
4327        state: StateId,
4328        /// How far to move it. Negative counts down.
4329        amount: i32,
4330        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4331        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4332        requires_flags: Vec<FlagId>,
4333        /// Per-effect negative flag gate (DSL v0.6); see
4334        /// [`QuestEffect::forbids_flags`].
4335        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4336        forbids_flags: Vec<FlagId>,
4337        /// Numeric gate terms (DSL v0.10, spec-0031); see
4338        /// [`QuestEffect::requires_state`].
4339        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4340        requires_state: Vec<StateCompare>,
4341    },
4342    /// Leaves a declared [`Stake`] behind for the acting player (DSL v0.10,
4343    /// spec-0032): forfeit the declared share of its datum, and place a
4344    /// collectable marker at the compile-time anchor for where they are.
4345    ///
4346    /// **Nothing about this verb says "death".** It is written in `on_death`
4347    /// because that is where a souls-shaped delve wants it, but the mechanism is
4348    /// "leave a recoverable cache where the acting player stands", and the effect
4349    /// carries the ordinary gate so any root may run it. What *is* death-specific
4350    /// — that the corpse stands on the death position, so the placement lookup has
4351    /// a position to key on — is a property of the `on_death` root, not of this
4352    /// verb.
4353    DropStake {
4354        /// The stake (stage-5 `stakes` ref) to leave.
4355        stake: StakeId,
4356        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4357        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4358        requires_flags: Vec<FlagId>,
4359        /// Per-effect negative flag gate (DSL v0.6); see
4360        /// [`QuestEffect::forbids_flags`].
4361        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4362        forbids_flags: Vec<FlagId>,
4363        /// Numeric gate terms (DSL v0.10, spec-0031); see
4364        /// [`QuestEffect::requires_state`].
4365        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4366        requires_state: Vec<StateCompare>,
4367    },
4368    /// Returns a declared datum to its declared `initial` (DSL v0.10,
4369    /// spec-0031) — the verb `FlagId` has never had.
4370    ClearState {
4371        /// The datum to clear (stage-5 `state` ref).
4372        state: StateId,
4373        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4374        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4375        requires_flags: Vec<FlagId>,
4376        /// Per-effect negative flag gate (DSL v0.6); see
4377        /// [`QuestEffect::forbids_flags`].
4378        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4379        forbids_flags: Vec<FlagId>,
4380        /// Numeric gate terms (DSL v0.10, spec-0031); see
4381        /// [`QuestEffect::requires_state`].
4382        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4383        requires_state: Vec<StateCompare>,
4384    },
4385    /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
4386    SpawnWave {
4387        /// The wave (stage-5 `waves` ref) to spawn.
4388        wave: WaveId,
4389        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4390        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4391        requires_flags: Vec<FlagId>,
4392        /// Per-effect negative flag gate (DSL v0.6); see
4393        /// [`QuestEffect::forbids_flags`].
4394        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4395        forbids_flags: Vec<FlagId>,
4396        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4397        /// must hold for this gate to be open. The third field of the one gate,
4398        /// carried by every gate consumer — never by the verb that first wanted
4399        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4400        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4401        requires_state: Vec<StateCompare>,
4402        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4403        /// 0.8.0, `DW0481`). Deliberately absent from
4404        /// the hand-written `Debug` rendering below: the declaration is
4405        /// validation metadata with no emission of its own, so a content key can
4406        /// never move because a beat gained a line of prose.
4407        #[serde(default, skip_serializing_if = "Option::is_none")]
4408        happening: Option<Happening>,
4409    },
4410    /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
4411    /// l10n key inventory like any player-visible string.
4412    Narrate {
4413        /// The line shown to the player.
4414        text: String,
4415        /// Presentation channel (default `chat`).
4416        #[serde(default, skip_serializing_if = "Option::is_none")]
4417        style: Option<NarrateStyle>,
4418        /// Optional sound id played alongside the line.
4419        #[serde(default, skip_serializing_if = "Option::is_none")]
4420        sound: Option<String>,
4421        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4422        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4423        requires_flags: Vec<FlagId>,
4424        /// Per-effect negative flag gate (DSL v0.6); see
4425        /// [`QuestEffect::forbids_flags`].
4426        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4427        forbids_flags: Vec<FlagId>,
4428        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4429        /// must hold for this gate to be open. The third field of the one gate,
4430        /// carried by every gate consumer — never by the verb that first wanted
4431        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4432        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4433        requires_state: Vec<StateCompare>,
4434    },
4435    /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
4436    /// placement. Block id validated against the pinned 1.21.11 block registry;
4437    /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
4438    /// accepted and passed through verbatim (DSL v0.6).
4439    SetBlock {
4440        /// The anchor to place the block at.
4441        anchor: AnchorId,
4442        /// Vanilla block id to place.
4443        block: String,
4444        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4445        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4446        requires_flags: Vec<FlagId>,
4447        /// Per-effect negative flag gate (DSL v0.6); see
4448        /// [`QuestEffect::forbids_flags`].
4449        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4450        forbids_flags: Vec<FlagId>,
4451        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4452        /// must hold for this gate to be open. The third field of the one gate,
4453        /// carried by every gate consumer — never by the verb that first wanted
4454        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4455        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4456        requires_state: Vec<StateCompare>,
4457    },
4458    /// **Fill a declared region with a block** at runtime (DSL v0.10, spec-0031).
4459    ///
4460    /// The general spelling of the capability `open-gate` / `close-gate` carried
4461    /// privately: a region, filled or cleared, from a point in the quest DAG.
4462    /// `close-gate` is this verb with the region and the block read off a prefab
4463    /// gate anchor instead of authored; `set-block` is the one-cell case at a
4464    /// point anchor. All three lower through one emission
4465    /// (`emit::fill_region_command`) and are modelled by one completability rule
4466    /// (`plan::RegionEvent`), so a third consumer inherits the proof instead of
4467    /// re-deriving it.
4468    ///
4469    /// From the DAG point at which this fires, the completability model treats the
4470    /// filled cells as whatever the **block** makes them. A full-cube block leaves
4471    /// them **solid**, exactly as a `close-gate` seal is: a critical path that must
4472    /// cross the region afterwards fails `DW0311`. `minecraft:water` /
4473    /// `minecraft:lava` leave them **flooded** — impassable and never floor, because
4474    /// nothing stands on a fluid — and because a fill carries no `replace` filter it
4475    /// takes away whatever floor was in the box, so a forced leg that needed that
4476    /// footing fails `DW0544`.
4477    FillRegion {
4478        /// The volume to fill, as an anchor-centred box (`anchor ± extent`).
4479        ///
4480        /// Deliberately the existing [`StealthZone`] — the engine's one
4481        /// anchor-centred box object class, already shared by `damage-players`'s
4482        /// `in` filter, `collapse`'s `region_anchor`, a `volley` kill zone and a
4483        /// `lethal_volumes[]` region, and resolved through the single
4484        /// `Plan::zone_box`. A private twin with the same two fields would be
4485        /// `tools/check-capability-ownership.py` check C by construction.
4486        ///
4487        /// An anchor-centred box rather than a prefab `region` anchor for the
4488        /// reason `collapse` states: the assembled model deletes every gate-region
4489        /// anchor's cells, so a slab declared that way would already be gone.
4490        region: StealthZone,
4491        /// The block the region is filled with (validated against the pinned
4492        /// 1.21.11 block registry, `DW0193`).
4493        block: String,
4494        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4495        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4496        requires_flags: Vec<FlagId>,
4497        /// Per-effect negative flag gate (DSL v0.6); see
4498        /// [`QuestEffect::forbids_flags`].
4499        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4500        forbids_flags: Vec<FlagId>,
4501        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4502        /// must hold for this gate to be open. The third field of the one gate,
4503        /// carried by every gate consumer — never by the verb that first wanted
4504        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4505        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4506        requires_state: Vec<StateCompare>,
4507    },
4508    /// **Clear a declared region to air** at runtime (DSL v0.10, spec-0031) — the
4509    /// physical dual of [`QuestEffect::FillRegion`], and the general spelling of
4510    /// what `open-gate` does to a gate anchor's region.
4511    ///
4512    /// The completability model treats the cleared cells as **passable** from the
4513    /// DAG point at which this fires, with one exception it states out loud: a
4514    /// cleared cell the model already floods stays impassable, because clearing a
4515    /// block does not remove water (`nav::World::with_cleared`).
4516    ClearRegion {
4517        /// The volume to clear, as an anchor-centred box (`anchor ± extent`) —
4518        /// the same object class [`QuestEffect::FillRegion`] fills.
4519        region: StealthZone,
4520        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4521        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4522        requires_flags: Vec<FlagId>,
4523        /// Per-effect negative flag gate (DSL v0.6); see
4524        /// [`QuestEffect::forbids_flags`].
4525        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4526        forbids_flags: Vec<FlagId>,
4527        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4528        /// must hold for this gate to be open. The third field of the one gate,
4529        /// carried by every gate consumer — never by the verb that first wanted
4530        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4531        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4532        requires_state: Vec<StateCompare>,
4533    },
4534    /// **Opens a placed piece's contingent way** (DSL v0.12, spec-0042 §2.4): the
4535    /// broken flight a beat repairs, the bridge a beat lowers, the rubble a beat
4536    /// clears.
4537    ///
4538    /// A piece's spatial contract may declare a traversal edge whose crossability
4539    /// depends on a named region — `laid` (empty as built, opening fills it) or
4540    /// `cleared` (built solid, opening voids it). The prefab checker proves the
4541    /// piece is severed as shipped and joined once that region is opened; this is
4542    /// the verb that opens it, and it is the only one, because a way is the object
4543    /// and opening it is the operation.
4544    ///
4545    /// **There is no region, no block and no sign on this effect, and that is the
4546    /// design rather than an omission.** All three are read from the piece's own
4547    /// exported metadata (`spatial_contract.edges[].way`), so the effect and the
4548    /// building cannot disagree about what a way is — two authorities plus an
4549    /// equality check is the defect this shape avoids, not a variant of the fix
4550    /// (spec-0042 AC8). What the campaign decides is *when*.
4551    ///
4552    /// Completability: the way is **shut until this fires**, and from the DAG
4553    /// point at which it fires the region is solid-and-footing (`laid`) or
4554    /// passable (`cleared`) — the same [`QuestEffect::FillRegion`] /
4555    /// [`QuestEffect::ClearRegion`] model, fed from metadata instead of from an
4556    /// authored box, so this verb inherits the forced-footing rule (`DW0546`)
4557    /// rather than restating it. Required content standing beyond a way that no
4558    /// forced opening precedes is `DW0548`, which names the way, the effect and
4559    /// the element.
4560    OpenWay {
4561        /// The placed piece whose way this opens (`prefab/<name>`).
4562        ///
4563        /// A piece, not an anchor: the way's cells are the contract's, not a gate
4564        /// anchor's, and a piece placed twice has two ways. The reference must
4565        /// name exactly one placement; naming none or several is `DW0547`.
4566        piece: PrefabId,
4567        /// The way's region name, as the piece's contract exports it
4568        /// (`spatial_contract.edges[].way.region`).
4569        way: String,
4570        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4571        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4572        requires_flags: Vec<FlagId>,
4573        /// Per-effect negative flag gate (DSL v0.6); see
4574        /// [`QuestEffect::forbids_flags`].
4575        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4576        forbids_flags: Vec<FlagId>,
4577        /// Numeric gate terms (DSL v0.10, spec-0031); see
4578        /// [`QuestEffect::requires_state`].
4579        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4580        requires_state: Vec<StateCompare>,
4581    },
4582    /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5).
4583    DespawnNpc {
4584        /// The NPC (stage-2 ref) to remove.
4585        npc: NpcId,
4586        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4587        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4588        requires_flags: Vec<FlagId>,
4589        /// Per-effect negative flag gate (DSL v0.6); see
4590        /// [`QuestEffect::forbids_flags`].
4591        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4592        forbids_flags: Vec<FlagId>,
4593        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4594        /// must hold for this gate to be open. The third field of the one gate,
4595        /// carried by every gate consumer — never by the verb that first wanted
4596        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4597        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4598        requires_state: Vec<StateCompare>,
4599        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4600        /// 0.8.0, `DW0481`). Deliberately absent from
4601        /// the hand-written `Debug` rendering below: the declaration is
4602        /// validation metadata with no emission of its own, so a content key can
4603        /// never move because a beat gained a line of prose.
4604        #[serde(default, skip_serializing_if = "Option::is_none")]
4605        happening: Option<Happening>,
4606    },
4607    /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
4608    /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
4609    /// path** by A* over the solved voxel grid and emits per-tick teleport
4610    /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
4611    /// a solid affordance. An unroutable move is a compile error (`DW0307`).
4612    MoveNpc {
4613        /// The NPC (stage-2 ref) to move.
4614        npc: NpcId,
4615        /// The destination anchor.
4616        to_anchor: AnchorId,
4617        /// Optional travel speed in blocks/tick (defaults to ~0.15).
4618        #[serde(default, skip_serializing_if = "Option::is_none")]
4619        speed: Option<f64>,
4620        /// Effects fired once the NPC arrives at the destination cell — exact
4621        /// parity with [`QuestEffect::MoveActor`]
4622        /// `on_arrive`: same arrival detection (the walk driver's final tick), same
4623        /// execution context, and every deep effect walker recurses into it via
4624        /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
4625        /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
4626        /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
4627        /// mark).
4628        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4629        on_arrive: Vec<QuestEffect>,
4630        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4631        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4632        requires_flags: Vec<FlagId>,
4633        /// Per-effect negative flag gate (DSL v0.6); see
4634        /// [`QuestEffect::forbids_flags`].
4635        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4636        forbids_flags: Vec<FlagId>,
4637        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4638        /// must hold for this gate to be open. The third field of the one gate,
4639        /// carried by every gate consumer — never by the verb that first wanted
4640        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4641        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4642        requires_state: Vec<StateCompare>,
4643        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4644        /// 0.8.0, `DW0481`). Deliberately absent from
4645        /// the hand-written `Debug` rendering below: the declaration is
4646        /// validation metadata with no emission of its own, so a content key can
4647        /// never move because a beat gained a line of prose.
4648        #[serde(default, skip_serializing_if = "Option::is_none")]
4649        happening: Option<Happening>,
4650    },
4651    /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
4652    /// gamemode+position, spectator, then dolly two co-located cameras along a
4653    /// straight-line lerp between waypoints and alternate `spectate` between them
4654    /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
4655    /// no-op and is never emitted), and restore on completion. The compiler
4656    /// validates the dolly path passes only through non-solid blocks — cameras
4657    /// fly but must not clip a solid (`DW0308`).
4658    ///
4659    /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
4660    /// emission to face that world point from its own position, so the shot keeps
4661    /// its subject framed for the whole move; without it, the camera faces along
4662    /// the direction of travel (the segment it is currently traversing).
4663    ///
4664    /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
4665    /// inside one save/restore bracket (hard cut between shots). Two accepted,
4666    /// mutually exclusive spellings, both normalized by
4667    /// [`QuestEffect::cutscene_shots`]:
4668    /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
4669    /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
4670    ///   exactly equivalent to a one-entry `shots` list.
4671    ///
4672    /// Mixing or omitting both is `DW0199`.
4673    Cutscene {
4674        /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
4675        /// with the single-shot `path`/`seconds` fields (`DW0199`).
4676        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4677        shots: Vec<CameraShot>,
4678        /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
4679        /// lerp between them).
4680        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4681        path: Vec<CameraWaypoint>,
4682        /// Single-shot form (DSL v0.4): shot duration in seconds.
4683        #[serde(default, skip_serializing_if = "Option::is_none")]
4684        seconds: Option<u32>,
4685        /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
4686        /// Absent = face along the direction of travel.
4687        #[serde(default, skip_serializing_if = "Option::is_none")]
4688        look_at: Option<CameraTarget>,
4689        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4690        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4691        requires_flags: Vec<FlagId>,
4692        /// Per-effect negative flag gate (DSL v0.6); see
4693        /// [`QuestEffect::forbids_flags`].
4694        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4695        forbids_flags: Vec<FlagId>,
4696        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4697        /// must hold for this gate to be open. The third field of the one gate,
4698        /// carried by every gate consumer — never by the verb that first wanted
4699        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4700        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4701        requires_state: Vec<StateCompare>,
4702    },
4703    /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
4704    /// Instantaneous (vanilla has no gradual transition); the state persists
4705    /// because the daylight cycle is frozen by sealing.
4706    SetTime {
4707        /// The time state to cut to.
4708        time: WorldTime,
4709        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4710        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4711        requires_flags: Vec<FlagId>,
4712        /// Per-effect negative flag gate (DSL v0.6); see
4713        /// [`QuestEffect::forbids_flags`].
4714        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4715        forbids_flags: Vec<FlagId>,
4716        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4717        /// must hold for this gate to be open. The third field of the one gate,
4718        /// carried by every gate consumer — never by the verb that first wanted
4719        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4720        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4721        requires_state: Vec<StateCompare>,
4722    },
4723    /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
4724    /// Instantaneous; persists because the weather cycle is frozen by sealing.
4725    SetWeather {
4726        /// The weather state to cut to.
4727        weather: WorldWeather,
4728        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4729        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4730        requires_flags: Vec<FlagId>,
4731        /// Per-effect negative flag gate (DSL v0.6); see
4732        /// [`QuestEffect::forbids_flags`].
4733        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4734        forbids_flags: Vec<FlagId>,
4735        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4736        /// must hold for this gate to be open. The third field of the one gate,
4737        /// carried by every gate consumer — never by the verb that first wanted
4738        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4739        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4740        requires_state: Vec<StateCompare>,
4741    },
4742    /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
4743    /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
4744    /// sound-event registry (`DW0326` unknown). `at` selects where the sound
4745    /// originates (default: each player's own position); `volume`/`pitch` map to
4746    /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
4747    /// vanilla).
4748    PlaySound {
4749        /// The vanilla sound-event id (`minecraft:` prefix optional).
4750        sound: String,
4751        /// Where the sound plays from (default: `players`).
4752        #[serde(default, skip_serializing_if = "Option::is_none")]
4753        at: Option<SoundAt>,
4754        /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
4755        #[serde(default, skip_serializing_if = "Option::is_none")]
4756        volume: Option<f64>,
4757        /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
4758        #[serde(default, skip_serializing_if = "Option::is_none")]
4759        pitch: Option<f64>,
4760        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4761        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4762        requires_flags: Vec<FlagId>,
4763        /// Per-effect negative flag gate (DSL v0.6); see
4764        /// [`QuestEffect::forbids_flags`].
4765        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4766        forbids_flags: Vec<FlagId>,
4767        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4768        /// must hold for this gate to be open. The third field of the one gate,
4769        /// carried by every gate consumer — never by the verb that first wanted
4770        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4771        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4772        requires_state: Vec<StateCompare>,
4773    },
4774    /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
4775    /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
4776    /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
4777    /// acting player: at top level it damages every player once; inside a stealth
4778    /// `on_caught` it damages the caught player (the "caught → death → respawn at
4779    /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
4780    /// is lethal through golden apples / absorption. `within` (JSON `in`) narrows to
4781    /// acting players standing inside an anchor-centred box (the same box model as a
4782    /// stealth zone), keeping the per-`@s` semantics. `damage_type` is the damage
4783    /// type — a curated set of vanilla types that all respect `keepInventory` and do
4784    /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
4785    /// (The field is `damage_type`, not `type`, because the effect enum is
4786    /// internally tagged on `type`.)
4787    DamagePlayers {
4788        /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
4789        amount: u32,
4790        /// Optional spatial filter: only damage an acting player inside this
4791        /// anchor-centred box (`anchor ± extent`). Absent = every acting player.
4792        #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
4793        within: Option<StealthZone>,
4794        /// The damage type (default [`DamageKind::Generic`]).
4795        #[serde(default, skip_serializing_if = "Option::is_none")]
4796        damage_type: Option<DamageKind>,
4797        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
4798        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4799        requires_flags: Vec<FlagId>,
4800        /// Per-effect negative flag gate (DSL v0.6); see
4801        /// [`QuestEffect::forbids_flags`].
4802        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4803        forbids_flags: Vec<FlagId>,
4804        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4805        /// must hold for this gate to be open. The third field of the one gate,
4806        /// carried by every gate consumer — never by the verb that first wanted
4807        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4808        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4809        requires_state: Vec<StateCompare>,
4810    },
4811    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
4812    /// `spawnpoint @a` at the anchor cell and mirrors the coords into
4813    /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
4814    /// `set-checkpoint` always replaces an earlier one). The compiler proves the
4815    /// cell is standable (`DW0316`) and that the remaining critical path stays
4816    /// reachable from it (`DW0315`).
4817    SetCheckpoint {
4818        /// The prefab checkpoint anchor the party respawns at.
4819        anchor: AnchorId,
4820        /// Per-player effects re-run each time a player respawns while this
4821        /// checkpoint is the active one — scene reset (e.g. re-caging an
4822        /// unleashed actor). Emitted idempotently in declared order; empty = no
4823        /// hook. Respawn is detected via the vanilla `deathCount` criterion.
4824        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4825        on_respawn: Vec<QuestEffect>,
4826    },
4827    /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
4828    /// [`QuestEffect::SetCheckpoint`] for souls-mode pacing. The effect *arms*
4829    /// the rest affordance (a `minecraft:interaction` the player right-clicks at
4830    /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
4831    /// only **when the party actually rests**. Resting fires `on_rest` — the
4832    /// scene reset that makes retry cheap: re-arming traps, re-seating waves
4833    /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
4834    /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
4835    /// so the world's answer to a death and to a rest is identical (spec-0016:
4836    /// death is an investment, never a tax).
4837    ///
4838    /// Proofs are inherited from the checkpoint machinery: the anchor must be
4839    /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
4840    /// the beat that arms the bonfire (the earliest moment a rest can happen).
4841    Bonfire {
4842        /// The prefab anchor the rest affordance stands at, and the cell the
4843        /// party respawns at once rested.
4844        anchor: AnchorId,
4845        /// Effects re-run on every rest **and** on every respawn at this bonfire
4846        /// — the scene reset. Emitted in declared order and expected to be
4847        /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
4848        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4849        on_rest: Vec<QuestEffect>,
4850        /// Title of the two-option rest dialog (DSL v0.8).
4851        /// Absent = the compiler's canonical English `Bonfire`,
4852        /// baked at emit time (the `world.boundary.message` precedent): an
4853        /// authored line is inventoried and translates like every other
4854        /// player-visible string.
4855        #[serde(default, skip_serializing_if = "Option::is_none")]
4856        prompt: Option<String>,
4857        /// Label of the **rest and save** button. Absent = `Rest and save`.
4858        /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
4859        /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
4860        #[serde(default, skip_serializing_if = "Option::is_none")]
4861        rest_label: Option<String>,
4862        /// Label of the **save only** button. Absent = `Save only`.
4863        #[serde(default, skip_serializing_if = "Option::is_none")]
4864        save_label: Option<String>,
4865    },
4866    /// Begins a stealth beat (DSL v0.6, spec-0014):
4867    /// zone presence alone = hidden — no sneak requirement, which collides with
4868    /// the spectator cutscene camera. While active, every player must be
4869    /// inside some `zone` each tick; a player outside every zone for
4870    /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
4871    /// Zone membership is read from the player's position. The compiler proves
4872    /// each zone is standable and reachable from the activating beat (`DW0327`).
4873    BeginStealth {
4874        /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
4875        zones: Vec<StealthZone>,
4876        /// Per-player effects fired when a player is caught (out of every zone
4877        /// for `grace_ticks`). Empty = no consequence.
4878        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4879        on_caught: Vec<QuestEffect>,
4880        /// Ticks a player may be exposed before `on_caught` fires (default 20).
4881        #[serde(default = "default_grace_ticks")]
4882        grace_ticks: u32,
4883    },
4884    /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
4885    EndStealth,
4886    /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
4887    /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
4888    /// scripted entrance a staged character needs. Idempotent: spawning an NPC
4889    /// already in the world is a no-op. Only meaningful for an NPC declared
4890    /// `deferred: true`; a non-deferred NPC is already in the world from init.
4891    SpawnNpc {
4892        /// The NPC (stage-2 ref) to summon.
4893        npc: NpcId,
4894        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4895        /// 0.8.0, `DW0481`). Deliberately absent from
4896        /// the hand-written `Debug` rendering below: the declaration is
4897        /// validation metadata with no emission of its own, so a content key can
4898        /// never move because a beat gained a line of prose.
4899        #[serde(default, skip_serializing_if = "Option::is_none")]
4900        happening: Option<Happening>,
4901    },
4902    // --- DSL v0.6 actor staging effects (spec-0014) ---
4903    /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
4904    /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
4905    SpawnActor {
4906        /// The actor (stage-5 `actors` ref) to summon.
4907        actor: ActorId,
4908        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4909        /// 0.8.0, `DW0481`). Deliberately absent from
4910        /// the hand-written `Debug` rendering below: the declaration is
4911        /// validation metadata with no emission of its own, so a content key can
4912        /// never move because a beat gained a line of prose.
4913        #[serde(default, skip_serializing_if = "Option::is_none")]
4914        happening: Option<Happening>,
4915    },
4916    /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
4917    /// animation (cutscene deaths); `vanish` is silent removal.
4918    DespawnActor {
4919        /// The actor (stage-5 `actors` ref) to remove.
4920        actor: ActorId,
4921        /// How the puppet is removed.
4922        style: DespawnStyle,
4923        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4924        /// 0.8.0, `DW0481`). Deliberately absent from
4925        /// the hand-written `Debug` rendering below: the declaration is
4926        /// validation metadata with no emission of its own, so a content key can
4927        /// never move because a beat gained a line of prose.
4928        #[serde(default, skip_serializing_if = "Option::is_none")]
4929        happening: Option<Happening>,
4930    },
4931    /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
4932    /// the assembled model, using the actor's hitbox footprint, yawed along the
4933    /// path tangent (DSL v0.6). Concurrent movers are allowed (a herded
4934    /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
4935    /// effects fire once the puppet reaches the destination cell.
4936    MoveActor {
4937        /// The actor (stage-5 `actors` ref) to move.
4938        actor: ActorId,
4939        /// The destination anchor.
4940        to_anchor: AnchorId,
4941        /// Optional travel speed in blocks/tick (defaults to ~0.15).
4942        #[serde(default, skip_serializing_if = "Option::is_none")]
4943        speed: Option<f64>,
4944        /// Effects fired once the puppet arrives at the destination cell.
4945        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4946        on_arrive: Vec<QuestEffect>,
4947        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4948        /// 0.8.0, `DW0481`). Deliberately absent from
4949        /// the hand-written `Debug` rendering below: the declaration is
4950        /// validation metadata with no emission of its own, so a content key can
4951        /// never move because a beat gained a line of prose.
4952        #[serde(default, skip_serializing_if = "Option::is_none")]
4953        happening: Option<Happening>,
4954    },
4955    /// Replaces an actor's puppet with a real-AI twin of the same type / position /
4956    /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
4957    /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
4958    /// special verb.
4959    UnleashActor {
4960        /// The actor (stage-5 `actors` ref) to unleash.
4961        actor: ActorId,
4962        /// What this beat does to the story (DSL v0.8, spec-0025; required at
4963        /// 0.8.0, `DW0481`). Deliberately absent from
4964        /// the hand-written `Debug` rendering below: the declaration is
4965        /// validation metadata with no emission of its own, so a content key can
4966        /// never move because a beat gained a line of prose.
4967        #[serde(default, skip_serializing_if = "Option::is_none")]
4968        happening: Option<Happening>,
4969    },
4970    /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
4971    /// at exact tick offsets. Effects are any in the stage-5 set except a nested
4972    /// `sequence` (rejected with `DW0329`).
4973    Sequence {
4974        /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
4975        steps: Vec<SequenceStep>,
4976    },
4977    /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
4978    /// projectiles with real velocity vectors, fired from a gallery slot into a
4979    /// declared kill zone.
4980    ///
4981    /// The contract is **saturation, not sniping**.
4982    /// Every salvo puts one projectile on the trajectory to **every standable
4983    /// cell of `kill_zone`**, plus one aimed at the triggering player's
4984    /// fire-time position (which punishes standing still). A player therefore
4985    /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
4986    /// decision rather than a lucky step. Coverage is proven at compile time:
4987    /// `from_anchor` must have clear line of fire to every standable kill-zone
4988    /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
4989    /// on the stairs" is a build-time fact, not a hope.
4990    ///
4991    /// Projectiles are summoned `NoGravity` so the flown path is exactly the
4992    /// straight segment the coverage proof checks — proof and runtime share one
4993    /// geometry. Drag scales speed but not direction, so the line is preserved.
4994    Volley {
4995        /// Projectile entity id (default `minecraft:arrow`; validated against
4996        /// the pinned 1.21.11 registry, `DW0441`).
4997        #[serde(default, skip_serializing_if = "Option::is_none")]
4998        projectile: Option<String>,
4999        /// The gallery slot the volley is fired FROM — a point anchor. Its cell
5000        /// must be clear (a projectile spawned inside a wall never leaves it).
5001        from_anchor: AnchorId,
5002        /// The zone the volley must blanket, as an anchor-centred box
5003        /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
5004        /// `begin-stealth`'s `zones` use. Every standable cell in it receives
5005        /// fire each salvo.
5006        ///
5007        /// Deliberately NOT a bare prefab `region` anchor: the assembled model
5008        /// clears every gate-region anchor's cells unconditionally, so
5009        /// describing a zone that way would delete the geometry it names.
5010        kill_zone: StealthZone,
5011        /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
5012        #[serde(default, skip_serializing_if = "Option::is_none")]
5013        salvos: Option<u32>,
5014        /// Ticks between salvos (default 10, `DW0443` bounds it).
5015        #[serde(default, skip_serializing_if = "Option::is_none")]
5016        interval: Option<u32>,
5017        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
5018        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5019        requires_flags: Vec<FlagId>,
5020        /// Per-effect negative flag gate (DSL v0.6); see
5021        /// [`QuestEffect::forbids_flags`].
5022        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5023        forbids_flags: Vec<FlagId>,
5024        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
5025        /// must hold for this gate to be open. The third field of the one gate,
5026        /// carried by every gate consumer — never by the verb that first wanted
5027        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
5028        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5029        requires_state: Vec<StateCompare>,
5030    },
5031    /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
5032    /// drop them as `falling_block` entities — the buried-alive trap redstone
5033    /// cannot express at all.
5034    ///
5035    /// The post-collapse world is **modeled, not guessed**: the compiler clears
5036    /// the region, settles every dropped column through the existing gravity
5037    /// model (spec-0010), optionally paves the landing surface with
5038    /// `then_floor`, and re-runs the critical-path completability proof against
5039    /// that mutated world (`DW0445`). A collapse that buries the only route is a
5040    /// build error, exactly as a `shortcut` seal that strands the party is.
5041    Collapse {
5042        /// The volume whose blocks fall, as an anchor-centred box
5043        /// (`anchor ± extent`). Must hold blocks and sit above standable footing
5044        /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
5045        ///
5046        /// An anchor-centred box rather than a prefab `region` anchor for a
5047        /// load-bearing reason: the assembled model deletes every gate-region
5048        /// anchor's cells, so a ceiling slab declared that way would already be
5049        /// gone before the trap ever fired.
5050        region_anchor: StealthZone,
5051        /// The block the falling entities are made of (default
5052        /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
5053        #[serde(default, skip_serializing_if = "Option::is_none")]
5054        falling_block: Option<String>,
5055        /// Optional block the landing surface is paved with once the debris
5056        /// settles — the authored post-collapse floor the completability proof
5057        /// reasons over.
5058        #[serde(default, skip_serializing_if = "Option::is_none")]
5059        then_floor: Option<String>,
5060        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
5061        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5062        requires_flags: Vec<FlagId>,
5063        /// Per-effect negative flag gate (DSL v0.6); see
5064        /// [`QuestEffect::forbids_flags`].
5065        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5066        forbids_flags: Vec<FlagId>,
5067        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
5068        /// must hold for this gate to be open. The third field of the one gate,
5069        /// carried by every gate consumer — never by the verb that first wanted
5070        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
5071        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5072        requires_state: Vec<StateCompare>,
5073    },
5074    /// Grants a vanilla **status effect** for a stated duration (DSL v0.10,
5075    /// spec-0031). The engine has emitted status effects since v0.6 — the
5076    /// night-vision area mitigation is a self-rescheduling, region-scoped
5077    /// `effect give` — and exposed none, so an author who wanted blindness for a
5078    /// lift ride, slowness in deep water or regeneration at a shrine had no
5079    /// surface at all. This is that surface, over the whole pinned 1.21.11
5080    /// `mob_effect` registry (`DW0192` rejects an id outside it).
5081    ///
5082    /// **A grant carries a duration, and there is no way to spell one that does
5083    /// not.** Vanilla's `infinite` keyword is deliberately absent from this
5084    /// surface: an effect whose only removal is a later command is an effect the
5085    /// player keeps forever whenever that command does not run — a logout, a
5086    /// crash, a chain interrupted by a death. A duration expires on its own, with
5087    /// no cooperation from anything. `seconds` is therefore required and bounded
5088    /// (1..=[`MAX_EFFECT_SECONDS`], `DW0541`), and the *pattern* that reintroduces
5089    /// the same hazard — pairing a grant with a `clear-effect` that removes it
5090    /// while it is still live — is `DW0540`.
5091    ///
5092    /// `in` narrows to players inside an anchor-centred box, the same
5093    /// [`StealthZone`] a `begin-stealth` zone, a `damage-players` filter and a
5094    /// `lethal_volumes[]` region use, resolved through the one `Plan::zone_box`.
5095    /// It is what makes "blind whoever is riding the car" expressible without
5096    /// blinding the whole party.
5097    GiveEffect {
5098        /// Vanilla status-effect id (e.g. `minecraft:blindness`), validated
5099        /// against the pinned 1.21.11 registry (`DW0192`).
5100        effect: String,
5101        /// Duration in seconds. Required, `1..=`[`MAX_EFFECT_SECONDS`]
5102        /// (`DW0541`) — see the variant docs for why there is no infinite form.
5103        seconds: u32,
5104        /// Amplifier (0 = level I), `0..=`[`MAX_POTION_AMPLIFIER`] (`DW0541`).
5105        /// Absent = 0.
5106        #[serde(default, skip_serializing_if = "Option::is_none")]
5107        amplifier: Option<u32>,
5108        /// Suppress the swirling particles (vanilla's `hideParticles`). Absent =
5109        /// `false`, vanilla's own default.
5110        #[serde(default, skip_serializing_if = "Option::is_none")]
5111        hide_particles: Option<bool>,
5112        /// Optional spatial filter: only grant to a player inside this
5113        /// anchor-centred box (`anchor ± extent`). Absent = every player the
5114        /// effect's audience addresses.
5115        #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5116        within: Option<StealthZone>,
5117        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
5118        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5119        requires_flags: Vec<FlagId>,
5120        /// Per-effect negative flag gate (DSL v0.6); see
5121        /// [`QuestEffect::forbids_flags`].
5122        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5123        forbids_flags: Vec<FlagId>,
5124        /// Numeric gate terms (DSL v0.10, spec-0031); see
5125        /// [`QuestEffect::requires_state`].
5126        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5127        requires_state: Vec<StateCompare>,
5128    },
5129    /// Removes a status effect (DSL v0.10, spec-0031) — vanilla's `effect clear`.
5130    ///
5131    /// This is **not** how a `give-effect` is supposed to end: a duration is. It
5132    /// exists for the effects the engine did not grant — a potion the player
5133    /// drank, a `wither` a mob applied, the whole set at a bonfire — which is why
5134    /// `effect` is optional (absent = clear everything, exactly as vanilla's
5135    /// `effect clear <targets>` does). Pairing it with a live grant of the same
5136    /// effect in the same bundle is `DW0540`.
5137    ClearEffect {
5138        /// The status-effect id to remove (`DW0192`). **Absent clears every
5139        /// effect**, matching `effect clear <targets>` with no id.
5140        #[serde(default, skip_serializing_if = "Option::is_none")]
5141        effect: Option<String>,
5142        /// Optional spatial filter, identical in shape and meaning to
5143        /// [`QuestEffect::GiveEffect`]'s.
5144        #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5145        within: Option<StealthZone>,
5146        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
5147        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5148        requires_flags: Vec<FlagId>,
5149        /// Per-effect negative flag gate (DSL v0.6); see
5150        /// [`QuestEffect::forbids_flags`].
5151        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5152        forbids_flags: Vec<FlagId>,
5153        /// Numeric gate terms (DSL v0.10, spec-0031); see
5154        /// [`QuestEffect::requires_state`].
5155        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5156        requires_state: Vec<StateCompare>,
5157    },
5158    /// Teleports **everything inside a declared volume** to an anchor (DSL v0.10,
5159    /// spec-0031).
5160    ///
5161    /// **The selector is a region, never a block.** "Whoever is standing on this
5162    /// block" has three different answers for a player half a foot over the edge,
5163    /// a player mid-jump and a player sneaking on the lip; a volume has one, and
5164    /// it is the same one every tick. `from` is the anchor-centred
5165    /// [`StealthZone`] box the rest of the engine already uses.
5166    ///
5167    /// **The selection is total over bodies.** Emission is a single `tp
5168    /// @e[<box>,tag=!dw_fixture] <cell>` with no `type=`, no `limit=` and no
5169    /// `sort=` — every body in the volume moves, and
5170    /// `crates/compiler/tests/v10_teleport.rs` asserts that from the emitted
5171    /// selector rather than from anyone's memory. A machinery-**type** exemption
5172    /// of the kind a `lethal_volumes[]` entry must carry was considered and
5173    /// **rejected**: an NPC is a body plus a co-located `minecraft:interaction`
5174    /// hitbox, so exempting `minecraft:interaction` — as the lethal volume does —
5175    /// would teleport the speaker and leave its dialogue box behind. Everyone on
5176    /// the car travels, players and entities
5177    /// alike; totality over bodies is how that is true.
5178    ///
5179    /// The one narrowing is a **class the engine's own furniture declares about
5180    /// itself**: `dw_fixture` means *my position IS engine state*. A bonfire's
5181    /// hitbox, a shortcut lever and a recovery stake's marker are places, not
5182    /// passengers, and carrying one does not move a thing — it rewrites a fact
5183    /// (for a stake, the ledger holds the marker's coordinates, and the next tick
5184    /// retires a marker nobody has a wager at). Places whose cell is known at
5185    /// compile time are refused outright instead, because the author can move
5186    /// them (`DW0542`); places the runtime puts down are excluded by the selector,
5187    /// because nobody can (`DW0545`). Nothing an author writes carries either tag,
5188    /// and no campaign JSON can turn either off.
5189    ///
5190    /// **A teleport is not a rescue.** Accumulated fall distance carries across
5191    /// one unchanged and is charged in full at the destination — measured Δ
5192    /// exactly `0.0000` in 46/46 trials on the pinned 1.21.11, including
5193    /// teleports 143 and 157 blocks straight *up*, with landing damage
5194    /// `floor(fall_distance) − 3` (`docs/notes/death-and-teleport-spike.md` §3).
5195    /// A platform that arrives under a falling player past ~20 blocks of fall is
5196    /// the surface they die on. The compiler does not try to reset the counter:
5197    /// what *does* reset it was explicitly not measured, and inventing a
5198    /// mechanism from recall is the folklore this project forbids.
5199    Teleport {
5200        /// The volume whose contents are moved, as an anchor-centred box
5201        /// (`anchor ± extent`) — the same shape a `begin-stealth` zone, a
5202        /// `damage-players` `in` filter and a `lethal_volumes[]` region take.
5203        ///
5204        /// Deliberately NOT a bare prefab `region` anchor, for the reason
5205        /// [`QuestEffect::Collapse`] records: the assembled model clears every
5206        /// gate-region anchor's cells, so a volume described that way would
5207        /// delete the geometry it names.
5208        from: StealthZone,
5209        /// The destination anchor. Resolved to a literal cell at build time, so
5210        /// the emitted `tp` carries absolute coordinates and no runtime search.
5211        to: AnchorId,
5212        /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
5213        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5214        requires_flags: Vec<FlagId>,
5215        /// Per-effect negative flag gate (DSL v0.6); see
5216        /// [`QuestEffect::forbids_flags`].
5217        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5218        forbids_flags: Vec<FlagId>,
5219        /// Numeric gate terms (DSL v0.10, spec-0031); see
5220        /// [`QuestEffect::requires_state`].
5221        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5222        requires_state: Vec<StateCompare>,
5223    },
5224}
5225
5226/// `Debug` is hand-written because it is a **stable content-key rendering**: the
5227/// compiler's `sequence_key` (FNV over `{steps:?}`, where a step's `effects` are
5228/// `QuestEffect`s) names generated `seq_<hash>` functions from it, so an effect
5229/// that uses none of the v0.6 additions (`forbids_flags` anywhere, `on_arrive`
5230/// on `move-npc`) must render byte-identically to the pre-addition derive —
5231/// otherwise every existing sequence would silently churn its function names on
5232/// a purely additive schema change (the [`CameraShot`] precedent). Rules:
5233/// every pre-existing field prints exactly as `#[derive(Debug)]` printed it (in
5234/// declaration order); `forbids_flags` prints only when non-empty; `move-npc`'s
5235/// `on_arrive` prints only when non-empty; and (DSL v0.10) `requires_state`
5236/// prints only when non-empty, on **every** variant that carries it — including
5237/// `volley` and `collapse`, whose other fields print unconditionally, because
5238/// what has to stay stable is what an existing campaign renders, and every
5239/// existing campaign's `requires_state` is empty.
5240///
5241/// It must print at all, though: two effects that differ only in their numeric
5242/// gate are different effects, and a rendering that omitted the difference would
5243/// collide their `seq_<hash>` function names.
5244impl std::fmt::Debug for QuestEffect {
5245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5246        /// Append `forbids_flags` only when non-empty (see the impl doc).
5247        fn ff<'c, 'a, 'b: 'a>(
5248            d: &'c mut std::fmt::DebugStruct<'a, 'b>,
5249            forbids_flags: &[FlagId],
5250        ) -> &'c mut std::fmt::DebugStruct<'a, 'b> {
5251            if forbids_flags.is_empty() {
5252                d
5253            } else {
5254                d.field("forbids_flags", &forbids_flags)
5255            }
5256        }
5257        /// Append `requires_state` only when non-empty (DSL v0.10; see the impl
5258        /// doc). Read off `self.requires_state()` rather than bound per arm, so
5259        /// the gate tail is written once for all twenty-one gated variants.
5260        fn rs<'c, 'a, 'b: 'a>(
5261            d: &'c mut std::fmt::DebugStruct<'a, 'b>,
5262            requires_state: &[StateCompare],
5263        ) -> &'c mut std::fmt::DebugStruct<'a, 'b> {
5264            if requires_state.is_empty() {
5265                d
5266            } else {
5267                d.field("requires_state", &requires_state)
5268            }
5269        }
5270        match self {
5271            QuestEffect::OpenGate {
5272                anchor,
5273                requires_flags,
5274                forbids_flags,
5275                ..
5276            } => rs(
5277                ff(
5278                    f.debug_struct("OpenGate")
5279                        .field("anchor", anchor)
5280                        .field("requires_flags", requires_flags),
5281                    forbids_flags,
5282                ),
5283                self.requires_state(),
5284            )
5285            .finish(),
5286            QuestEffect::CloseGate {
5287                anchor,
5288                requires_flags,
5289                forbids_flags,
5290                sealed_hint,
5291                ..
5292            } => {
5293                let mut s = f.debug_struct("CloseGate");
5294                let d = rs(
5295                    ff(
5296                        s.field("anchor", anchor)
5297                            .field("requires_flags", requires_flags),
5298                        forbids_flags,
5299                    ),
5300                    self.requires_state(),
5301                );
5302                // Prints only when authored (the additive-field rule): a campaign
5303                // that takes the compiler's canonical seal line renders exactly as
5304                // the pre-0.8 derive did, so no existing `seq_<hash>` moves.
5305                match sealed_hint {
5306                    Some(h) => d.field("sealed_hint", h).finish(),
5307                    None => d.finish(),
5308                }
5309            }
5310            QuestEffect::CampaignComplete { .. } => f.write_str("CampaignComplete"),
5311            QuestEffect::GiveItem {
5312                item,
5313                count,
5314                name,
5315                carrier,
5316                requires_flags,
5317                forbids_flags,
5318                ..
5319            } => rs(
5320                ff(
5321                    f.debug_struct("GiveItem")
5322                        .field("item", item)
5323                        .field("count", count)
5324                        .field("name", name)
5325                        .field("carrier", carrier)
5326                        .field("requires_flags", requires_flags),
5327                    forbids_flags,
5328                ),
5329                self.requires_state(),
5330            )
5331            .finish(),
5332            QuestEffect::SetFlag {
5333                flag,
5334                requires_flags,
5335                forbids_flags,
5336                ..
5337            } => rs(
5338                ff(
5339                    f.debug_struct("SetFlag")
5340                        .field("flag", flag)
5341                        .field("requires_flags", requires_flags),
5342                    forbids_flags,
5343                ),
5344                self.requires_state(),
5345            )
5346            .finish(),
5347            QuestEffect::SpawnWave {
5348                wave,
5349                requires_flags,
5350                forbids_flags,
5351                ..
5352            } => rs(
5353                ff(
5354                    f.debug_struct("SpawnWave")
5355                        .field("wave", wave)
5356                        .field("requires_flags", requires_flags),
5357                    forbids_flags,
5358                ),
5359                self.requires_state(),
5360            )
5361            .finish(),
5362            QuestEffect::Narrate {
5363                text,
5364                style,
5365                sound,
5366                requires_flags,
5367                forbids_flags,
5368                ..
5369            } => rs(
5370                ff(
5371                    f.debug_struct("Narrate")
5372                        .field("text", text)
5373                        .field("style", style)
5374                        .field("sound", sound)
5375                        .field("requires_flags", requires_flags),
5376                    forbids_flags,
5377                ),
5378                self.requires_state(),
5379            )
5380            .finish(),
5381            QuestEffect::SetBlock {
5382                anchor,
5383                block,
5384                requires_flags,
5385                forbids_flags,
5386                ..
5387            } => rs(
5388                ff(
5389                    f.debug_struct("SetBlock")
5390                        .field("anchor", anchor)
5391                        .field("block", block)
5392                        .field("requires_flags", requires_flags),
5393                    forbids_flags,
5394                ),
5395                self.requires_state(),
5396            )
5397            .finish(),
5398            QuestEffect::DespawnNpc {
5399                npc,
5400                requires_flags,
5401                forbids_flags,
5402                ..
5403            } => rs(
5404                ff(
5405                    f.debug_struct("DespawnNpc")
5406                        .field("npc", npc)
5407                        .field("requires_flags", requires_flags),
5408                    forbids_flags,
5409                ),
5410                self.requires_state(),
5411            )
5412            .finish(),
5413            QuestEffect::MoveNpc {
5414                npc,
5415                to_anchor,
5416                speed,
5417                on_arrive,
5418                requires_flags,
5419                forbids_flags,
5420                ..
5421            } => {
5422                let mut d = f.debug_struct("MoveNpc");
5423                d.field("npc", npc)
5424                    .field("to_anchor", to_anchor)
5425                    .field("speed", speed);
5426                if !on_arrive.is_empty() {
5427                    d.field("on_arrive", on_arrive);
5428                }
5429                d.field("requires_flags", requires_flags);
5430                rs(ff(&mut d, forbids_flags), self.requires_state()).finish()
5431            }
5432            QuestEffect::Cutscene {
5433                shots,
5434                path,
5435                seconds,
5436                look_at,
5437                requires_flags,
5438                forbids_flags,
5439                ..
5440            } => rs(
5441                ff(
5442                    f.debug_struct("Cutscene")
5443                        .field("shots", shots)
5444                        .field("path", path)
5445                        .field("seconds", seconds)
5446                        .field("look_at", look_at)
5447                        .field("requires_flags", requires_flags),
5448                    forbids_flags,
5449                ),
5450                self.requires_state(),
5451            )
5452            .finish(),
5453            QuestEffect::SetTime {
5454                time,
5455                requires_flags,
5456                forbids_flags,
5457                ..
5458            } => rs(
5459                ff(
5460                    f.debug_struct("SetTime")
5461                        .field("time", time)
5462                        .field("requires_flags", requires_flags),
5463                    forbids_flags,
5464                ),
5465                self.requires_state(),
5466            )
5467            .finish(),
5468            QuestEffect::SetWeather {
5469                weather,
5470                requires_flags,
5471                forbids_flags,
5472                ..
5473            } => rs(
5474                ff(
5475                    f.debug_struct("SetWeather")
5476                        .field("weather", weather)
5477                        .field("requires_flags", requires_flags),
5478                    forbids_flags,
5479                ),
5480                self.requires_state(),
5481            )
5482            .finish(),
5483            QuestEffect::PlaySound {
5484                sound,
5485                at,
5486                volume,
5487                pitch,
5488                requires_flags,
5489                forbids_flags,
5490                ..
5491            } => rs(
5492                ff(
5493                    f.debug_struct("PlaySound")
5494                        .field("sound", sound)
5495                        .field("at", at)
5496                        .field("volume", volume)
5497                        .field("pitch", pitch)
5498                        .field("requires_flags", requires_flags),
5499                    forbids_flags,
5500                ),
5501                self.requires_state(),
5502            )
5503            .finish(),
5504            QuestEffect::DamagePlayers {
5505                amount,
5506                within,
5507                damage_type,
5508                requires_flags,
5509                forbids_flags,
5510                ..
5511            } => rs(
5512                ff(
5513                    f.debug_struct("DamagePlayers")
5514                        .field("amount", amount)
5515                        .field("within", within)
5516                        .field("damage_type", damage_type)
5517                        .field("requires_flags", requires_flags),
5518                    forbids_flags,
5519                ),
5520                self.requires_state(),
5521            )
5522            .finish(),
5523            QuestEffect::SetCheckpoint { anchor, on_respawn } => f
5524                .debug_struct("SetCheckpoint")
5525                .field("anchor", anchor)
5526                .field("on_respawn", on_respawn)
5527                .finish(),
5528            QuestEffect::Bonfire {
5529                anchor,
5530                on_rest,
5531                prompt,
5532                rest_label,
5533                save_label,
5534            } => f
5535                .debug_struct("Bonfire")
5536                .field("anchor", anchor)
5537                .field("on_rest", on_rest)
5538                .field("prompt", prompt)
5539                .field("rest_label", rest_label)
5540                .field("save_label", save_label)
5541                .finish(),
5542            QuestEffect::BeginStealth {
5543                zones,
5544                on_caught,
5545                grace_ticks,
5546            } => f
5547                .debug_struct("BeginStealth")
5548                .field("zones", zones)
5549                .field("on_caught", on_caught)
5550                .field("grace_ticks", grace_ticks)
5551                .finish(),
5552            QuestEffect::EndStealth => f.write_str("EndStealth"),
5553            QuestEffect::SpawnNpc { npc, .. } => {
5554                f.debug_struct("SpawnNpc").field("npc", npc).finish()
5555            }
5556            QuestEffect::SpawnActor { actor, .. } => {
5557                f.debug_struct("SpawnActor").field("actor", actor).finish()
5558            }
5559            QuestEffect::DespawnActor { actor, style, .. } => f
5560                .debug_struct("DespawnActor")
5561                .field("actor", actor)
5562                .field("style", style)
5563                .finish(),
5564            QuestEffect::MoveActor {
5565                actor,
5566                to_anchor,
5567                speed,
5568                on_arrive,
5569                ..
5570            } => f
5571                .debug_struct("MoveActor")
5572                .field("actor", actor)
5573                .field("to_anchor", to_anchor)
5574                .field("speed", speed)
5575                .field("on_arrive", on_arrive)
5576                .finish(),
5577            QuestEffect::UnleashActor { actor, .. } => f
5578                .debug_struct("UnleashActor")
5579                .field("actor", actor)
5580                .finish(),
5581            QuestEffect::Sequence { steps } => {
5582                f.debug_struct("Sequence").field("steps", steps).finish()
5583            }
5584            // spec-0022 additions. These are NEW variants, so there is no
5585            // pre-addition rendering to preserve — every field prints
5586            // unconditionally, which is the stable choice going forward.
5587            QuestEffect::Volley {
5588                projectile,
5589                from_anchor,
5590                kill_zone,
5591                salvos,
5592                interval,
5593                requires_flags,
5594                forbids_flags,
5595                ..
5596            } => rs(
5597                f.debug_struct("Volley")
5598                    .field("projectile", projectile)
5599                    .field("from_anchor", from_anchor)
5600                    .field("kill_zone", kill_zone)
5601                    .field("salvos", salvos)
5602                    .field("interval", interval)
5603                    .field("requires_flags", requires_flags)
5604                    .field("forbids_flags", forbids_flags),
5605                self.requires_state(),
5606            )
5607            .finish(),
5608            QuestEffect::Collapse {
5609                region_anchor,
5610                falling_block,
5611                then_floor,
5612                requires_flags,
5613                forbids_flags,
5614                ..
5615            } => rs(
5616                f.debug_struct("Collapse")
5617                    .field("region_anchor", region_anchor)
5618                    .field("falling_block", falling_block)
5619                    .field("then_floor", then_floor)
5620                    .field("requires_flags", requires_flags)
5621                    .field("forbids_flags", forbids_flags),
5622                self.requires_state(),
5623            )
5624            .finish(),
5625            // DSL v0.10 (spec-0031). New variants, so there is no pre-addition
5626            // rendering to preserve: every authored field prints. The gate tail
5627            // still prints only when non-empty — one rendering rule for one
5628            // field, everywhere.
5629            QuestEffect::FillRegion {
5630                region,
5631                block,
5632                requires_flags,
5633                forbids_flags,
5634                ..
5635            } => rs(
5636                ff(
5637                    f.debug_struct("FillRegion")
5638                        .field("region", region)
5639                        .field("block", block)
5640                        .field("requires_flags", requires_flags),
5641                    forbids_flags,
5642                ),
5643                self.requires_state(),
5644            )
5645            .finish(),
5646            QuestEffect::ClearRegion {
5647                region,
5648                requires_flags,
5649                forbids_flags,
5650                ..
5651            } => rs(
5652                ff(
5653                    f.debug_struct("ClearRegion")
5654                        .field("region", region)
5655                        .field("requires_flags", requires_flags),
5656                    forbids_flags,
5657                ),
5658                self.requires_state(),
5659            )
5660            .finish(),
5661            // Both fields print: the reference IS the content of this effect, and
5662            // two `open-way`s on different ways of one piece emit different fills.
5663            QuestEffect::OpenWay {
5664                piece,
5665                way,
5666                requires_flags,
5667                forbids_flags,
5668                ..
5669            } => rs(
5670                ff(
5671                    f.debug_struct("OpenWay")
5672                        .field("piece", piece)
5673                        .field("way", way)
5674                        .field("requires_flags", requires_flags),
5675                    forbids_flags,
5676                ),
5677                self.requires_state(),
5678            )
5679            .finish(),
5680            QuestEffect::SetState {
5681                state,
5682                value,
5683                requires_flags,
5684                forbids_flags,
5685                ..
5686            } => rs(
5687                ff(
5688                    f.debug_struct("SetState")
5689                        .field("state", state)
5690                        .field("value", value)
5691                        .field("requires_flags", requires_flags),
5692                    forbids_flags,
5693                ),
5694                self.requires_state(),
5695            )
5696            .finish(),
5697            QuestEffect::AddState {
5698                state,
5699                amount,
5700                requires_flags,
5701                forbids_flags,
5702                ..
5703            } => rs(
5704                ff(
5705                    f.debug_struct("AddState")
5706                        .field("state", state)
5707                        .field("amount", amount)
5708                        .field("requires_flags", requires_flags),
5709                    forbids_flags,
5710                ),
5711                self.requires_state(),
5712            )
5713            .finish(),
5714            QuestEffect::ClearState {
5715                state,
5716                requires_flags,
5717                forbids_flags,
5718                ..
5719            } => rs(
5720                ff(
5721                    f.debug_struct("ClearState")
5722                        .field("state", state)
5723                        .field("requires_flags", requires_flags),
5724                    forbids_flags,
5725                ),
5726                self.requires_state(),
5727            )
5728            .finish(),
5729            QuestEffect::GiveEffect {
5730                effect,
5731                seconds,
5732                amplifier,
5733                hide_particles,
5734                within,
5735                requires_flags,
5736                forbids_flags,
5737                ..
5738            } => rs(
5739                ff(
5740                    f.debug_struct("GiveEffect")
5741                        .field("effect", effect)
5742                        .field("seconds", seconds)
5743                        .field("amplifier", amplifier)
5744                        .field("hide_particles", hide_particles)
5745                        .field("within", within)
5746                        .field("requires_flags", requires_flags),
5747                    forbids_flags,
5748                ),
5749                self.requires_state(),
5750            )
5751            .finish(),
5752            QuestEffect::ClearEffect {
5753                effect,
5754                within,
5755                requires_flags,
5756                forbids_flags,
5757                ..
5758            } => rs(
5759                ff(
5760                    f.debug_struct("ClearEffect")
5761                        .field("effect", effect)
5762                        .field("within", within)
5763                        .field("requires_flags", requires_flags),
5764                    forbids_flags,
5765                ),
5766                self.requires_state(),
5767            )
5768            .finish(),
5769            QuestEffect::Teleport {
5770                from,
5771                to,
5772                requires_flags,
5773                forbids_flags,
5774                ..
5775            } => rs(
5776                ff(
5777                    f.debug_struct("Teleport")
5778                        .field("from", from)
5779                        .field("to", to)
5780                        .field("requires_flags", requires_flags),
5781                    forbids_flags,
5782                ),
5783                self.requires_state(),
5784            )
5785            .finish(),
5786            QuestEffect::DropStake {
5787                stake,
5788                requires_flags,
5789                forbids_flags,
5790                ..
5791            } => rs(
5792                ff(
5793                    f.debug_struct("DropStake")
5794                        .field("stake", stake)
5795                        .field("requires_flags", requires_flags),
5796                    forbids_flags,
5797                ),
5798                self.requires_state(),
5799            )
5800            .finish(),
5801        }
5802    }
5803}
5804
5805/// Default `grace_ticks` for [`QuestEffect::BeginStealth`] (spec-0014).
5806fn default_grace_ticks() -> u32 {
5807    20
5808}
5809
5810/// Default projectile for [`QuestEffect::Volley`] (spec-0022).
5811pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
5812/// Default salvo count for [`QuestEffect::Volley`] (spec-0022).
5813pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
5814/// Default ticks between salvos for [`QuestEffect::Volley`] (spec-0022).
5815pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
5816/// Largest admissible `salvos` — beyond this a volley is an entity-count
5817/// hazard rather than a trap (`DW0443`).
5818pub const MAX_VOLLEY_SALVOS: u32 = 16;
5819/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
5820/// slower than this is no longer one event the player reads as a trap.
5821pub const MAX_VOLLEY_INTERVAL: u32 = 200;
5822/// Default falling block for [`QuestEffect::Collapse`] (spec-0022).
5823pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
5824
5825/// A stealth "shadow" region (DSL v0.6, spec-0014): an axis-aligned box centred
5826/// on `anchor`, extending `extent` blocks along each axis (so the box spans
5827/// `anchor ± extent`). Presented in-world via dark cells but judged purely by
5828/// region membership, so the check is deterministic and provable.
5829#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5830#[serde(deny_unknown_fields)]
5831pub struct StealthZone {
5832    /// The prefab anchor at the centre of the zone box.
5833    pub anchor: AnchorId,
5834    /// Half-extents `[x, y, z]` in blocks from the anchor (each component ≥ 0);
5835    /// the zone AABB is `[anchor - extent, anchor + extent]`.
5836    pub extent: [u32; 3],
5837}
5838
5839/// Where a [`QuestEffect::PlaySound`] originates (DSL v0.6, spec-0014). A sound
5840/// plays at fixed coordinates or at each listener's own position; the compiler
5841/// resolves no position for a live actor, so the `actor` variant is accepted by
5842/// the schema and rejected with `DW0335`.
5843#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5844#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
5845pub enum SoundAt {
5846    /// Play the sound positioned at a resolved anchor, audible to all players.
5847    Anchor {
5848        /// The anchor the sound plays from.
5849        anchor: AnchorId,
5850    },
5851    /// Play the sound at each player's own position (the default).
5852    Players,
5853    /// Play the sound at a scripted actor's position (rejected — `DW0335`; no
5854    /// actor position resolves at emission).
5855    Actor {
5856        /// The actor id (stage-5 `actors[]`).
5857        actor: String,
5858    },
5859}
5860
5861/// The damage type of a [`QuestEffect::DamagePlayers`] effect (DSL v0.6). A
5862/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
5863/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
5864/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
5865/// `generic_kill` types are deliberately excluded, so a scripted consequence can
5866/// never silently void a player's held totem. Modelled as an enum (not a free
5867/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
5868/// registry / diagnostic. `generic` is the default: command damage that respects
5869/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
5870#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5871#[serde(rename_all = "kebab-case")]
5872pub enum DamageKind {
5873    /// `minecraft:generic` — armor-ignoring command damage (default).
5874    Generic,
5875    /// `minecraft:magic` — magical damage.
5876    Magic,
5877    /// `minecraft:wither` — the wither/withering effect's damage type.
5878    Wither,
5879    /// `minecraft:on_fire` — burning damage.
5880    Fire,
5881    /// `minecraft:drown` — drowning damage.
5882    Drown,
5883    /// `minecraft:freeze` — powder-snow freezing damage.
5884    Freeze,
5885    /// `minecraft:fall` — fall damage.
5886    Fall,
5887    /// `minecraft:lightning_bolt` — a lightning strike's damage type.
5888    LightningBolt,
5889    /// `minecraft:explosion` — a (non-player) explosion's damage type.
5890    Explosion,
5891}
5892
5893impl DamageKind {
5894    /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
5895    pub fn id(self) -> &'static str {
5896        match self {
5897            DamageKind::Generic => "minecraft:generic",
5898            DamageKind::Magic => "minecraft:magic",
5899            DamageKind::Wither => "minecraft:wither",
5900            DamageKind::Fire => "minecraft:on_fire",
5901            DamageKind::Drown => "minecraft:drown",
5902            DamageKind::Freeze => "minecraft:freeze",
5903            DamageKind::Fall => "minecraft:fall",
5904            DamageKind::LightningBolt => "minecraft:lightning_bolt",
5905            DamageKind::Explosion => "minecraft:explosion",
5906        }
5907    }
5908}
5909
5910/// A stage-5 **lethal volume** (DSL v0.10, spec-0031): a declared box that kills
5911/// whatever enters it, and states — in the campaign's own words — what killed it.
5912///
5913/// # A mechanism, not a fiction
5914///
5915/// The commissioning case was a cliff whose fall must be fatal, but nothing here
5916/// knows what a cliff is: a lava pit, an acid pool, an out-of-bounds plane and the
5917/// bottom of a lift shaft are the same declaration, differently dressed and
5918/// differently worded. The alternative considered and **rejected** for the cliff
5919/// was making the world's horizon void so the fall kills anyway — that changes
5920/// approved art to obtain a behaviour, and it serves exactly one fiction.
5921///
5922/// # It is geometry, so the completability proof owns it
5923///
5924/// A volume that kills is, for a route, a volume that cannot be crossed. The
5925/// compiler models its cells as impassable in the same navigation world every
5926/// other reachability proof runs on, exactly as a `close-gate`'s sealed region is
5927/// modelled solid — so a forced path that has no way to an objective except
5928/// through a lethal volume is a build failure (`DW0510`) naming the volume, never
5929/// a shipped delve that kills the player on the critical path. A respawn seat
5930/// inside one is the death loop that failure mode ends in, and is its own
5931/// error (`DW0511`).
5932///
5933/// # It rides the death edge that already exists
5934///
5935/// The kill is a `/damage`, exactly like `damage-players`, a trap payload or a
5936/// timed gate's crush. Everything downstream — the vanilla `deathCount` edge
5937/// (`dw.deaths` / `dw.death_ack`), the checkpoint re-seat (`cp_respawn_check`),
5938/// `keep_inventory` — sees an ordinary death and needs no second detector.
5939#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5940#[serde(deny_unknown_fields)]
5941pub struct LethalVolume {
5942    /// Unique lethal-volume id (`lethal/<kebab>`).
5943    pub id: LethalVolumeId,
5944    /// The volume: an anchor-centred box (`anchor ± extent`).
5945    ///
5946    /// **Deliberately the existing zone type**, not a second struct with the same
5947    /// two fields. `StealthZone` is already the engine's anchor-centred box object
5948    /// class — `damage-players`'s `in` filter reuses it, and the compiler resolves
5949    /// every one of them through the single `Plan::zone_box`. A private twin here
5950    /// would be `tools/check-capability-ownership.py` check C by construction, and
5951    /// would fork the resolution the very next time a box grew a capability.
5952    pub region: StealthZone,
5953    /// What this volume says when it kills — a player-visible line, inventoried
5954    /// under `lethal.<id>.message` and translated like every other one.
5955    ///
5956    /// Required, and deliberately so: a volume with no words is a player who dies
5957    /// with no idea why, and there is no compiler-owned default that could be
5958    /// right for a cliff, a lava pit and an acid pool at once.
5959    pub message: String,
5960    /// The damage type the kill is dealt with (default [`DamageKind::Generic`]).
5961    ///
5962    /// This is what words vanilla's own broadcast — `fall` says the party member
5963    /// fell from a high place, `on_fire` that they burnt to a crisp — while
5964    /// [`Self::message`] says what the *place* was. The curated enum is shared
5965    /// with `damage-players`, so a lethal volume can no more void a held totem
5966    /// than a scripted hit can.
5967    #[serde(default, skip_serializing_if = "Option::is_none")]
5968    pub damage_type: Option<DamageKind>,
5969}
5970
5971// ---------------------------------------------------------------------------
5972// Stage 5 — trade and the recovery stake (DSL v0.10, spec-0032)
5973// ---------------------------------------------------------------------------
5974
5975/// A stage-5 **shop** (DSL v0.10, spec-0032): an interaction point that opens a
5976/// list of offers, each of which is a gate and a bundle of effects.
5977///
5978/// # It is the rest flow with different buttons, and that is the whole design
5979///
5980/// A bonfire is already an interaction entity, a player-interaction advancement
5981/// that supplies the acting player, a `minecraft:multi_action` dialog, buttons
5982/// that run `/trigger`, and tick dispatch. A shop is that same hardware with an
5983/// authored button list, so nothing here is new machinery — it is the machinery
5984/// spec-0016 §1 shipped, made author-visible.
5985///
5986/// # There is no price field, and that is deliberate
5987///
5988/// A price is *"may this happen yet?"*, which is the question
5989/// [`Gate`](crate::gate::Gate) answers for six other object classes. An offer is
5990/// therefore the **seventh gate consumer**: it carries `requires_flags`,
5991/// `forbids_flags` and `requires_state`, and the numeric comparison it needs is
5992/// the one spec-0031 put in the gate rather than in the verb that first asked.
5993/// A `price` field would be a second comparison surface for one meaning, which is
5994/// the defect CLAUDE.md names first.
5995///
5996/// # Villager `Offers` is excluded
5997///
5998/// Three independent reasons, any one sufficient (spec-0032): a vanilla trade
5999/// cost can only ever be an item, never a scoreboard value; right-click on a
6000/// villager body is already allocated to dialogue; and the data-driven trade
6001/// registry post-dates the pinned 1.21.11.
6002#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6003#[serde(deny_unknown_fields)]
6004pub struct Shop {
6005    /// Unique shop id (`shop/<kebab>`).
6006    pub id: ShopId,
6007    /// The prefab anchor the shop's interaction point stands on.
6008    pub anchor: AnchorId,
6009    /// The dialog's title line. Player-visible, inventoried under
6010    /// `shop.<id>.title`.
6011    pub title: String,
6012    /// The item the visible marker renders as (default `minecraft:emerald`).
6013    /// Cosmetic only: the marker is a `minecraft:item_display`, so it never
6014    /// occupies the cell or obstructs the hitbox.
6015    #[serde(default, skip_serializing_if = "Option::is_none")]
6016    pub marker_item: Option<String>,
6017    /// The offers, in declaration order — which is button order, and the order
6018    /// the `/trigger` routing values are assigned in.
6019    pub offers: Vec<ShopOffer>,
6020}
6021
6022/// One button in a [`Shop`]: a gate, a label, and the effects choosing it runs.
6023///
6024/// **Effect root R8.** The bundle hangs off an object with runtime machinery of
6025/// its own (an advancement, a dialog, a trigger objective and a tick dispatch),
6026/// which is spec-0031's stated rule for adding a root rather than desugaring.
6027///
6028/// **Refusal is authored, not a field.** An offer whose own gate is closed is not
6029/// shown at all; an offer that should be *shown and refuse* leaves its own gate
6030/// open and gates its effects instead — the purchase behind `at-least <price>`,
6031/// the apology behind `at-most <price − 1>`. Both are the ordinary per-effect
6032/// gate every effect already carries, so the engine adds no `refused` field and
6033/// no second way to say one thing.
6034#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6035#[serde(deny_unknown_fields)]
6036pub struct ShopOffer {
6037    /// The button's caption. Player-visible, inventoried under
6038    /// `shop.<id>.offer.<i>.label`, and width-checked like any dialog button.
6039    pub label: String,
6040    /// The button's hover tooltip — the natural home for a price in words.
6041    /// Player-visible, inventoried under `shop.<id>.offer.<i>.tooltip`.
6042    #[serde(default, skip_serializing_if = "Option::is_none")]
6043    pub tooltip: Option<String>,
6044    /// Flags that must all be set for this offer to be shown (the one gate).
6045    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6046    pub requires_flags: Vec<FlagId>,
6047    /// Flags whose being set hides this offer (the one gate).
6048    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6049    pub forbids_flags: Vec<FlagId>,
6050    /// Numeric gate terms (the one gate) — **this is where a price lives**.
6051    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6052    pub requires_state: Vec<StateCompare>,
6053    /// What choosing this offer does. Effect root R8; runs with the choosing
6054    /// player as the acting player, so a `player`-scoped datum is writable here.
6055    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6056    pub effects: Vec<QuestEffect>,
6057}
6058
6059impl ShopOffer {
6060    /// This offer's whole gate, as one value (DSL v0.10) — see
6061    /// [`crate::gate::Gate`].
6062    pub fn gate(&self) -> crate::gate::Gate<'_> {
6063        crate::gate::Gate::of(
6064            &self.requires_flags,
6065            &self.forbids_flags,
6066            &self.requires_state,
6067        )
6068    }
6069}
6070
6071/// How much of a datum a death forfeits into a [`Stake`] (DSL v0.10, spec-0032).
6072///
6073/// A creator who wants **no death cost at all** picks [`Forfeit::None`]; the
6074/// stake then still marks where they fell, which is the memorial case.
6075#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6076#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
6077pub enum Forfeit {
6078    /// The whole balance.
6079    All,
6080    /// A percentage of the balance, rounded toward zero (integer arithmetic —
6081    /// ADR-0006 forbids anything a second implementation could round differently).
6082    Proportion {
6083        /// 0–100.
6084        percent: u32,
6085    },
6086    /// A fixed amount, capped at the balance so a purse can never go negative.
6087    Fixed {
6088        /// The amount.
6089        amount: i32,
6090    },
6091    /// Nothing is taken. The stake is a marker, not a wager.
6092    None,
6093}
6094
6095/// What a death does when the player already holds the maximum number of live
6096/// stakes (DSL v0.10, spec-0032).
6097#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6098#[serde(rename_all = "kebab-case")]
6099pub enum OnFull {
6100    /// Retire the player's oldest live stake — its contents are lost — and place a
6101    /// new one. With `max_live: 1` this is the souls behaviour.
6102    Replace,
6103    /// Leave the existing stakes alone: the new death places nothing and forfeits
6104    /// nothing. One wager at a time.
6105    Keep,
6106}
6107
6108impl OnFull {
6109    /// The wire token (`replace` / `keep`).
6110    pub fn token(self) -> &'static str {
6111        match self {
6112            OnFull::Replace => "replace",
6113            OnFull::Keep => "keep",
6114        }
6115    }
6116}
6117
6118/// Who may collect a stake that is not theirs (DSL v0.10, spec-0032).
6119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6120#[serde(rename_all = "kebab-case")]
6121pub enum CollectBy {
6122    /// Only the player who left it. The default.
6123    Owner,
6124    /// Anyone standing at it. The whole of every live stake at that place is
6125    /// transferred to the collector.
6126    Anyone,
6127}
6128
6129impl CollectBy {
6130    /// The wire token (`owner` / `anyone`).
6131    pub fn token(self) -> &'static str {
6132        match self {
6133            CollectBy::Owner => "owner",
6134            CollectBy::Anyone => "anyone",
6135        }
6136    }
6137}
6138
6139/// A stage-5 **recovery stake** (DSL v0.10, spec-0032): what a death leaves
6140/// behind, and the one chance to get it back.
6141///
6142/// # A mechanism, not a genre
6143///
6144/// "Souls" is one setting of this declaration and nothing here knows the word.
6145/// The mechanism is *a death forfeits some of a declared datum into a collectable
6146/// marker at a computed place, and collecting it returns exactly what was taken*.
6147/// A campaign with no death cost, a campaign with a permanent memorial at every
6148/// death site, and a campaign with the souls loop are three configurations, not
6149/// three engines.
6150///
6151/// # Where it lands is a compile-time answer
6152///
6153/// The owner's rule: *the anchor is the point, on the walkable path
6154/// from the respawn point in force at the moment of death to the death point
6155/// under the quest state in force at that moment, that minimises distance to the
6156/// death point.* Every term already has an owner in the compiler — walkability is
6157/// the navigation world the completability proof runs on, quest-state passability
6158/// is the DAG-indexed sealing `close-gate` established, and the respawn point in
6159/// force is engine state. So it is a table computed at build time and a lookup at
6160/// run time: **no runtime search, no nondeterminism.**
6161///
6162/// # It is hardware, so it inherits the hardware rules
6163///
6164/// The marker is an invisible `minecraft:interaction` for the hitbox plus a
6165/// glowing `minecraft:item_display` for the rendering — the same pair every other
6166/// affordance in the engine uses, and therefore the same invisible-affordance
6167/// (`DW0420`) and hardware-erasure (`DW0421`) proofs. It is deliberately **not**
6168/// a dropped item entity: an item despawns after 6000 ticks, burns in lava, sinks
6169/// in the void, and can be picked up by anyone.
6170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6171#[serde(deny_unknown_fields)]
6172pub struct Stake {
6173    /// Unique stake id (`stake/<kebab>`).
6174    pub id: StakeId,
6175    /// The datum a death forfeits into this stake, and a collection returns.
6176    /// Must be declared in the stage-5 `state` list, and must be `player`-scoped:
6177    /// a stake is a personal wager, and a party-shared purse would turn one
6178    /// player's death into everyone's penalty.
6179    pub state: StateId,
6180    /// How much of [`Self::state`] a death takes. Default: the whole balance.
6181    #[serde(default, skip_serializing_if = "Option::is_none")]
6182    pub forfeit: Option<Forfeit>,
6183    /// How many live stakes one player may hold at once. Default 1; `0` means a
6184    /// death never places one (and never forfeits anything), which is the
6185    /// "no death cost" configuration.
6186    #[serde(default, skip_serializing_if = "Option::is_none")]
6187    pub max_live: Option<u32>,
6188    /// What a death does when the player is already at [`Self::max_live`].
6189    /// Default [`OnFull::Replace`].
6190    #[serde(default, skip_serializing_if = "Option::is_none")]
6191    pub on_full: Option<OnFull>,
6192    /// Who may collect. Default [`CollectBy::Owner`].
6193    #[serde(default, skip_serializing_if = "Option::is_none")]
6194    pub collect_by: Option<CollectBy>,
6195    /// The line a collection says, on the action bar. Player-visible, inventoried
6196    /// under `stake.<id>.collected`.
6197    pub collected_message: String,
6198    /// The item the glowing marker renders as (default `minecraft:soul_lantern`).
6199    #[serde(default, skip_serializing_if = "Option::is_none")]
6200    pub marker_item: Option<String>,
6201}
6202
6203impl Stake {
6204    /// The forfeit rule, with the documented default applied.
6205    pub fn forfeit(&self) -> Forfeit {
6206        self.forfeit.unwrap_or(Forfeit::All)
6207    }
6208
6209    /// How many live stakes one player may hold, with the documented default.
6210    pub fn max_live(&self) -> u32 {
6211        self.max_live.unwrap_or(1)
6212    }
6213
6214    /// The at-capacity rule, with the documented default.
6215    pub fn on_full(&self) -> OnFull {
6216        self.on_full.unwrap_or(OnFull::Replace)
6217    }
6218
6219    /// The collection rule, with the documented default.
6220    pub fn collect_by(&self) -> CollectBy {
6221        self.collect_by.unwrap_or(CollectBy::Owner)
6222    }
6223
6224    /// The item the marker renders as, with the documented default.
6225    pub fn marker_item(&self) -> &str {
6226        self.marker_item
6227            .as_deref()
6228            .unwrap_or("minecraft:soul_lantern")
6229    }
6230}
6231
6232/// The presentation channel for a [`QuestEffect::Narrate`] (DSL v0.4).
6233#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6234#[serde(rename_all = "kebab-case")]
6235pub enum NarrateStyle {
6236    /// A chat line (default).
6237    Chat,
6238    /// A large on-screen title.
6239    Title,
6240    /// An on-screen subtitle.
6241    Subtitle,
6242    /// An "art" title rendered through the delve's custom resource-pack pixel-banner
6243    /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
6244    /// spec-0014). Text is checked at compile time against the font's glyph inventory
6245    /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
6246    /// renders in the vanilla title slot, so it is width-checked like any title
6247    /// (`DW0330`) — roughly 15 glyphs fit on screen.
6248    Art,
6249    /// The **actionbar** — the one-line strip above the hotbar (DSL v0.11).
6250    ///
6251    /// This is the channel a *reply* uses: it does not interrupt, it does not
6252    /// stack, and it is overwritten by the next one. Every reply the compiler
6253    /// itself writes has always used it — a sealed gate's answer, a checkpoint
6254    /// return, the lobby's party count — but `narrate` could not reach it, which
6255    /// is the mechanical reason `close-gate.sealed_hint` could not have been an
6256    /// ordinary `narrate` even had someone tried (capability-ownership audit
6257    /// finding 3b). A channel is a property of the message, not of the verb that
6258    /// first wanted it.
6259    ///
6260    /// Unlike a title it is never width-checked: vanilla truncates nothing and
6261    /// draws it at GUI width, and a reply is a fragment rather than a banner.
6262    Actionbar,
6263}
6264
6265impl NarrateStyle {
6266    /// The kebab tag (`chat` / `title` / `subtitle` / `art` / `actionbar`).
6267    pub fn token(self) -> &'static str {
6268        match self {
6269            NarrateStyle::Chat => "chat",
6270            NarrateStyle::Title => "title",
6271            NarrateStyle::Subtitle => "subtitle",
6272            NarrateStyle::Art => "art",
6273            NarrateStyle::Actionbar => "actionbar",
6274        }
6275    }
6276}
6277
6278/// One camera waypoint of a [`QuestEffect::Cutscene`] (DSL v0.4): an anchor plus
6279/// an integer block offset from it, giving the camera's world position.
6280#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6281#[serde(deny_unknown_fields)]
6282pub struct CameraWaypoint {
6283    /// The anchor the waypoint is relative to.
6284    pub anchor: AnchorId,
6285    /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
6286    #[serde(default, skip_serializing_if = "is_zero3")]
6287    pub offset: [i32; 3],
6288}
6289
6290/// One shot of a [`QuestEffect::Cutscene`] (DSL v0.6): a camera dolly with its
6291/// own duration and optional subject. A cutscene plays its shots back-to-back —
6292/// a hard cut between them — inside a single gamemode/position save-restore
6293/// bracket, so a wide establishing move can be followed by an interior close-up
6294/// without the players ever leaving the cinematic.
6295#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6296#[serde(deny_unknown_fields)]
6297pub struct CameraShot {
6298    /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
6299    /// path is a static shot. Required without `shot_style`; with one, optional —
6300    /// an explicit `path` always overrides the style's expanded dolly.
6301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6302    pub path: Vec<CameraWaypoint>,
6303    /// This shot's duration in seconds. Required without `shot_style`; with one,
6304    /// optional — the style's default duration applies (see
6305    /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
6306    #[serde(default, skip_serializing_if = "Option::is_none")]
6307    pub seconds: Option<u32>,
6308    /// Optional subject the camera keeps framed for this shot. Absent = face
6309    /// along the direction of travel (or, under a `shot_style`, the style's own
6310    /// aim at its `subject`). An explicit `look_at` always overrides a style's
6311    /// aim.
6312    #[serde(default, skip_serializing_if = "Option::is_none")]
6313    pub look_at: Option<CameraTarget>,
6314    /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
6315    /// compiler expands the style deterministically into a camera dolly +
6316    /// per-keyframe aim from the `subject`'s resolved geometry. Requires
6317    /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
6318    /// the corresponding expanded part (`DW0348` polices the combinations).
6319    #[serde(default, skip_serializing_if = "Option::is_none")]
6320    pub shot_style: Option<ShotStyle>,
6321    /// The styled shot's subject — what the shot is *about*. Required with
6322    /// `shot_style`, rejected without one (`DW0348`).
6323    #[serde(default, skip_serializing_if = "Option::is_none")]
6324    pub subject: Option<CameraSubject>,
6325    /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
6326    #[serde(default, skip_serializing_if = "Option::is_none")]
6327    pub subject_b: Option<CameraSubject>,
6328    /// Styled shots only: the style's characteristic camera distance in blocks
6329    /// (its *start* distance for the dolly styles). Default per style — see the
6330    /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
6331    /// (`DW0348`).
6332    #[serde(default, skip_serializing_if = "Option::is_none")]
6333    pub dist: Option<f64>,
6334    /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
6335    /// default 90 (`DW0348` elsewhere or out of range).
6336    #[serde(default, skip_serializing_if = "Option::is_none")]
6337    pub degrees: Option<f64>,
6338    /// Styled shots only: placement bearing in degrees — where the camera sits
6339    /// (or starts) relative to the subject, measured like a Minecraft yaw
6340    /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
6341    /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
6342    /// `side-track` the bearing picks which side of the subject's travel the
6343    /// camera runs abeam (`0` = right of travel, `180` = left).
6344    #[serde(default, skip_serializing_if = "Option::is_none")]
6345    pub bearing: Option<f64>,
6346}
6347
6348/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
6349/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
6350/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
6351/// is **distance only** — vanilla has no in-game FOV control.
6352#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6353#[serde(rename_all = "kebab-case")]
6354pub enum ShotStyle {
6355    /// Fully static close framing of a prop/detail. The beat that always looks
6356    /// right.
6357    Insert,
6358    /// Static position; only the aim turns as the subject (ideally moving)
6359    /// passes. Rockstar's roadside "ground view".
6360    LockedOff,
6361    /// Straight dolly toward the subject along the view axis; medium → close.
6362    PushIn,
6363    /// Reverse of `push-in`: close → wide, revealing context.
6364    PullBackReveal,
6365    /// High and far, descending and closing on the subject. First sight of an
6366    /// area.
6367    EstablishingCrane,
6368    /// Constant-radius, constant-height arc around the subject.
6369    OrbitArc,
6370    /// Parallel dolly abeam a **moving** subject at constant offset — needs a
6371    /// compiler-known subject path (`DW0349`).
6372    SideTrack,
6373    /// Static placement solved so **both** subjects land on opposite thirds
6374    /// (Toric-space construction). Needs `subject_b`.
6375    TwoShot,
6376    /// Low, close, trailing a **moving** subject near ground level — needs a
6377    /// compiler-known subject path (`DW0349`).
6378    LowFollow,
6379}
6380
6381impl ShotStyle {
6382    /// The kebab token (diagnostics, digests).
6383    pub fn token(self) -> &'static str {
6384        match self {
6385            ShotStyle::Insert => "insert",
6386            ShotStyle::LockedOff => "locked-off",
6387            ShotStyle::PushIn => "push-in",
6388            ShotStyle::PullBackReveal => "pull-back-reveal",
6389            ShotStyle::EstablishingCrane => "establishing-crane",
6390            ShotStyle::OrbitArc => "orbit-arc",
6391            ShotStyle::SideTrack => "side-track",
6392            ShotStyle::TwoShot => "two-shot",
6393            ShotStyle::LowFollow => "low-follow",
6394        }
6395    }
6396
6397    /// Default shot duration in seconds, from the dossier's per-style duration
6398    /// ranges (§2, anchored on the film-editing ASL literature) — the value an
6399    /// omitted `seconds` resolves to.
6400    pub fn default_seconds(self) -> u32 {
6401        match self {
6402            ShotStyle::Insert => 2,
6403            ShotStyle::LockedOff => 6,
6404            ShotStyle::PushIn => 4,
6405            ShotStyle::PullBackReveal => 6,
6406            ShotStyle::EstablishingCrane => 8,
6407            ShotStyle::OrbitArc => 8,
6408            ShotStyle::SideTrack => 8,
6409            ShotStyle::TwoShot => 5,
6410            ShotStyle::LowFollow => 5,
6411        }
6412    }
6413
6414    /// `true` for the styles whose subject must be *moving* on a compiler-known
6415    /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
6416    /// `side-track` and `low-follow` (`DW0349` otherwise).
6417    pub fn needs_moving_subject(self) -> bool {
6418        matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
6419    }
6420}
6421
6422/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
6423/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
6424/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
6425/// **plus one block up** (torso height, so a close shot does not frame feet)
6426/// before `offset` is applied; an `anchor` subject aims at the block centre
6427/// exactly like a [`CameraTarget`].
6428/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
6429/// — serde has no variant-level `deny_unknown_fields`, so an untagged
6430/// enum with inline struct variants silently *ignores* any key it does not
6431/// recognise: `{"npc": …, "ofset": [0,1,0]}` would deserialize happily with the
6432/// offset dropped, and `{"anchor": …, "npc": …}` would quietly match `Anchor`
6433/// and discard the NPC. Lifting each variant into its own type keeps the repo-wide
6434/// deny-unknown rule for both serde and the published JSON Schema
6435/// (`additionalProperties: false`): a mistyped shot subject fails the schema
6436/// instead of rendering a shot pointed somewhere the author never asked for.
6437#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6438#[serde(untagged)]
6439pub enum CameraSubject {
6440    /// A fixed world point: prefab anchor + offset.
6441    Anchor(AnchorSubject),
6442    /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
6443    /// group / sequence, else static at its declared (or spawn) anchor.
6444    Npc(NpcSubject),
6445    /// A stage-5 actor — moving if a `move-actor` for it runs in the same
6446    /// effect group / sequence, else static at its declared anchor.
6447    Actor(ActorSubject),
6448}
6449
6450/// A [`CameraSubject::Anchor`] payload: a fixed world point.
6451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6452#[serde(deny_unknown_fields)]
6453pub struct AnchorSubject {
6454    /// The anchor the subject sits at.
6455    pub anchor: AnchorId,
6456    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
6457    #[serde(default, skip_serializing_if = "is_zero3")]
6458    pub offset: [i32; 3],
6459}
6460
6461/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
6462#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6463#[serde(deny_unknown_fields)]
6464pub struct NpcSubject {
6465    /// The NPC (stage-2 ref).
6466    pub npc: NpcId,
6467    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
6468    #[serde(default, skip_serializing_if = "is_zero3")]
6469    pub offset: [i32; 3],
6470}
6471
6472/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
6473#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6474#[serde(deny_unknown_fields)]
6475pub struct ActorSubject {
6476    /// The actor (stage-5 `actors` ref).
6477    pub actor: ActorId,
6478    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
6479    #[serde(default, skip_serializing_if = "is_zero3")]
6480    pub offset: [i32; 3],
6481}
6482
6483impl CameraSubject {
6484    /// The subject's integer offset, whichever variant.
6485    pub fn offset(&self) -> [i32; 3] {
6486        match self {
6487            CameraSubject::Anchor(s) => s.offset,
6488            CameraSubject::Npc(s) => s.offset,
6489            CameraSubject::Actor(s) => s.offset,
6490        }
6491    }
6492
6493    /// A short canonical rendering for digests/diagnostics.
6494    pub fn canon(&self) -> String {
6495        let (kind, id, o) = match self {
6496            CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
6497            CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
6498            CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
6499        };
6500        format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
6501    }
6502}
6503
6504/// `Debug` is hand-written because it is a **stable content-key rendering**:
6505/// the compiler's `sequence_key` (FNV over `{steps:?}`) names generated
6506/// `seq_<hash>` functions from it, so a shot that uses none of the v0.6 style
6507/// fields must render byte-identically to the pre-style struct (`seconds`
6508/// prints its inner value; absent style fields print nothing) — otherwise
6509/// every existing sequence containing a cutscene would silently churn its
6510/// function names on a purely additive schema change.
6511impl std::fmt::Debug for CameraShot {
6512    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6513        let mut d = f.debug_struct("CameraShot");
6514        d.field("path", &self.path);
6515        match self.seconds {
6516            Some(v) => d.field("seconds", &v),
6517            None => d.field("seconds", &self.seconds),
6518        };
6519        d.field("look_at", &self.look_at);
6520        if self.shot_style.is_some() {
6521            d.field("shot_style", &self.shot_style);
6522        }
6523        if self.subject.is_some() {
6524            d.field("subject", &self.subject);
6525        }
6526        if self.subject_b.is_some() {
6527            d.field("subject_b", &self.subject_b);
6528        }
6529        if self.dist.is_some() {
6530            d.field("dist", &self.dist);
6531        }
6532        if self.degrees.is_some() {
6533            d.field("degrees", &self.degrees);
6534        }
6535        if self.bearing.is_some() {
6536            d.field("bearing", &self.bearing);
6537        }
6538        d.finish()
6539    }
6540}
6541
6542impl CameraShot {
6543    /// The shot's resolved duration in seconds: explicit `seconds`, else the
6544    /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
6545    /// fallback only keeps downstream passes total).
6546    pub fn resolved_seconds(&self) -> u32 {
6547        self.seconds
6548            .or(self.shot_style.map(ShotStyle::default_seconds))
6549            .unwrap_or(1)
6550    }
6551}
6552
6553/// The subject a [`QuestEffect::Cutscene`] camera keeps framed (DSL v0.6): an
6554/// anchor plus an integer block offset from it, giving the world point every
6555/// dolly camera is aimed at. Same shape as a [`CameraWaypoint`] — a waypoint says
6556/// where the camera *is*, a target says what it *looks at*.
6557#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6558#[serde(deny_unknown_fields)]
6559pub struct CameraTarget {
6560    /// The anchor the look target is relative to.
6561    pub anchor: AnchorId,
6562    /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
6563    #[serde(default, skip_serializing_if = "is_zero3")]
6564    pub offset: [i32; 3],
6565}
6566
6567/// serde `skip_serializing_if` helper: skip a `[0, 0, 0]` offset.
6568fn is_zero3(v: &[i32; 3]) -> bool {
6569    *v == [0, 0, 0]
6570}
6571
6572impl QuestEffect {
6573    /// The kebab-case `type` tag this effect serializes as — the verb a
6574    /// diagnostic should name so the author can find it in the JSON.
6575    pub fn verb(&self) -> &'static str {
6576        match self {
6577            QuestEffect::OpenGate { .. } => "open-gate",
6578            QuestEffect::CloseGate { .. } => "close-gate",
6579            QuestEffect::CampaignComplete { .. } => "campaign-complete",
6580            QuestEffect::GiveItem { .. } => "give-item",
6581            QuestEffect::SetFlag { .. } => "set-flag",
6582            QuestEffect::SetState { .. } => "set-state",
6583            QuestEffect::AddState { .. } => "add-state",
6584            QuestEffect::ClearState { .. } => "clear-state",
6585            QuestEffect::DropStake { .. } => "drop-stake",
6586            QuestEffect::SpawnWave { .. } => "spawn-wave",
6587            QuestEffect::Narrate { .. } => "narrate",
6588            QuestEffect::SetBlock { .. } => "set-block",
6589            QuestEffect::FillRegion { .. } => "fill-region",
6590            QuestEffect::ClearRegion { .. } => "clear-region",
6591            QuestEffect::OpenWay { .. } => "open-way",
6592            QuestEffect::DespawnNpc { .. } => "despawn-npc",
6593            QuestEffect::MoveNpc { .. } => "move-npc",
6594            QuestEffect::Cutscene { .. } => "cutscene",
6595            QuestEffect::SetTime { .. } => "set-time",
6596            QuestEffect::SetWeather { .. } => "set-weather",
6597            QuestEffect::PlaySound { .. } => "play-sound",
6598            QuestEffect::DamagePlayers { .. } => "damage-players",
6599            QuestEffect::SetCheckpoint { .. } => "set-checkpoint",
6600            QuestEffect::Bonfire { .. } => "bonfire",
6601            QuestEffect::BeginStealth { .. } => "begin-stealth",
6602            QuestEffect::EndStealth => "end-stealth",
6603            QuestEffect::SpawnNpc { .. } => "spawn-npc",
6604            QuestEffect::SpawnActor { .. } => "spawn-actor",
6605            QuestEffect::DespawnActor { .. } => "despawn-actor",
6606            QuestEffect::MoveActor { .. } => "move-actor",
6607            QuestEffect::UnleashActor { .. } => "unleash-actor",
6608            QuestEffect::Sequence { .. } => "sequence",
6609            QuestEffect::Volley { .. } => "volley",
6610            QuestEffect::Collapse { .. } => "collapse",
6611            QuestEffect::GiveEffect { .. } => "give-effect",
6612            QuestEffect::ClearEffect { .. } => "clear-effect",
6613            QuestEffect::Teleport { .. } => "teleport",
6614        }
6615    }
6616
6617    /// The gate anchor if this is `open-gate`.
6618    pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
6619        match self {
6620            QuestEffect::OpenGate { anchor, .. } => Some(anchor),
6621            _ => None,
6622        }
6623    }
6624
6625    /// The gate anchor if this is `close-gate` (DSL v0.6).
6626    pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
6627        match self {
6628            QuestEffect::CloseGate { anchor, .. } => Some(anchor),
6629            _ => None,
6630        }
6631    }
6632
6633    /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
6634    /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
6635    /// the compiler's canonical English seal line.
6636    pub fn close_gate_sealed_hint(&self) -> Option<&str> {
6637        match self {
6638            QuestEffect::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
6639            _ => None,
6640        }
6641    }
6642
6643    /// The wave id if this is `spawn-wave` (v0.3).
6644    pub fn spawn_wave(&self) -> Option<&WaveId> {
6645        match self {
6646            QuestEffect::SpawnWave { wave, .. } => Some(wave),
6647            _ => None,
6648        }
6649    }
6650
6651    /// The flag id if this is `set-flag` (v0.3).
6652    pub fn set_flag(&self) -> Option<&FlagId> {
6653        match self {
6654            QuestEffect::SetFlag { flag, .. } => Some(flag),
6655            _ => None,
6656        }
6657    }
6658
6659    /// The item id if this is `give-item` (v0.3).
6660    pub fn give_item(&self) -> Option<&str> {
6661        match self {
6662            QuestEffect::GiveItem { item, .. } => Some(item),
6663            _ => None,
6664        }
6665    }
6666
6667    /// `true` if this is a `give-item` carrying a v0.4 display `name`.
6668    pub fn give_item_named(&self) -> bool {
6669        matches!(self, QuestEffect::GiveItem { name: Some(_), .. })
6670    }
6671
6672    /// The declared `carrier` if this is a `give-item` that states one (v0.6,
6673    /// spec-0018). `None` for any other effect **and** for a `give-item` that
6674    /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
6675    /// matters only for the pre-0.6 reserved-field gate.
6676    pub fn give_carrier(&self) -> Option<Carrier> {
6677        match self {
6678            QuestEffect::GiveItem { carrier, .. } => *carrier,
6679            _ => None,
6680        }
6681    }
6682
6683    /// Does this `give-item` hand a single copy to the acting player (v0.6,
6684    /// spec-0018)? `false` for every other effect and for the party-wide default.
6685    pub fn gives_to_one(&self) -> bool {
6686        matches!(self.give_carrier(), Some(Carrier::One))
6687    }
6688
6689    /// The `set-block` block id if this is a v0.4 `set-block` effect.
6690    pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
6691        match self {
6692            QuestEffect::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
6693            _ => None,
6694        }
6695    }
6696
6697    /// The NPC id if this is a v0.4 `despawn-npc` effect.
6698    pub fn despawn_npc(&self) -> Option<&NpcId> {
6699        match self {
6700            QuestEffect::DespawnNpc { npc, .. } => Some(npc),
6701            _ => None,
6702        }
6703    }
6704
6705    /// `(npc, to_anchor)` if this is a v0.4 `move-npc` effect.
6706    pub fn move_npc(&self) -> Option<(&NpcId, &AnchorId)> {
6707        match self {
6708            QuestEffect::MoveNpc { npc, to_anchor, .. } => Some((npc, to_anchor)),
6709            _ => None,
6710        }
6711    }
6712
6713    /// The v0.3 effect name if this effect is one introduced in DSL v0.3
6714    /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
6715    ///.
6716    pub fn v03_effect(&self) -> Option<&'static str> {
6717        match self {
6718            QuestEffect::GiveItem { .. } => Some("give-item"),
6719            QuestEffect::SetFlag { .. } => Some("set-flag"),
6720            QuestEffect::SpawnWave { .. } => Some("spawn-wave"),
6721            QuestEffect::OpenGate { .. }
6722            | QuestEffect::CloseGate { .. }
6723            | QuestEffect::CampaignComplete { .. } => None,
6724            // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
6725            // are not v0.3 verbs.
6726            QuestEffect::Narrate { .. }
6727            | QuestEffect::SetBlock { .. }
6728            | QuestEffect::DespawnNpc { .. }
6729            | QuestEffect::MoveNpc { .. }
6730            | QuestEffect::Cutscene { .. }
6731            | QuestEffect::SetTime { .. }
6732            | QuestEffect::SetWeather { .. }
6733            | QuestEffect::PlaySound { .. }
6734            | QuestEffect::DamagePlayers { .. }
6735            | QuestEffect::SetCheckpoint { .. }
6736            | QuestEffect::Bonfire { .. }
6737            | QuestEffect::BeginStealth { .. }
6738            | QuestEffect::EndStealth
6739            | QuestEffect::SpawnActor { .. }
6740            | QuestEffect::DespawnActor { .. }
6741            | QuestEffect::MoveActor { .. }
6742            | QuestEffect::UnleashActor { .. }
6743            | QuestEffect::SpawnNpc { .. }
6744            | QuestEffect::Sequence { .. }
6745            // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
6746            | QuestEffect::Volley { .. }
6747            | QuestEffect::Collapse { .. }
6748            // spec-0031's state verbs, region writes, status effects and teleport,
6749            // and spec-0032's `drop-stake`, are all v0.10 — they report via
6750            // `v10_effect`.
6751            | QuestEffect::SetState { .. }
6752            | QuestEffect::AddState { .. }
6753            | QuestEffect::ClearState { .. }
6754            | QuestEffect::FillRegion { .. }
6755            | QuestEffect::ClearRegion { .. }
6756            // spec-0042's `open-way` is v0.12 — it reports via `v12_effect`.
6757            | QuestEffect::OpenWay { .. }
6758            | QuestEffect::GiveEffect { .. }
6759            | QuestEffect::ClearEffect { .. }
6760            | QuestEffect::Teleport { .. }
6761            | QuestEffect::DropStake { .. } => None,
6762        }
6763    }
6764
6765    /// The v0.4 effect name if this effect is one introduced in DSL v0.4
6766    /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
6767    /// in v0.4 campaigns.
6768    pub fn v04_effect(&self) -> Option<&'static str> {
6769        match self {
6770            QuestEffect::Narrate { .. } => Some("narrate"),
6771            QuestEffect::SetBlock { .. } => Some("set-block"),
6772            QuestEffect::DespawnNpc { .. } => Some("despawn-npc"),
6773            QuestEffect::MoveNpc { .. } => Some("move-npc"),
6774            QuestEffect::Cutscene { .. } => Some("cutscene"),
6775            _ => None,
6776        }
6777    }
6778
6779    /// The v0.5 effect name if this effect is one introduced in DSL v0.5
6780    /// (`set-time`/`set-weather`, spec-0010).
6781    pub fn v05_effect(&self) -> Option<&'static str> {
6782        match self {
6783            QuestEffect::SetTime { .. } => Some("set-time"),
6784            QuestEffect::SetWeather { .. } => Some("set-weather"),
6785            _ => None,
6786        }
6787    }
6788
6789    /// The v0.6 effect name if this effect is one introduced in DSL v0.6
6790    /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
6791    /// `play-sound`, spec-0014; the scripted-actor staging verbs
6792    /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
6793    /// spec-0014). These validate in v0.6 campaigns
6794    /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
6795    /// — see [`QuestEffect::narrate_art`] — not a new effect.)
6796    pub fn v06_effect(&self) -> Option<&'static str> {
6797        match self {
6798            QuestEffect::CloseGate { .. } => Some("close-gate"),
6799            QuestEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
6800            QuestEffect::Bonfire { .. } => Some("bonfire"),
6801            QuestEffect::BeginStealth { .. } => Some("begin-stealth"),
6802            QuestEffect::EndStealth => Some("end-stealth"),
6803            QuestEffect::PlaySound { .. } => Some("play-sound"),
6804            QuestEffect::DamagePlayers { .. } => Some("damage-players"),
6805            QuestEffect::SpawnActor { .. } => Some("spawn-actor"),
6806            QuestEffect::DespawnActor { .. } => Some("despawn-actor"),
6807            QuestEffect::MoveActor { .. } => Some("move-actor"),
6808            QuestEffect::UnleashActor { .. } => Some("unleash-actor"),
6809            QuestEffect::Sequence { .. } => Some("sequence"),
6810            QuestEffect::SpawnNpc { .. } => Some("spawn-npc"),
6811            // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
6812            QuestEffect::Volley { .. } => Some("volley"),
6813            QuestEffect::Collapse { .. } => Some("collapse"),
6814            _ => None,
6815        }
6816    }
6817
6818    /// The v0.10 effect name if this effect is one introduced in DSL v0.10
6819    /// (`set-state`/`add-state`/`clear-state` and the region writes
6820    /// `fill-region`/`clear-region`, spec-0031). These validate in v0.10
6821    /// campaigns.
6822    pub fn v10_effect(&self) -> Option<&'static str> {
6823        match self {
6824            QuestEffect::SetState { .. } => Some("set-state"),
6825            QuestEffect::AddState { .. } => Some("add-state"),
6826            QuestEffect::ClearState { .. } => Some("clear-state"),
6827            QuestEffect::FillRegion { .. } => Some("fill-region"),
6828            QuestEffect::ClearRegion { .. } => Some("clear-region"),
6829            QuestEffect::GiveEffect { .. } => Some("give-effect"),
6830            QuestEffect::ClearEffect { .. } => Some("clear-effect"),
6831            QuestEffect::Teleport { .. } => Some("teleport"),
6832            QuestEffect::DropStake { .. } => Some("drop-stake"),
6833            _ => None,
6834        }
6835    }
6836
6837    /// The v0.12 effect name if this effect is one introduced in DSL v0.12
6838    /// (`open-way`, spec-0042).
6839    pub fn v12_effect(&self) -> Option<&'static str> {
6840        match self {
6841            QuestEffect::OpenWay { .. } => Some("open-way"),
6842            _ => None,
6843        }
6844    }
6845
6846    /// **The way this effect opens** (DSL v0.12, spec-0042): the placed piece and
6847    /// the name of one way that piece's spatial contract exports.
6848    ///
6849    /// The third spelling of the one region write, beside
6850    /// [`QuestEffect::region_write`] (the author's own box) and
6851    /// [`QuestEffect::gate_region_write`] (a gate anchor's box). It answers with a
6852    /// *reference* and never with geometry, because the geometry is not the
6853    /// campaign's to state: the cells, the block and the direction all live in the
6854    /// piece's metadata, and the compiler resolves them there
6855    /// (`compiler::ways`). A region-shaped accessor here would be the second
6856    /// authority this surface exists to avoid.
6857    pub fn way_write(&self) -> Option<(&PrefabId, &str)> {
6858        match self {
6859            QuestEffect::OpenWay { piece, way, .. } => Some((piece, way.as_str())),
6860            _ => None,
6861        }
6862    }
6863
6864    /// **The one region write**, whichever verb spelled it (DSL v0.10,
6865    /// spec-0031): the box to write and the block to write it with — `None` for
6866    /// the block meaning *clear to air*.
6867    ///
6868    /// This is the accessor the capability belongs to. It answers for
6869    /// `fill-region` / `clear-region`, which name their own box; `open-gate` /
6870    /// `close-gate` are the same operation over a box a prefab gate anchor
6871    /// declares, so they answer through
6872    /// [`QuestEffect::gate_region_write`](Self::gate_region_write) — the anchor
6873    /// is theirs, the *operation* is shared.
6874    pub fn region_write(&self) -> Option<(&StealthZone, Option<&str>)> {
6875        match self {
6876            QuestEffect::FillRegion { region, block, .. } => Some((region, Some(block.as_str()))),
6877            QuestEffect::ClearRegion { region, .. } => Some((region, None)),
6878            _ => None,
6879        }
6880    }
6881
6882    /// The gate anchor this effect writes, and whether the write **fills** it:
6883    /// `Some((anchor, true))` for `close-gate`, `Some((anchor, false))` for
6884    /// `open-gate`, `None` for everything else.
6885    ///
6886    /// The gate half of [`QuestEffect::region_write`]: same operation, but the box
6887    /// and the fill block come from the prefab's gate anchor rather than from the
6888    /// author. Everything that reasons about runtime region writes reads both
6889    /// accessors and nothing else.
6890    pub fn gate_region_write(&self) -> Option<(&AnchorId, bool)> {
6891        match self {
6892            QuestEffect::CloseGate { anchor, .. } => Some((anchor, true)),
6893            QuestEffect::OpenGate { anchor, .. } => Some((anchor, false)),
6894            _ => None,
6895        }
6896    }
6897
6898    /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
6899    /// `volley` (spec-0022), with the documented defaults already applied.
6900    pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
6901        match self {
6902            QuestEffect::Volley {
6903                projectile,
6904                from_anchor,
6905                kill_zone,
6906                salvos,
6907                interval,
6908                ..
6909            } => Some((
6910                projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
6911                from_anchor,
6912                kill_zone,
6913                salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
6914                interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
6915            )),
6916            _ => None,
6917        }
6918    }
6919
6920    /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
6921    /// (spec-0022), with the documented default already applied.
6922    pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
6923        match self {
6924            QuestEffect::Collapse {
6925                region_anchor,
6926                falling_block,
6927                then_floor,
6928                ..
6929            } => Some((
6930                region_anchor,
6931                falling_block
6932                    .as_deref()
6933                    .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
6934                then_floor.as_deref(),
6935            )),
6936            _ => None,
6937        }
6938    }
6939
6940    /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
6941    /// [`QuestEffect::despawn_npc`]).
6942    pub fn spawn_npc(&self) -> Option<&NpcId> {
6943        match self {
6944            QuestEffect::SpawnNpc { npc, .. } => Some(npc),
6945            _ => None,
6946        }
6947    }
6948
6949    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
6950    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
6951        match self {
6952            QuestEffect::SetCheckpoint { anchor, on_respawn } => {
6953                Some((anchor, on_respawn.as_slice()))
6954            }
6955            _ => None,
6956        }
6957    }
6958
6959    /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
6960    pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
6961        match self {
6962            QuestEffect::Bonfire {
6963                anchor, on_rest, ..
6964            } => Some((anchor, on_rest.as_slice())),
6965            _ => None,
6966        }
6967    }
6968
6969    /// The bonfire's authored rest-dialog strings, each `None` when unauthored
6970    /// (the compiler then bakes its canonical English). `None` for every other
6971    /// effect (spec-0016 §1).
6972    pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
6973        match self {
6974            QuestEffect::Bonfire {
6975                prompt,
6976                rest_label,
6977                save_label,
6978                ..
6979            } => Some(BonfireLabels {
6980                prompt: prompt.as_deref(),
6981                rest_label: rest_label.as_deref(),
6982                save_label: save_label.as_deref(),
6983            }),
6984            _ => None,
6985        }
6986    }
6987
6988    /// The `within` filter zone if this is a v0.6 `damage-players` effect that
6989    /// declares one (the `in` spatial scope). `None` for an unscoped
6990    /// `damage-players` and for every other effect.
6991    pub fn damage_within(&self) -> Option<&StealthZone> {
6992        match self {
6993            QuestEffect::DamagePlayers { within, .. } => within.as_ref(),
6994            _ => None,
6995        }
6996    }
6997
6998    /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
6999    pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
7000        match self {
7001            QuestEffect::BeginStealth {
7002                zones,
7003                on_caught,
7004                grace_ticks,
7005            } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
7006            _ => None,
7007        }
7008    }
7009
7010    /// The target time if this is a v0.5 `set-time` effect.
7011    pub fn set_time(&self) -> Option<WorldTime> {
7012        match self {
7013            QuestEffect::SetTime { time, .. } => Some(*time),
7014            _ => None,
7015        }
7016    }
7017
7018    /// The target weather if this is a v0.5 `set-weather` effect.
7019    pub fn set_weather(&self) -> Option<WorldWeather> {
7020        match self {
7021            QuestEffect::SetWeather { weather, .. } => Some(*weather),
7022            _ => None,
7023        }
7024    }
7025
7026    /// The effect lists nested one level inside this effect (DSL v0.6): a
7027    /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
7028    /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
7029    /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
7030    ///
7031    /// This is the **single authority** on effect nesting. Every deep traversal —
7032    /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
7033    /// string inventory, and emission — walks the tree through it (see
7034    /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
7035    /// once and no walker can silently miss a list (the class of bug where a
7036    /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
7037    pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
7038        match self {
7039            QuestEffect::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
7040            QuestEffect::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
7041            QuestEffect::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
7042            QuestEffect::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
7043            QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
7044                vec![on_arrive.as_slice()]
7045            }
7046            _ => Vec::new(),
7047        }
7048    }
7049
7050    /// Visit `self` and every transitively nested effect (depth-first, pre-order),
7051    /// descending through [`Self::nested_effect_lists`].
7052    pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
7053        f(self);
7054        for list in self.nested_effect_lists() {
7055            for e in list {
7056                e.visit_deep(f);
7057            }
7058        }
7059    }
7060
7061    /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
7062    /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
7063    /// exposed mutably so the localization pass can rewrite nested player-visible
7064    /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
7065    /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
7066    /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
7067    /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
7068    /// derived segments make every derived key deterministic and stable across
7069    /// builds (ADR-0006 byte-identity).
7070    pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
7071        match self {
7072            QuestEffect::Sequence { steps } => steps
7073                .iter_mut()
7074                .enumerate()
7075                .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
7076                .collect(),
7077            QuestEffect::SetCheckpoint { on_respawn, .. } => {
7078                vec![("respawn".to_string(), on_respawn.as_mut_slice())]
7079            }
7080            QuestEffect::Bonfire { on_rest, .. } => {
7081                vec![("rest".to_string(), on_rest.as_mut_slice())]
7082            }
7083            QuestEffect::BeginStealth { on_caught, .. } => {
7084                vec![("caught".to_string(), on_caught.as_mut_slice())]
7085            }
7086            QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
7087                vec![("arrive".to_string(), on_arrive.as_mut_slice())]
7088            }
7089            _ => Vec::new(),
7090        }
7091    }
7092
7093    /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
7094    /// exposes the **JSON-pointer path segment** for each nested list, so a deep
7095    /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
7096    /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
7097    /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
7098    /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
7099    /// (same lists, same order): the l10n key segments match
7100    /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
7101    /// `arrive`), and the path segments name the real fields
7102    /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
7103    pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
7104        match self {
7105            QuestEffect::Sequence { steps } => steps
7106                .iter()
7107                .enumerate()
7108                .map(|(s, st)| {
7109                    (
7110                        format!("steps/{s}/effects"),
7111                        format!("seq.{s}"),
7112                        st.effects.as_slice(),
7113                    )
7114                })
7115                .collect(),
7116            QuestEffect::SetCheckpoint { on_respawn, .. } => vec![(
7117                "on_respawn".to_string(),
7118                "respawn".to_string(),
7119                on_respawn.as_slice(),
7120            )],
7121            QuestEffect::Bonfire { on_rest, .. } => vec![(
7122                "on_rest".to_string(),
7123                "rest".to_string(),
7124                on_rest.as_slice(),
7125            )],
7126            QuestEffect::BeginStealth { on_caught, .. } => vec![(
7127                "on_caught".to_string(),
7128                "caught".to_string(),
7129                on_caught.as_slice(),
7130            )],
7131            QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
7132                vec![(
7133                    "on_arrive".to_string(),
7134                    "arrive".to_string(),
7135                    on_arrive.as_slice(),
7136                )]
7137            }
7138            _ => Vec::new(),
7139        }
7140    }
7141
7142    /// Every world anchor this effect names **at this node** — the single
7143    /// authority on the anchor-bearing effect surface, the referential sibling of
7144    /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
7145    /// where the suffix is appended to the effect's own JSON pointer
7146    /// (`anchor`, `to_anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
7147    /// `shots/<i>/path/<j>/anchor`, …).
7148    ///
7149    /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
7150    /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
7151    /// reference scan, the compiler's build-time resolution seal (`DW0355`) —
7152    /// goes through here, so a new anchor-bearing variant (or a new anchor field
7153    /// on an existing one) is picked up by all of them at once. That closes the
7154    /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
7155    /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
7156    /// found no match) while every shallow validator looked straight past it.
7157    ///
7158    /// # The third element is the SHAPE the site demands
7159    ///
7160    /// A reference is not only a name: `close-gate` fills and clears a region and
7161    /// `bonfire` seats a body, and until spec-0052 those two sat in one match arm
7162    /// with nothing recording the difference. The demand rides with the reference
7163    /// so that a new anchor-bearing variant cannot be added without stating what
7164    /// it does with the anchor — the same reason this is one authority and not
7165    /// three walks.
7166    ///
7167    /// **A demand is stated only where the shape is structurally required**, and
7168    /// the line is drawn where this engine's own `DW0845` already draws it — *a
7169    /// region is not a place to stand*:
7170    ///
7171    /// * [`StationKind::Gate`] where the verb cannot function without a region
7172    ///   that seals and clears: the two gate verbs. A point is not a gate, and
7173    ///   `gate_region_block_any` finds nothing for one.
7174    /// * [`StationKind::Point`] where a **body is put**: a checkpoint seat, a
7175    ///   bonfire, a `move-npc`/`move-actor` destination, a `teleport`
7176    ///   destination. A body cannot stand inside bars.
7177    /// * `None` wherever a reference merely names a **location** — every
7178    ///   anchor-centred volume's centre, every camera field, a `set-block`, a
7179    ///   `play-sound`. A gate region's own corner is a perfectly good answer
7180    ///   there, and refusing it would refuse correct content.
7181    ///
7182    /// That last case is not a gap left for later. An earlier draft of this rule
7183    /// demanded a point at every non-gate site, and the gallery refused to
7184    /// compile: `trigger/east-door-wrong-side` is a `use` trigger sitting on the
7185    /// very `anchor/seam-…` of a barred door so that it can say "the east door
7186    /// does not open from this side". A check that resolves against a smaller
7187    /// world than the campaign has refuses CONTENT, which is the lesson `DW0343`
7188    /// carries three files away.
7189    pub fn anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
7190        // Every camera field is a point: a shot flies through cells and looks at
7191        // one.
7192        /// `(suffix, anchor, kind)` for a shot's own anchor-bearing fields, under `base`.
7193        fn shot_refs<'a>(
7194            base: &str,
7195            shot: &'a CameraShot,
7196        ) -> Vec<(String, &'a AnchorId, Option<StationKind>)> {
7197            let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shot
7198                .path
7199                .iter()
7200                .enumerate()
7201                .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor, None))
7202                .collect();
7203            if let Some(t) = &shot.look_at {
7204                out.push((format!("{base}look_at/anchor"), &t.anchor, None));
7205            }
7206            for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
7207                if let Some(CameraSubject::Anchor(s)) = subject {
7208                    out.push((format!("{base}{field}/anchor"), &s.anchor, None));
7209                }
7210            }
7211            out
7212        }
7213        match self {
7214            // The two gate verbs address a REGION that seals and clears; the
7215            // three below them seat or write at a cell. They shared an arm until
7216            // the demand had somewhere to be written down.
7217            QuestEffect::OpenGate { anchor, .. } | QuestEffect::CloseGate { anchor, .. } => {
7218                vec![("anchor".to_string(), anchor, Some(StationKind::Gate))]
7219            }
7220            // A checkpoint and a bonfire are **respawn seats** — a body is put
7221            // there, so a region is not one. `set-block` writes a block at a
7222            // cell, which names a location and seats nothing.
7223            QuestEffect::SetCheckpoint { anchor, .. } | QuestEffect::Bonfire { anchor, .. } => {
7224                vec![("anchor".to_string(), anchor, Some(StationKind::Point))]
7225            }
7226            QuestEffect::SetBlock { anchor, .. } => {
7227                vec![("anchor".to_string(), anchor, None)]
7228            }
7229            QuestEffect::MoveNpc { to_anchor, .. } | QuestEffect::MoveActor { to_anchor, .. } => {
7230                vec![("to_anchor".to_string(), to_anchor, Some(StationKind::Point))]
7231            }
7232            // The `in` filter is one capability on three verbs, so it registers
7233            // once: `damage-players` (v0.6) and the v0.10 status-effect pair.
7234            QuestEffect::DamagePlayers {
7235                within: Some(zone), ..
7236            }
7237            | QuestEffect::GiveEffect {
7238                within: Some(zone), ..
7239            }
7240            | QuestEffect::ClearEffect {
7241                within: Some(zone), ..
7242            } => vec![("in/anchor".to_string(), &zone.anchor, None)],
7243            // Both of a `teleport`'s anchors are load-bearing — the source volume
7244            // decides WHAT moves and the destination decides WHERE — so a typo in
7245            // either is a dangling reference (`DW0142`), never a silently
7246            // zero-cell volume or a dropped command.
7247            QuestEffect::Teleport { from, to, .. } => vec![
7248                ("from/anchor".to_string(), &from.anchor, None),
7249                ("to".to_string(), to, Some(StationKind::Point)),
7250            ],
7251            QuestEffect::BeginStealth { zones, .. } => zones
7252                .iter()
7253                .enumerate()
7254                .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor, None))
7255                .collect(),
7256            QuestEffect::PlaySound {
7257                at: Some(SoundAt::Anchor { anchor }),
7258                ..
7259            } => vec![("at/anchor".to_string(), anchor, None)],
7260            // spec-0022 trap-payload verbs. Both anchors of a `volley` are
7261            // load-bearing for the coverage proof, so both register here — a
7262            // typo'd `kill_zone` must be a dangling-reference error, never a
7263            // silently zero-cell (and therefore vacuously "covered") volley.
7264            QuestEffect::Volley {
7265                from_anchor,
7266                kill_zone,
7267                ..
7268            } => vec![
7269                ("from_anchor".to_string(), from_anchor, None),
7270                ("kill_zone/anchor".to_string(), &kill_zone.anchor, None),
7271            ],
7272            QuestEffect::Collapse { region_anchor, .. } => {
7273                vec![(
7274                    "region_anchor/anchor".to_string(),
7275                    &region_anchor.anchor,
7276                    None,
7277                )]
7278            }
7279            // The v0.10 region writes: the box's anchor is load-bearing for both
7280            // the emission and the completability model, so a typo'd one must be a
7281            // dangling-reference error (`DW0142`/`DW0355`), never a silently
7282            // unwritten — and therefore vacuously proven — region.
7283            QuestEffect::FillRegion { region, .. } | QuestEffect::ClearRegion { region, .. } => {
7284                vec![("region/anchor".to_string(), &region.anchor, None)]
7285            }
7286            // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
7287            // multi-shot list, or the v0.4 single-shot fields flattened at the
7288            // effect's own level.
7289            QuestEffect::Cutscene {
7290                shots,
7291                path,
7292                look_at,
7293                ..
7294            } => {
7295                let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shots
7296                    .iter()
7297                    .enumerate()
7298                    .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
7299                    .collect();
7300                out.extend(
7301                    path.iter()
7302                        .enumerate()
7303                        .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor, None)),
7304                );
7305                if let Some(t) = look_at {
7306                    out.push(("look_at/anchor".to_string(), &t.anchor, None));
7307                }
7308                out
7309            }
7310            _ => Vec::new(),
7311        }
7312    }
7313
7314    /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
7315    /// the v0.6 `look_at` field.
7316    pub fn cutscene_look_at(&self) -> Option<&CameraTarget> {
7317        match self {
7318            QuestEffect::Cutscene { look_at, .. } => look_at.as_ref(),
7319            _ => None,
7320        }
7321    }
7322
7323    /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
7324    ///.
7325    pub fn cutscene_multi_shot(&self) -> bool {
7326        matches!(self, QuestEffect::Cutscene { shots, .. } if !shots.is_empty())
7327    }
7328
7329    /// The normalized shot list of a `cutscene`, whichever spelling was used: the
7330    /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
7331    /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
7332    /// whose shape is invalid (`DW0199` reports that).
7333    pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
7334        match self {
7335            QuestEffect::Cutscene {
7336                shots,
7337                path,
7338                seconds,
7339                look_at,
7340                ..
7341            } => {
7342                if !shots.is_empty() {
7343                    return Some(shots.clone());
7344                }
7345                match seconds {
7346                    Some(secs) => Some(vec![CameraShot {
7347                        path: path.clone(),
7348                        seconds: Some(*secs),
7349                        look_at: look_at.clone(),
7350                        shot_style: None,
7351                        subject: None,
7352                        subject_b: None,
7353                        dist: None,
7354                        degrees: None,
7355                        bearing: None,
7356                    }]),
7357                    None => Some(Vec::new()),
7358                }
7359            }
7360            _ => None,
7361        }
7362    }
7363
7364    /// `true` if this is a `narrate` carrying the `art` style (glyph-checked
7365    /// `DW0328`).
7366    pub fn narrate_art(&self) -> bool {
7367        matches!(
7368            self,
7369            QuestEffect::Narrate {
7370                style: Some(NarrateStyle::Art),
7371                ..
7372            }
7373        )
7374    }
7375
7376    /// The `narrate` line's text if this is a `narrate` with the `art` style.
7377    pub fn narrate_art_text(&self) -> Option<&str> {
7378        match self {
7379            QuestEffect::Narrate {
7380                text,
7381                style: Some(NarrateStyle::Art),
7382                ..
7383            } => Some(text.as_str()),
7384            _ => None,
7385        }
7386    }
7387
7388    /// The `narrate` line's style and text if this is a `narrate` rendered **on
7389    /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
7390    /// styles vanilla draws centred and unwrapped, so their rendered width is
7391    /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
7392    /// is exempt.
7393    pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
7394        match self {
7395            QuestEffect::Narrate {
7396                text,
7397                style: Some(s),
7398                ..
7399            } if matches!(
7400                s,
7401                NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
7402            ) =>
7403            {
7404                Some((*s, text.as_str()))
7405            }
7406            _ => None,
7407        }
7408    }
7409
7410    /// Every vanilla sound-event id this effect references, for registry
7411    /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
7412    /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
7413    /// within the effect (e.g. `sound`).
7414    pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
7415        match self {
7416            QuestEffect::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
7417            QuestEffect::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
7418            _ => Vec::new(),
7419        }
7420    }
7421
7422    /// The `play-sound` `at: actor` id, if this effect is a `play-sound`
7423    /// targeting an actor (rejected `DW0335`).
7424    pub fn play_sound_actor(&self) -> Option<&str> {
7425        match self {
7426            QuestEffect::PlaySound {
7427                at: Some(SoundAt::Actor { actor }),
7428                ..
7429            } => Some(actor.as_str()),
7430            _ => None,
7431        }
7432    }
7433
7434    /// The per-effect flag gate (DSL v0.6): flags that must ALL be set
7435    /// (per player) for this effect to fire. Empty for an ungated effect and for
7436    /// the verbs that are not per-effect gatable — terminal `campaign-complete`
7437    /// and the party/session-global `set-checkpoint` / `begin-stealth` /
7438    /// `end-stealth`. Emission wraps a gated effect's commands in a per-player
7439    /// `execute if score @s dw.f_<flag> matches 1` guard.
7440    pub fn requires_flags(&self) -> &[FlagId] {
7441        match self {
7442            QuestEffect::OpenGate { requires_flags, .. }
7443            | QuestEffect::CloseGate { requires_flags, .. }
7444            | QuestEffect::GiveItem { requires_flags, .. }
7445            | QuestEffect::SetFlag { requires_flags, .. }
7446            | QuestEffect::SetState { requires_flags, .. }
7447            | QuestEffect::AddState { requires_flags, .. }
7448            | QuestEffect::ClearState { requires_flags, .. }
7449            | QuestEffect::DropStake { requires_flags, .. }
7450            | QuestEffect::SpawnWave { requires_flags, .. }
7451            | QuestEffect::Narrate { requires_flags, .. }
7452            | QuestEffect::SetBlock { requires_flags, .. }
7453            | QuestEffect::DespawnNpc { requires_flags, .. }
7454            | QuestEffect::MoveNpc { requires_flags, .. }
7455            | QuestEffect::Cutscene { requires_flags, .. }
7456            | QuestEffect::SetTime { requires_flags, .. }
7457            | QuestEffect::SetWeather { requires_flags, .. }
7458            | QuestEffect::PlaySound { requires_flags, .. }
7459            | QuestEffect::DamagePlayers { requires_flags, .. }
7460            | QuestEffect::FillRegion { requires_flags, .. }
7461            | QuestEffect::ClearRegion { requires_flags, .. }
7462            | QuestEffect::OpenWay { requires_flags, .. }
7463            | QuestEffect::Volley { requires_flags, .. }
7464            | QuestEffect::Collapse { requires_flags, .. }
7465            | QuestEffect::GiveEffect { requires_flags, .. }
7466            | QuestEffect::ClearEffect { requires_flags, .. }
7467            | QuestEffect::Teleport { requires_flags, .. } => requires_flags,
7468            // Terminal / party- or session-global verbs are not per-effect
7469            // gatable: `campaign-complete` is terminal; `set-checkpoint`
7470            // (`spawnpoint @a`) / `begin-stealth` / `end-stealth` are party-wide
7471            // session state; and the actor staging verbs (`spawn-actor` /
7472            // `despawn-actor` / `move-actor` / `unleash-actor` / `sequence`) and
7473            // `spawn-npc` are world-global staging — none are per-player `@s`
7474            // effects. Gate these at the objective / dialogue-option level instead.
7475            QuestEffect::CampaignComplete { .. }
7476            | QuestEffect::SpawnNpc { .. }
7477            | QuestEffect::SetCheckpoint { .. }
7478            | QuestEffect::Bonfire { .. }
7479            | QuestEffect::BeginStealth { .. }
7480            | QuestEffect::EndStealth
7481            | QuestEffect::SpawnActor { .. }
7482            | QuestEffect::DespawnActor { .. }
7483            | QuestEffect::MoveActor { .. }
7484            | QuestEffect::UnleashActor { .. }
7485            | QuestEffect::Sequence { .. } => &[],
7486        }
7487    }
7488
7489    /// The per-effect **negative** flag gate (DSL v0.6): flags whose being set
7490    /// (per player) **suppresses** this effect — the dual of
7491    /// [`QuestEffect::requires_flags`], accepted on exactly the same verbs.
7492    /// Emission wraps a gated effect's commands in a per-player
7493    /// `execute unless score @s dw.f_<flag> matches 1` guard, so an unset score
7494    /// counts as "not set" (flag scores are never pre-initialized). Empty for an
7495    /// ungated effect and for the verbs that are not per-effect gatable (see
7496    /// `requires_flags`).
7497    pub fn forbids_flags(&self) -> &[FlagId] {
7498        match self {
7499            QuestEffect::OpenGate { forbids_flags, .. }
7500            | QuestEffect::CloseGate { forbids_flags, .. }
7501            | QuestEffect::GiveItem { forbids_flags, .. }
7502            | QuestEffect::SetFlag { forbids_flags, .. }
7503            | QuestEffect::SetState { forbids_flags, .. }
7504            | QuestEffect::AddState { forbids_flags, .. }
7505            | QuestEffect::ClearState { forbids_flags, .. }
7506            | QuestEffect::DropStake { forbids_flags, .. }
7507            | QuestEffect::SpawnWave { forbids_flags, .. }
7508            | QuestEffect::Narrate { forbids_flags, .. }
7509            | QuestEffect::SetBlock { forbids_flags, .. }
7510            | QuestEffect::DespawnNpc { forbids_flags, .. }
7511            | QuestEffect::MoveNpc { forbids_flags, .. }
7512            | QuestEffect::Cutscene { forbids_flags, .. }
7513            | QuestEffect::SetTime { forbids_flags, .. }
7514            | QuestEffect::SetWeather { forbids_flags, .. }
7515            | QuestEffect::PlaySound { forbids_flags, .. }
7516            | QuestEffect::DamagePlayers { forbids_flags, .. }
7517            | QuestEffect::FillRegion { forbids_flags, .. }
7518            | QuestEffect::ClearRegion { forbids_flags, .. }
7519            | QuestEffect::OpenWay { forbids_flags, .. }
7520            | QuestEffect::Volley { forbids_flags, .. }
7521            | QuestEffect::Collapse { forbids_flags, .. }
7522            | QuestEffect::GiveEffect { forbids_flags, .. }
7523            | QuestEffect::ClearEffect { forbids_flags, .. }
7524            | QuestEffect::Teleport { forbids_flags, .. } => forbids_flags,
7525            QuestEffect::CampaignComplete { .. }
7526            | QuestEffect::SpawnNpc { .. }
7527            | QuestEffect::SetCheckpoint { .. }
7528            | QuestEffect::Bonfire { .. }
7529            | QuestEffect::BeginStealth { .. }
7530            | QuestEffect::EndStealth
7531            | QuestEffect::SpawnActor { .. }
7532            | QuestEffect::DespawnActor { .. }
7533            | QuestEffect::MoveActor { .. }
7534            | QuestEffect::UnleashActor { .. }
7535            | QuestEffect::Sequence { .. } => &[],
7536        }
7537    }
7538
7539    /// The numeric gate terms (DSL v0.10, spec-0031), accepted on exactly the
7540    /// verbs the flag pair is accepted on — it is one gate with three fields, not
7541    /// a fourth mechanism. The ten verbs that carry no flag gate carry no
7542    /// comparison either; when they gain one they gain all three together
7543    /// (`tools/check-capability-ownership.py`, `MODIFIER_HOLES`).
7544    pub fn requires_state(&self) -> &[StateCompare] {
7545        match self {
7546            QuestEffect::OpenGate { requires_state, .. }
7547            | QuestEffect::CloseGate { requires_state, .. }
7548            | QuestEffect::GiveItem { requires_state, .. }
7549            | QuestEffect::SetFlag { requires_state, .. }
7550            | QuestEffect::SetState { requires_state, .. }
7551            | QuestEffect::AddState { requires_state, .. }
7552            | QuestEffect::ClearState { requires_state, .. }
7553            | QuestEffect::DropStake { requires_state, .. }
7554            | QuestEffect::SpawnWave { requires_state, .. }
7555            | QuestEffect::Narrate { requires_state, .. }
7556            | QuestEffect::SetBlock { requires_state, .. }
7557            | QuestEffect::DespawnNpc { requires_state, .. }
7558            | QuestEffect::MoveNpc { requires_state, .. }
7559            | QuestEffect::Cutscene { requires_state, .. }
7560            | QuestEffect::SetTime { requires_state, .. }
7561            | QuestEffect::SetWeather { requires_state, .. }
7562            | QuestEffect::PlaySound { requires_state, .. }
7563            | QuestEffect::DamagePlayers { requires_state, .. }
7564            | QuestEffect::FillRegion { requires_state, .. }
7565            | QuestEffect::ClearRegion { requires_state, .. }
7566            | QuestEffect::OpenWay { requires_state, .. }
7567            | QuestEffect::Volley { requires_state, .. }
7568            | QuestEffect::Collapse { requires_state, .. }
7569            | QuestEffect::GiveEffect { requires_state, .. }
7570            | QuestEffect::ClearEffect { requires_state, .. }
7571            | QuestEffect::Teleport { requires_state, .. } => requires_state,
7572            QuestEffect::CampaignComplete { .. }
7573            | QuestEffect::SpawnNpc { .. }
7574            | QuestEffect::SetCheckpoint { .. }
7575            | QuestEffect::Bonfire { .. }
7576            | QuestEffect::BeginStealth { .. }
7577            | QuestEffect::EndStealth
7578            | QuestEffect::SpawnActor { .. }
7579            | QuestEffect::DespawnActor { .. }
7580            | QuestEffect::MoveActor { .. }
7581            | QuestEffect::UnleashActor { .. }
7582            | QuestEffect::Sequence { .. } => &[],
7583        }
7584    }
7585
7586    /// The datum this effect writes and how, if it is one of the DSL v0.10 state
7587    /// verbs (`set-state` / `add-state` / `clear-state`).
7588    pub fn writes_state(&self) -> Option<(&StateId, StateWrite)> {
7589        match self {
7590            QuestEffect::SetState { state, value, .. } => Some((state, StateWrite::Set(*value))),
7591            QuestEffect::AddState { state, amount, .. } => Some((state, StateWrite::Add(*amount))),
7592            QuestEffect::ClearState { state, .. } => Some((state, StateWrite::Clear)),
7593            _ => None,
7594        }
7595    }
7596
7597    /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
7598    /// parity with `move-actor`). `None` for a bare `move-npc` and every other
7599    /// effect.
7600    pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
7601        match self {
7602            QuestEffect::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => {
7603                Some(on_arrive.as_slice())
7604            }
7605            _ => None,
7606        }
7607    }
7608
7609    /// The actor id this effect targets, if it is one of the actor staging effects
7610    /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
7611    /// no single actor (its nested effects each carry their own).
7612    pub fn actor_ref(&self) -> Option<&ActorId> {
7613        match self {
7614            QuestEffect::SpawnActor { actor, .. }
7615            | QuestEffect::DespawnActor { actor, .. }
7616            | QuestEffect::MoveActor { actor, .. }
7617            | QuestEffect::UnleashActor { actor, .. } => Some(actor),
7618            _ => None,
7619        }
7620    }
7621
7622    /// Every vanilla **status-effect** id this effect names, for registry
7623    /// validation (`DW0192`) — the sibling of [`Self::sound_refs`] and the single
7624    /// authority on the status-effect-bearing verb surface. `(subpath, id)`
7625    /// pairs; empty for a `clear-effect` that names none (which clears all).
7626    pub fn status_effect_refs(&self) -> Vec<(&'static str, &str)> {
7627        match self {
7628            QuestEffect::GiveEffect { effect, .. } => vec![("effect", effect.as_str())],
7629            QuestEffect::ClearEffect {
7630                effect: Some(e), ..
7631            } => vec![("effect", e.as_str())],
7632            _ => Vec::new(),
7633        }
7634    }
7635
7636    /// `(effect, seconds, amplifier, hide_particles, in)` if this is a
7637    /// `give-effect` (DSL v0.10), with the documented defaults already applied.
7638    pub fn give_effect(&self) -> Option<(&str, u32, u32, bool, Option<&StealthZone>)> {
7639        match self {
7640            QuestEffect::GiveEffect {
7641                effect,
7642                seconds,
7643                amplifier,
7644                hide_particles,
7645                within,
7646                ..
7647            } => Some((
7648                effect.as_str(),
7649                *seconds,
7650                amplifier.unwrap_or(0),
7651                hide_particles.unwrap_or(false),
7652                within.as_ref(),
7653            )),
7654            _ => None,
7655        }
7656    }
7657
7658    /// `(effect, in)` if this is a `clear-effect` (DSL v0.10). The effect is
7659    /// `None` for the clear-everything form, exactly as vanilla spells it.
7660    pub fn clear_effect(&self) -> Option<(Option<&str>, Option<&StealthZone>)> {
7661        match self {
7662            QuestEffect::ClearEffect { effect, within, .. } => {
7663                Some((effect.as_deref(), within.as_ref()))
7664            }
7665            _ => None,
7666        }
7667    }
7668
7669    /// `(from, to)` if this is a `teleport` (DSL v0.10): the source volume and
7670    /// the destination anchor.
7671    pub fn teleport(&self) -> Option<(&StealthZone, &AnchorId)> {
7672        match self {
7673            QuestEffect::Teleport { from, to, .. } => Some((from, to)),
7674            _ => None,
7675        }
7676    }
7677}
7678
7679// ---------------------------------------------------------------------------
7680// Stage 7 — world-edits (the map editor edit script, DSL v0.6, spec-0017)
7681// ---------------------------------------------------------------------------
7682
7683/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
7684///
7685/// The artifact of record for L3 world detailing: an ordered list of edit
7686/// batches the compiler replays deterministically **after** world assembly.
7687/// The world files are never truth — same DSL + same edits + same seed →
7688/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
7689/// builds byte-identically to one from before this stage existed.
7690#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7691#[serde(deny_unknown_fields)]
7692pub struct WorldEditsContent {
7693    /// Ordered edit batches, replayed in order. After every batch the post-edit
7694    /// invariants re-prove (walkability, sealing + relight, boundary safety),
7695    /// so each batch is a valid, snapshot-reviewable world state.
7696    pub batches: Vec<EditBatch>,
7697}
7698
7699/// One edit batch: an ordered group of edit verbs applied to a single area,
7700/// checked and snapshot-rendered as a unit.
7701#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7702#[serde(deny_unknown_fields)]
7703pub struct EditBatch {
7704    /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
7705    /// snapshot name and seed-stream label — renaming a batch deliberately
7706    /// reseeds its noise.
7707    pub id: EditBatchId,
7708    /// The stage-1 area this batch edits. Frames and regions resolve against
7709    /// this area's placed pieces and anchors.
7710    pub area: AreaId,
7711    /// Authoring context (why this batch exists). Machine-ignored; **excluded**
7712    /// from l10n like `theme`/`premise`.
7713    #[serde(default, skip_serializing_if = "Option::is_none")]
7714    pub note: Option<String>,
7715    /// Ordered edit verbs. `select` defines named regions; later verbs in the
7716    /// same batch refer back to them (strictly backward, like every DSL ref).
7717    pub edits: Vec<WorldEdit>,
7718}
7719
7720/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
7721/// seeded verb derives its noise stream from the campaign seed + its script
7722/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
7723#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7724#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
7725pub enum WorldEdit {
7726    /// Define a named region (`region/<kebab>`) for later verbs in this batch.
7727    Select {
7728        /// The region name being defined (unique within the batch).
7729        name: RegionId,
7730        /// The region's shape (box, surface band, palette match, or a
7731        /// composition of earlier regions).
7732        shape: RegionShape,
7733    },
7734    /// Fill every cell of a region from a seeded palette recipe (value-noise
7735    /// keyed, so picks cluster into patches — never a uniform fill).
7736    Fill {
7737        /// The region (an earlier `select` in this batch) to fill.
7738        region: RegionId,
7739        /// The palette recipe to fill with.
7740        recipe: PaletteRecipe,
7741    },
7742    /// Like `fill`, but only rewrites cells whose current block matches one of
7743    /// `matching` (base ids; blockstate suffixes are ignored when matching).
7744    Replace {
7745        /// The region (an earlier `select` in this batch) to edit.
7746        region: RegionId,
7747        /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
7748        matching: Vec<String>,
7749        /// The palette recipe to rewrite them with.
7750        recipe: PaletteRecipe,
7751    },
7752    /// Clear a region to air. Sealing-aware: the carved region re-enters the
7753    /// sealing + relight passes and every walkability invariant re-proves.
7754    Carve {
7755        /// The region (an earlier `select` in this batch) to clear.
7756        region: RegionId,
7757    },
7758    /// Reshape terrain surface within a region (raise / lower / smooth).
7759    Morph {
7760        /// The region (an earlier `select` in this batch) whose columns to
7761        /// reshape. The region defines the footprint and where each column's
7762        /// surface is read (the highest occupied cell in the region's y-range);
7763        /// `raise`/`smooth` may add cells above the region's top — reshaping
7764        /// upward is the point — while removal only touches region cells.
7765        region: RegionId,
7766        /// The surface operation.
7767        op: MorphOp,
7768    },
7769    /// Seeded dressing scatter (spec-0017): drop weighted single-block
7770    /// dressing (flora, rocks, props) onto standable cells of a region —
7771    /// air cells with an occupied cell directly below — honoring keep-clear
7772    /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
7773    /// speckle, not the fill verbs' clustered patches), deterministic from the
7774    /// campaign seed + script position.
7775    Scatter {
7776        /// The region (an earlier `select` in this batch) to dress.
7777        region: RegionId,
7778        /// Weighted dressing blocks (blockstate suffixes allowed).
7779        items: Vec<PaletteBlock>,
7780        /// Per-candidate placement probability in `(0, 1]`.
7781        density: f64,
7782        /// Keep-clear envelopes: earlier regions whose cells (and columns —
7783        /// matched by `(x, z)`) never receive dressing.
7784        #[serde(default, skip_serializing_if = "Vec::is_empty")]
7785        avoid: Vec<RegionId>,
7786        /// Optional minimum spacing: when set, candidates are taken in
7787        /// descending noise order and one is rejected while another accepted
7788        /// candidate is closer than this on **both** horizontal axes (the
7789        /// generators' spread rule).
7790        #[serde(default, skip_serializing_if = "Option::is_none")]
7791        spacing: Option<u32>,
7792        /// Optional cap on how many items are placed (highest-noise first).
7793        #[serde(default, skip_serializing_if = "Option::is_none")]
7794        limit: Option<u32>,
7795    },
7796    /// Structural flora (spec-0017): plant hand-shaped trees via the
7797    /// lean-or-grow canopy rules — a canopy that would reach a
7798    /// keep-clear (`avoid`) column first leans one block away from it; if that
7799    /// still covers the corridor the tree grows tall instead, arching its
7800    /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
7801    Plant {
7802        /// The region (an earlier `select` in this batch) to plant in. Trunk
7803        /// cells are standable region cells (air over an occupied cell).
7804        region: RegionId,
7805        /// The tree species (canopy shape rules are per-species).
7806        tree: TreeKind,
7807        /// How many trees to plant (≥ 1; highest-noise candidates first).
7808        count: u32,
7809        /// Keep-clear envelopes: trunks never stand in these columns and
7810        /// canopies lean/grow to clear them.
7811        #[serde(default, skip_serializing_if = "Vec::is_empty")]
7812        avoid: Vec<RegionId>,
7813        /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
7814        #[serde(default, skip_serializing_if = "Option::is_none")]
7815        spacing: Option<u32>,
7816    },
7817    /// Stamp a prefab fragment (spec-0017): copy a library prefab's
7818    /// non-air cells into the world at a frame-resolved position. The fragment
7819    /// is a first-class library prefab — its provenance/license metadata is
7820    /// recorded and validated exactly like any placed prefab (ADR-0013);
7821    /// nothing outside the library can be stamped.
7822    Fragment {
7823        /// The library prefab to stamp.
7824        prefab: PrefabId,
7825        /// The frame `at` resolves in.
7826        frame: EditFrame,
7827        /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
7828        at: [i32; 3],
7829        /// Placement rotation (default `none`), the same quarter-turn set as
7830        /// `/place template`.
7831        #[serde(default, skip_serializing_if = "Option::is_none")]
7832        rotation: Option<FragmentRotation>,
7833    },
7834    /// Explicit region relight (spec-0017, spec-0010 machinery): run the
7835    /// deterministic fixture-placement pass over ONE region and bake the
7836    /// resulting fixtures into the edit script's writes — authorial control of
7837    /// where fixtures land, instead of the whole-area pass's greedy siting.
7838    /// (The whole-area relight still re-proves after every batch either way.)
7839    Relight {
7840        /// The region (an earlier `select` in this batch) to relight: its
7841        /// reachable walkable cells are brought to `min_light`.
7842        region: RegionId,
7843        /// Fixture override; default = the area's declared `lighting.fixture`.
7844        #[serde(default, skip_serializing_if = "Option::is_none")]
7845        fixture: Option<Fixture>,
7846        /// Target light override (1..=14); default = the area's declared
7847        /// `lighting.min_light`. Required (with `fixture`) when the area
7848        /// declares no `lighting`.
7849        #[serde(default, skip_serializing_if = "Option::is_none")]
7850        min_light: Option<u8>,
7851    },
7852    /// L2 massing (spec-0017): replace a placed piece with another
7853    /// library prefab that re-mates every currently-mated socket at its exact
7854    /// world pose (any rotation; overlap-checked). Applied at **plan** time —
7855    /// the whole downstream ladder (anchors, gate reachability, assembly,
7856    /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
7857    /// verbs live in massing-only batches, ordered before every detailing
7858    /// batch.
7859    SwapPiece {
7860        /// The piece's placement index in its area's solved layout (0-based,
7861        /// entry first).
7862        piece: u32,
7863        /// The prefab the indexed piece must currently be (drift guard).
7864        prefab: PrefabId,
7865        /// The library prefab to swap in.
7866        with: PrefabId,
7867    },
7868    /// L2 massing (spec-0017): attach a new piece at a specific **open**
7869    /// (unmated) socket of an existing piece — the targeted form of the
7870    /// solver's frontier attach. The socket opens (its seal becomes a
7871    /// passage); the new piece's other sockets seal.
7872    InsertPiece {
7873        /// The host piece's placement index.
7874        at_piece: u32,
7875        /// The prefab the host piece must currently be (drift guard).
7876        prefab: PrefabId,
7877        /// The host's connector index (prefab metadata `connectors` order).
7878        socket: u32,
7879        /// The library prefab to attach.
7880        insert: PrefabId,
7881    },
7882    /// L2 massing (spec-0017): remove a **leaf** piece (exactly one
7883    /// mated socket; never the entry piece). The neighbour's socket unmates
7884    /// and re-seals. Removal shifts later placement indices — order removals
7885    /// before other index-referencing massing verbs.
7886    RemovePiece {
7887        /// The piece's placement index.
7888        piece: u32,
7889        /// The prefab the indexed piece must currently be (drift guard).
7890        prefab: PrefabId,
7891    },
7892    /// L2 massing (spec-0017): override one socket's seal — `open`
7893    /// clears the opening to a passage, `sealed` walls it up — independent of
7894    /// its mated state (sealing a mated doorway makes a wall between joined
7895    /// pieces; opening an unmated exterior socket exposes the outside, which
7896    /// the boundary-safety proof then judges).
7897    RewireSocket {
7898        /// The piece's placement index.
7899        piece: u32,
7900        /// The prefab the indexed piece must currently be (drift guard).
7901        prefab: PrefabId,
7902        /// The connector index (prefab metadata `connectors` order).
7903        socket: u32,
7904        /// The socket's new state.
7905        state: SocketState,
7906    },
7907    /// L2 massing (spec-0017): re-pick this piece from its area pool's
7908    /// compatible members (weighted, seeded from the campaign seed + this
7909    /// verb's script position — moving the verb deliberately re-rolls). The
7910    /// current prefab is excluded, so a reseed always changes the piece or
7911    /// errors loudly.
7912    ReseedPiece {
7913        /// The piece's placement index.
7914        piece: u32,
7915        /// The prefab the indexed piece must currently be (drift guard).
7916        prefab: PrefabId,
7917    },
7918}
7919
7920/// A socket seal state for `rewire-socket` (spec-0017).
7921#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7922#[serde(rename_all = "kebab-case")]
7923pub enum SocketState {
7924    /// The opening is cleared to a passage.
7925    Open,
7926    /// The opening is walled up.
7927    Sealed,
7928}
7929
7930/// A tree species for the `plant` verb (spec-0017). One species per
7931/// canopy-rule implementation; the shipped rule set is the lean-or-grow oak.
7932#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7933#[serde(rename_all = "kebab-case")]
7934pub enum TreeKind {
7935    /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
7936    /// lean-or-grow corridor rules.
7937    Oak,
7938}
7939
7940/// A `fragment` stamp rotation (spec-0017) — the `/place template`
7941/// quarter-turn set.
7942#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7943#[serde(rename_all = "kebab-case")]
7944pub enum FragmentRotation {
7945    /// No rotation.
7946    None,
7947    /// 90° clockwise.
7948    Clockwise90,
7949    /// 180°.
7950    Clockwise180,
7951    /// 90° counterclockwise.
7952    Counterclockwise90,
7953}
7954
7955/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
7956/// frame; compositions combine earlier regions of the same batch.
7957#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7958#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7959pub enum RegionShape {
7960    /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
7961    Box {
7962        /// The coordinate frame `min`/`max` resolve in.
7963        frame: EditFrame,
7964        /// Inclusive minimum corner (frame coordinates).
7965        min: [i32; 3],
7966        /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
7967        max: [i32; 3],
7968    },
7969    /// The band of cells at `from..=to` blocks relative to each column's
7970    /// terrain surface (the highest non-air cell of the column) within an
7971    /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
7972    /// surface; `from: 0, to: 0` is the surface cells themselves; negative
7973    /// offsets reach below the surface.
7974    SurfaceBand {
7975        /// The earlier region whose columns are scanned.
7976        over: RegionId,
7977        /// Inclusive band start, relative to each column's surface y.
7978        from: i32,
7979        /// Inclusive band end (≥ `from`), relative to each column's surface y.
7980        to: i32,
7981    },
7982    /// The cells of an earlier region whose current block matches one of
7983    /// `blocks` (base ids; blockstate suffixes ignored when matching).
7984    PaletteMatch {
7985        /// The earlier region to filter.
7986        within: RegionId,
7987        /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
7988        blocks: Vec<String>,
7989    },
7990    /// The union of earlier regions.
7991    Union {
7992        /// The earlier regions to unite (≥ 2).
7993        of: Vec<RegionId>,
7994    },
7995    /// The intersection of earlier regions.
7996    Intersect {
7997        /// The earlier regions to intersect (≥ 2).
7998        of: Vec<RegionId>,
7999    },
8000    /// An earlier region minus other earlier regions.
8001    Subtract {
8002        /// The earlier region to start from.
8003        base: RegionId,
8004        /// The earlier regions to remove from it (≥ 1).
8005        remove: Vec<RegionId>,
8006    },
8007}
8008
8009/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
8010/// piece-local or anchor-relative — never raw world coordinates, so an edit
8011/// script survives a layout's world placement moving.
8012#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8013#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8014pub enum EditFrame {
8015    /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
8016    /// the piece's structure origin, axes as authored (the compiler applies
8017    /// the piece's placed rotation).
8018    PieceLocal {
8019        /// The piece's placement index in the area's solved layout (0-based,
8020        /// entry piece first — the order `delvec snapshot`'s manifest lists).
8021        piece: u32,
8022        /// The prefab the indexed piece must be (a drift guard: if a re-solve
8023        /// changed the layout, the mismatch is a loud compile error, never a
8024        /// silently misplaced edit).
8025        prefab: PrefabId,
8026    },
8027    /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
8028    /// anchor cell, axes world-aligned.
8029    AnchorRelative {
8030        /// The anchor (prefab metadata, resolved by the compiler).
8031        anchor: AnchorId,
8032    },
8033}
8034
8035/// A surface operation for the `morph` verb (spec-0017).
8036#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8037#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8038pub enum MorphOp {
8039    /// Raise each column's surface by up to `by` blocks, drawing the added
8040    /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
8041    /// as natural strata, not an extruded slab).
8042    Raise {
8043        /// How many blocks to raise each column (≥ 1).
8044        by: u32,
8045        /// The palette recipe for the added cells.
8046        recipe: PaletteRecipe,
8047    },
8048    /// Lower each column's surface by up to `by` blocks (carving the topmost
8049    /// solid cells to air).
8050    Lower {
8051        /// How many blocks to lower each column (≥ 1).
8052        by: u32,
8053    },
8054    /// Relax each column's surface toward the mean of its cardinal neighbours
8055    /// (one block per pass), turning steps into slopes. Added cells draw from
8056    /// `recipe`; removed cells carve to air. Deterministic double-buffered
8057    /// passes in fixed scan order.
8058    Smooth {
8059        /// Relaxation passes (≥ 1).
8060        passes: u32,
8061        /// The palette recipe for cells a pass adds.
8062        recipe: PaletteRecipe,
8063    },
8064}
8065
8066/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
8067/// smooth value-noise sample, the island/cave generators' proven primitive —
8068/// picks cluster into strata/patches instead of per-cell speckle, and a
8069/// single-entry recipe is the degenerate (discouraged) uniform case.
8070#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8071#[serde(deny_unknown_fields)]
8072pub struct PaletteRecipe {
8073    /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
8074    pub blocks: Vec<PaletteBlock>,
8075    /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
8076    /// across). Larger = smaller patches. Must be finite and > 0.
8077    #[serde(default, skip_serializing_if = "Option::is_none")]
8078    pub scale: Option<f64>,
8079}
8080
8081/// One weighted entry of a [`PaletteRecipe`].
8082#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8083#[serde(deny_unknown_fields)]
8084pub struct PaletteBlock {
8085    /// Vanilla block id (validated against the pinned 1.21.11 registry), with
8086    /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
8087    pub block: String,
8088    /// Relative weight (finite, > 0).
8089    pub weight: f64,
8090}
8091
8092/// Where in the campaign one effect sits — the attribution every per-branch
8093/// proof and the branch chronicle need (spec-0025).
8094#[derive(Clone, Debug, PartialEq, Eq)]
8095pub enum EffectSite {
8096    /// A quest's `on_objective_complete[<objective>]` bundle.
8097    Objective {
8098        /// The owning quest.
8099        quest: String,
8100        /// The objective whose completion fires the bundle.
8101        objective: String,
8102    },
8103    /// A quest's `on_complete` bundle.
8104    QuestComplete {
8105        /// The owning quest.
8106        quest: String,
8107    },
8108    /// An environment trigger's `effects` bundle — ambient, no DAG position.
8109    Trigger {
8110        /// The trigger id.
8111        trigger: String,
8112    },
8113    /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
8114    Trap {
8115        /// The trap id.
8116        trap: String,
8117    },
8118    /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
8119    /// DAG position, and the only site that does not live in the quests stage.
8120    ///
8121    /// This variant did not exist until the effect-root sweep, and its absence was
8122    /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
8123    /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
8124    /// (`combat::actor_beats`, `validate::difficulty_checks`,
8125    /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
8126    /// even if their authors had thought of it. Widening the type is what let the
8127    /// walk widen.
8128    DialogueRespawn {
8129        /// The NPC whose dialogue tree hosts the option.
8130        npc: String,
8131        /// The node the option sits under.
8132        node: String,
8133    },
8134    /// A `shortcuts[].on_unlock` bundle (spec-0016 §2) — ambient, no DAG
8135    /// position, and the sixth root: representable here only since spec-0031, for
8136    /// exactly the reason [`EffectSite::DialogueRespawn`] records above.
8137    ShortcutUnlock {
8138        /// The shortcut id.
8139        shortcut: String,
8140    },
8141    /// A `shops[].offers[].effects` bundle (DSL v0.10, spec-0032) — ambient, no
8142    /// DAG position: nobody is forced to buy anything.
8143    ShopOffer {
8144        /// The shop id.
8145        shop: String,
8146        /// The offer's index within that shop, which is also its button order.
8147        offer: usize,
8148    },
8149    /// The campaign's `on_death` bundle (spec-0031) — ambient, no DAG position,
8150    /// and no owning object: there is one per campaign.
8151    OnDeath,
8152}
8153
8154impl EffectSite {
8155    /// The quest this site belongs to, if it has a DAG position at all.
8156    ///
8157    /// **This `Option` is the capability, and it is on the enum rather than on the
8158    /// variants that happen to have a quest.** Only two of the eight sites name a
8159    /// quest, because only two of the eight roots HAVE a DAG position: an ambient
8160    /// root — a trigger, a trap payload, a dialogue `on_respawn`, a shortcut's
8161    /// `on_unlock`, the campaign's `on_death`, a shop offer — fires at a moment no
8162    /// static model can order, and inventing a quest for one would be exactly the
8163    /// over-attribution the completability model must not make. Asking the question
8164    /// of every variant and getting an honest `None` is the lift;
8165    /// `tools/check-capability-ownership.py` check D asked for it while the field
8166    /// was still cross-cutting, and spec-0032's eighth site took it below that
8167    /// threshold, so the reasoning lives here now rather than in an exemption.
8168    pub fn quest(&self) -> Option<&str> {
8169        match self {
8170            EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
8171                Some(quest)
8172            }
8173            EffectSite::Trigger { .. }
8174            | EffectSite::Trap { .. }
8175            | EffectSite::DialogueRespawn { .. }
8176            | EffectSite::ShortcutUnlock { .. }
8177            | EffectSite::ShopOffer { .. }
8178            | EffectSite::OnDeath => None,
8179        }
8180    }
8181}
8182
8183/// Visit **every** effect the compiler can lower — at every one of the five
8184/// effect roots, top-level and transitively nested — in a fixed deterministic
8185/// order, invoking `f(json_pointer, site, effect)`.
8186///
8187/// The roots come from [`crate::effects::for_each_effect_root`], the single
8188/// enumeration; nesting is descended through the single
8189/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
8190/// enumerated here, which is the point: this walk used to hand-list four of the
8191/// five roots (it had no `EffectSite` variant for the fifth), so every proof
8192/// defined in terms of it inherited that blind spot.
8193pub fn for_each_campaign_effect<'a>(
8194    c: &'a crate::envelope::Campaign,
8195    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
8196) {
8197    crate::effects::for_each_effect_root(c, &mut |root, list| {
8198        let site = match root.owner {
8199            crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
8200                EffectSite::Objective {
8201                    quest: quest.id.as_str().to_string(),
8202                    objective: objective.to_string(),
8203                }
8204            }
8205            crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
8206                quest: quest.id.as_str().to_string(),
8207            },
8208            crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
8209                trigger: t.id.as_str().to_string(),
8210            },
8211            crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
8212                trap: t.id.as_str().to_string(),
8213            },
8214            crate::effects::EffectRootOwner::DialogueRespawn => {
8215                // The npc and node are in the root's path; parse them back rather
8216                // than widening the root walk's owner for one consumer.
8217                let seg = |n: usize| -> String {
8218                    root.path.split('/').nth(n).unwrap_or_default().to_string()
8219                };
8220                EffectSite::DialogueRespawn {
8221                    npc: seg(3),
8222                    node: seg(5),
8223                }
8224            }
8225            crate::effects::EffectRootOwner::ShortcutUnlock(s) => EffectSite::ShortcutUnlock {
8226                shortcut: s.id.as_str().to_string(),
8227            },
8228            crate::effects::EffectRootOwner::OnDeath => EffectSite::OnDeath,
8229            crate::effects::EffectRootOwner::ShopOffer(h) => EffectSite::ShopOffer {
8230                shop: h.id.as_str().to_string(),
8231                // The offer index is in the root's path (`…/offers/<i>/effects`),
8232                // parsed back rather than widening the owner for one consumer —
8233                // the same call the dialogue arm above makes.
8234                offer: root
8235                    .path
8236                    .split('/')
8237                    .nth(4)
8238                    .and_then(|n| n.parse().ok())
8239                    .unwrap_or(0),
8240            },
8241        };
8242        for (i, eff) in list.iter().enumerate() {
8243            campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
8244        }
8245    });
8246}
8247
8248fn campaign_effect_deep<'a>(
8249    eff: &'a QuestEffect,
8250    path: &str,
8251    site: &EffectSite,
8252    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
8253) {
8254    f(path, site, eff);
8255    for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
8256        for (j, inner) in list.iter().enumerate() {
8257            campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
8258        }
8259    }
8260}
8261
8262// ---------------------------------------------------------------------------
8263// The spine authority (`QuestPlanContent::spine`)
8264// ---------------------------------------------------------------------------
8265
8266#[cfg(test)]
8267mod spine_tests {
8268    use super::QuestPlanContent;
8269
8270    /// Build a plan from `(id, deps)` pairs plus a finale. JSON rather than a
8271    /// struct literal on purpose: a field added to `PlannedQuest` later must not
8272    /// red these tests for a reason that has nothing to do with the spine.
8273    fn plan(finale: &str, quests: &[(&str, &[&str])]) -> QuestPlanContent {
8274        let quests: Vec<serde_json::Value> = quests
8275            .iter()
8276            .map(|(id, deps)| {
8277                serde_json::json!({
8278                    "id": id,
8279                    "goal": "g",
8280                    "area": "area/keep",
8281                    "npcs": [],
8282                    "depends_on": deps,
8283                    "mandatory": true,
8284                    "act": 1,
8285                })
8286            })
8287            .collect();
8288        serde_json::from_value(serde_json::json!({
8289            "finale": finale,
8290            "quests": quests,
8291        }))
8292        .expect("plan fixture parses")
8293    }
8294
8295    fn sorted(p: &QuestPlanContent) -> Vec<String> {
8296        p.spine().into_iter().map(str::to_owned).collect()
8297    }
8298
8299    #[test]
8300    fn a_chain_is_wholly_spine() {
8301        let p = plan(
8302            "quest/c",
8303            &[
8304                ("quest/a", &[]),
8305                ("quest/b", &["quest/a"]),
8306                ("quest/c", &["quest/b"]),
8307            ],
8308        );
8309        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c"]);
8310    }
8311
8312    #[test]
8313    fn a_quest_the_finale_does_not_depend_on_is_off_the_spine() {
8314        // Exactly the `DW0132` shape: the plan does not converge, and the spine
8315        // is the half that does. The authority answers, it does not refuse — the
8316        // refusal is `validate`'s, built on this answer.
8317        let p = plan("quest/end", &[("quest/end", &[]), ("quest/side-trip", &[])]);
8318        assert_eq!(sorted(&p), ["quest/end"]);
8319    }
8320
8321    #[test]
8322    fn a_diamond_counts_the_join_once() {
8323        let p = plan(
8324            "quest/d",
8325            &[
8326                ("quest/a", &[]),
8327                ("quest/b", &["quest/a"]),
8328                ("quest/c", &["quest/a"]),
8329                ("quest/d", &["quest/b", "quest/c"]),
8330            ],
8331        );
8332        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c", "quest/d"]);
8333    }
8334
8335    #[test]
8336    fn a_cycle_terminates_and_yields_a_set() {
8337        // `DW0130` refuses this plan, but the authority is asked before that
8338        // verdict is known (the layout binding prints on an erroring campaign),
8339        // so it must terminate rather than hang.
8340        let p = plan(
8341            "quest/b",
8342            &[("quest/a", &["quest/b"]), ("quest/b", &["quest/a"])],
8343        );
8344        assert_eq!(sorted(&p), ["quest/a", "quest/b"]);
8345    }
8346
8347    #[test]
8348    fn a_dangling_dependency_is_reported_and_expands_no_further() {
8349        // The deliberate difference between the two derivations this function
8350        // replaced. `validate` pruned undeclared ids before walking; the
8351        // authority does not, because pruning them would make the set disagree
8352        // with the document, and naming an id the plan does not declare is
8353        // `DW0112`'s finding rather than the spine's.
8354        let p = plan("quest/end", &[("quest/end", &["quest/ghost"])]);
8355        assert_eq!(sorted(&p), ["quest/end", "quest/ghost"]);
8356    }
8357
8358    #[test]
8359    fn an_undeclared_finale_is_the_whole_spine() {
8360        // `DW0131`'s shape. The answer is honest about the document: nothing the
8361        // plan declares is on the spine of a finale it never declared.
8362        let p = plan(
8363            "quest/ghost",
8364            &[("quest/a", &[]), ("quest/b", &["quest/a"])],
8365        );
8366        assert_eq!(sorted(&p), ["quest/ghost"]);
8367    }
8368}