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::firework::FireworkExplosion;
13use crate::layout::StationKind;
14
15use crate::ids::{
16    ActorId, AmbushId, AnchorId, AreaId, BranchId, BranchPointId, ClassId, DialogueId, EditBatchId,
17    EndingId, FlagId, LethalVolumeId, LootId, NpcId, ObjectiveId, PoolId, PrefabId, QuestId,
18    RegionId, ShopId, ShortcutId, StakeId, StateId, TimedGateId, TrapId, TriggerId, WaveId,
19};
20
21/// serde default helper: `true` (used by DSL v0.4 `trigger.once`).
22fn default_true() -> bool {
23    true
24}
25
26/// serde `skip_serializing_if` helper: skip a `false` bool (DSL v0.4
27/// `objective.stealth`), keeping older campaigns byte-identical.
28fn is_false(b: &bool) -> bool {
29    !*b
30}
31
32// ---------------------------------------------------------------------------
33// Stage 1 — world
34// ---------------------------------------------------------------------------
35
36/// Stage 1 payload: setting, seed and the areas that make up the delve.
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
38#[serde(deny_unknown_fields)]
39pub struct WorldContent {
40    /// Player-facing delve title.
41    pub title: String,
42    /// One-line thematic description.
43    pub theme: String,
44    /// Short narrative premise.
45    pub premise: String,
46    /// The single downstream randomness source (ADR-0006).
47    pub seed: u64,
48    /// Informational pacing target in minutes (v0: not enforced).
49    pub target_minutes: u32,
50    /// The areas the delve is made of; each binds exactly one of `prefab` /
51    /// `prefab_pool`.
52    ///
53    /// **A campaign declares its placement in exactly one document, so this
54    /// list is empty on a site-plan campaign** (`DW0839`): where a
55    /// `site-plan.json` is present the plan is the placement authority, its one
56    /// place is `area/site`, and declaring `areas[]` as well gives every
57    /// question about where something is two answers. Empty is therefore a
58    /// legitimate and common value, not a campaign that forgot to place
59    /// anything.
60    pub areas: Vec<Area>,
61    /// Additional author-declared translation languages (BCP-47-style codes, e.g.
62    /// `["zh-cn"]`). English (`en`) is implicit, always canonical, and is **never**
63    /// listed here (spec-0001 i18n addendum). Absent or empty = English-only. Every
64    /// declared language must ship a fully-covering `l10n/<code>.json` sidecar
65    /// (`DW0180`/`DW0181`). Stage docs themselves stay pure English.
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub languages: Vec<String>,
68    /// **The hour this delve is played at** (DSL v0.5, spec-0010; required since
69    /// spec-0061). Dimension-global; frozen by environment sealing
70    /// (`advance_time false`) so the set state persists. Affects sky attenuation
71    /// in the compiler's assembled-light model.
72    ///
73    /// **Required, and it has no default.** "This delve is played at noon" is a
74    /// design decision, and a mechanism that supplies one silently when the
75    /// author said nothing is exactly what `CLAUDE.md` forbids a primitive from
76    /// encoding — the same ruling spec-0060 §4.1 made for `walk_y`. It is also
77    /// the world half of the comparison `DW0890` makes against the approved
78    /// design's rows, so every campaign has to state it for the comparison to
79    /// have two sides. Emission is unchanged: `time set <kw>` was always
80    /// emitted, so a campaign that already declared this builds
81    /// byte-identically.
82    pub time: WorldTime,
83    /// **The weather this delve is played in** (DSL v0.5, spec-0010; required
84    /// since spec-0061). Dimension-global; frozen by environment sealing
85    /// (`advance_weather false`). Rain and thunder attenuate effective sky
86    /// brightness in the assembled-light model.
87    ///
88    /// Required, with no default, for the reason [`WorldContent::time`] gives.
89    /// Emission is unchanged: `weather <kw>` is emitted only for a declared
90    /// non-`clear` weather, because `clear` is vanilla's own state.
91    pub weather: WorldWeather,
92    /// Declared combat difficulty (DSL v0.6). Absent =
93    /// the compiler's historical derivation — `easy` when the campaign fields any
94    /// wave, `peaceful` when it fields none — which is what keeps every campaign
95    /// written before this field byte-identical. Declaring it overrides the
96    /// derivation for **both** the shipped `server.properties` and a `/difficulty`
97    /// in the sealing baseline, so the declaration also holds when the datapack is
98    /// dropped into somebody else's world.
99    ///
100    /// `peaceful` is rejected (`DW0468`). Raising difficulty changes the damage
101    /// players take — easy halves it — so combat arithmetic tuned under the old
102    /// implicit `easy` must be redone.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub difficulty: Option<WorldDifficulty>,
105    /// The scenic horizon: the ground and the sky the map stands in
106    /// (spec-0026). Absent or `void` is the void world. `ocean` swaps the world
107    /// generator for a deterministic superflat sea (bedrock/stone/water, sea
108    /// level y=62) and drops the area datum to y=60 so island pieces meet the
109    /// sea at their authored waterline. `valley` rings the map in a generated
110    /// mountain annulus — the one base that builds terrain rather than picking
111    /// a generator. Either a string shorthand or the object form
112    /// `{base, …params}`; see [`Horizon`].
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub horizon: Option<Horizon>,
115    /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
116    /// derives a region from the placed geometry and a per-second clock returns any
117    /// player who leaves it to the last checkpoint. Required when `horizon` is
118    /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub boundary: Option<Boundary>,
121    /// The closing line on the campaign-completion advancement — the last
122    /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
123    /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
124    /// finale quest's `goal`, which is already both campaign-derived and
125    /// inventoried; the description was previously the hardcoded English
126    /// "You left the keep." on *every* delve, whatever its theme or language.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub outro: Option<String>,
129    /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
130    /// party of one is always legal and every pre-0.6 campaign keeps that reading.
131    /// A design whose beats genuinely need `n` players — two rooms whose switches
132    /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
133    /// lobby then refuses to start below it: the class-selection dialog stays shut
134    /// and the waiting players get a party-count actionbar instead.
135    ///
136    /// Progression is party state either way (spec-0018), so this is a *declaration
137    /// of intent*, not a mechanism: it makes a mandatory-n design first-class
138    /// and turns on the analyzer's n-agent division
139    /// proof. Out of `1..=4` is `DW0370`.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub min_players: Option<u8>,
142}
143
144/// Who a granted item goes to (DSL v0.6, spec-0018).
145///
146/// Progression is a fact about the party, so the default for every `give-item` and
147/// every class-kit entry is **all**: a quest beat that arms the party arms all of
148/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
149/// the stake): exactly one copy enters the party, handed to the player whose action
150/// earned it, and the party passes it around physically.
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152#[serde(rename_all = "kebab-case")]
153pub enum Carrier {
154    /// Every party member receives the item (the default).
155    #[default]
156    All,
157    /// Exactly one copy, to the player whose action fired the effect.
158    One,
159}
160
161/// A declared world time state (DSL v0.5, spec-0010). The sole difference from
162/// vanilla is that the daylight cycle is frozen (`advance_time false`), so a set
163/// state persists for the whole delve until a `set-time` effect cuts to another.
164///
165/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
166/// tick count, and the tick form is the general one — so the states worth naming
167/// for a delve's pacing are not limited to the four keywords. `dusk` and `dawn`
168/// are the tick form exposed first-class, per the
169/// no-hack rule: the DSL names the beat, the compiler emits `/time set <ticks>`.
170/// Every keyword-to-tick mapping lives in exactly one table ([`WorldTime::spec`]),
171/// and the four vanilla keywords still emit their keyword verbatim, so existing
172/// campaigns are byte-identical.
173///
174/// **There is no `Default`** (spec-0061 §4). A default hour is a design decision
175/// wearing a mechanism's clothes, and `#[default] Noon` is what let a delve whose
176/// whole approved look was night build, light-check and render under a blue noon
177/// sky. Removing the impl is what makes that unwritable rather than merely
178/// discouraged: `WorldContent::time` is required, and nothing can supply an hour
179/// the author did not.
180#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "kebab-case")]
182pub enum WorldTime {
183    /// Morning daylight (`/time set day`, 1000 ticks).
184    Day,
185    /// Midday, brightest (`/time set noon`, 6000 ticks).
186    Noon,
187    /// Sunset — the sky visibly going orange and the day ending
188    /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
189    /// has finished setting, which is what the `night` keyword already sets, so
190    /// 13000 would make `dusk` a synonym rather than its own beat.
191    Dusk,
192    /// Night, sun fully down (`/time set night`, 13000 ticks).
193    Night,
194    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
195    Midnight,
196    /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
197    /// `sunrise` is accepted as a synonym on input.
198    #[serde(alias = "sunrise")]
199    Dawn,
200}
201
202impl WorldTime {
203    /// The single keyword/tick table: `(the /time set argument, daytime ticks)`.
204    ///
205    /// A state vanilla names keeps its keyword — the argument the compiler has
206    /// always emitted — so no shipped campaign's bytes move. A state vanilla does
207    /// not name emits the equivalent tick count, which is the same primitive.
208    const fn spec(self) -> (&'static str, i64) {
209        match self {
210            WorldTime::Day => ("day", 1000),
211            WorldTime::Noon => ("noon", 6000),
212            WorldTime::Dusk => ("12000", 12000),
213            WorldTime::Night => ("night", 13000),
214            WorldTime::Midnight => ("midnight", 18000),
215            WorldTime::Dawn => ("23000", 23000),
216        }
217    }
218
219    /// The vanilla `/time set` argument — a keyword for the four states vanilla
220    /// names, a tick count otherwise.
221    pub fn token(self) -> &'static str {
222        self.spec().0
223    }
224
225    /// The `daytime` tick value this state sets (the `time query daytime`
226    /// read-back). Vanilla constants: day=1000, noon=6000, dusk=12000 (sunset
227    /// onset), night=13000, midnight=18000, dawn=23000.
228    pub fn daytime_ticks(self) -> i64 {
229        self.spec().1
230    }
231
232    /// **The word an author writes** — this state's spelling in a document.
233    ///
234    /// Not [`WorldTime::token`], which is the `/time set` argument and is a raw
235    /// tick count for the two states vanilla does not name. A diagnostic that
236    /// asks an author to declare an hour has to say `dusk`, not `12000`: the
237    /// number is what the compiler emits and is not writable in `world.json`.
238    pub fn keyword(self) -> &'static str {
239        match self {
240            WorldTime::Day => "day",
241            WorldTime::Noon => "noon",
242            WorldTime::Dusk => "dusk",
243            WorldTime::Night => "night",
244            WorldTime::Midnight => "midnight",
245            WorldTime::Dawn => "dawn",
246        }
247    }
248}
249
250/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
251/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
252///
253/// **No `Default`**, for the reason [`WorldTime`] gives.
254#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
255#[serde(rename_all = "kebab-case")]
256pub enum WorldWeather {
257    /// Clear sky (`/weather clear`). Vanilla's own state, so emission writes no
258    /// `/weather` command for it.
259    Clear,
260    /// Rain (`/weather rain`).
261    Rain,
262    /// Thunderstorm (`/weather thunder`).
263    Thunder,
264}
265
266impl WorldWeather {
267    /// The word an author writes — identical to [`WorldWeather::token`] for
268    /// every state, and stated separately so a message that names a document's
269    /// vocabulary reads the document's vocabulary. See [`WorldTime::keyword`],
270    /// where the two differ.
271    pub fn keyword(self) -> &'static str {
272        self.token()
273    }
274
275    /// The vanilla `/weather` keyword.
276    pub fn token(self) -> &'static str {
277        match self {
278            WorldWeather::Clear => "clear",
279            WorldWeather::Rain => "rain",
280            WorldWeather::Thunder => "thunder",
281        }
282    }
283}
284
285/// The declared combat difficulty of the delve (DSL v0.6). Values are the
286/// vanilla `/difficulty` keywords.
287///
288/// Difficulty is the single largest lever on how hard a delve *feels*, so the
289/// campaign declares it rather than letting the compiler choose. Easy **halves
290/// incoming player damage** — `min(dmg / 2 + 1, dmg)` — so a campaign tuned
291/// under `easy` is tuned against a halved world. A campaign that
292/// raises this must redo that arithmetic.
293///
294/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
295/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
296/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
297/// campaign would silently vanish. It is a variant only so the compiler can say
298/// that in a diagnostic instead of a serde "unknown variant" parse error.
299#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
300#[serde(rename_all = "kebab-case")]
301pub enum WorldDifficulty {
302    /// `/difficulty easy` — the compiler's historical choice for a wave
303    /// campaign, and the default reading of an absent field. Incoming player
304    /// damage is halved (`min(dmg / 2 + 1, dmg)`).
305    #[default]
306    Easy,
307    /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
308    Normal,
309    /// `/difficulty hard` — amplified damage, and zombies reinforce.
310    Hard,
311    /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
312    /// the rejection can be a diagnostic with a rationale.
313    Peaceful,
314}
315
316impl WorldDifficulty {
317    /// The vanilla `/difficulty` keyword.
318    pub fn token(self) -> &'static str {
319        match self {
320            WorldDifficulty::Peaceful => "peaceful",
321            WorldDifficulty::Easy => "easy",
322            WorldDifficulty::Normal => "normal",
323            WorldDifficulty::Hard => "hard",
324        }
325    }
326
327    /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
328    /// `/difficulty` query command returns — the only vanilla read-back path for
329    /// the setting, and so what the generated PackTest asserts on.
330    pub fn id(self) -> i32 {
331        match self {
332            WorldDifficulty::Peaceful => 0,
333            WorldDifficulty::Easy => 1,
334            WorldDifficulty::Normal => 2,
335            WorldDifficulty::Hard => 3,
336        }
337    }
338}
339
340/// A scenic horizon (spec-0026). A horizon is a **composition of orthogonal
341/// axes**, not an enum of monoliths: a **base** — what surrounds the map — and
342/// that base's params. The field accepts a plain string shorthand
343/// ([`HorizonName`]) or the object form [`HorizonSpec`] `{base, …params}`.
344///
345/// Consumers never match this wire enum. [`Horizon::resolved`] desugars both
346/// forms into one [`ResolvedHorizon`] with the pinned defaults applied, and
347/// [`horizon_base`] answers the one question most callers have.
348#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
349#[serde(untagged)]
350pub enum Horizon {
351    /// A bare base name — `"ocean"` is exactly `{base: "ocean"}`. The two
352    /// bases that predate the horizon library were spelled this way and still
353    /// are, byte-identically; a base added since is spellable this way too,
354    /// because a base with every param at its default has nothing else to say.
355    Name(HorizonBase),
356    /// The object form `{base, …params}`.
357    Spec(HorizonSpec),
358}
359
360/// What surrounds the map.
361///
362/// **One enumeration of bases, reachable two ways.** The shorthand
363/// `horizon: "ocean"` and the object form `horizon: {base: "ocean"}` name this
364/// same variant, which is what stops a base from existing in one spelling and
365/// not the other. A separate list of "names" beside this one would be two
366/// enumerations of the same thing, and the second base added would land in
367/// whichever of them its author was looking at.
368///
369/// What is deliberately NOT here is a name that stands for a base plus a set of
370/// params. Such a name reads as a thing, and the whole claim of this design is
371/// that it is not one — it is a base with params set. A spelling that hides
372/// which params it sets makes that claim unverifiable by looking at the
373/// document, and buys a few saved keystrokes for it. Each base carries its own params on
374/// [`HorizonSpec`], and a param foreign to the declared base is refused rather
375/// than ignored.
376#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
377#[serde(rename_all = "kebab-case")]
378pub enum HorizonBase {
379    /// Void superflat; no surround. The default.
380    #[default]
381    Void,
382    /// Pinned water superflat, sea level 62; no surround.
383    Ocean,
384    /// A mountain annulus around a flat gap floor, with void ambient below the
385    /// tile skirt. The one base that generates terrain.
386    Valley,
387}
388
389impl HorizonBase {
390    /// The kebab wire name.
391    pub fn token(self) -> &'static str {
392        match self {
393            HorizonBase::Void => "void",
394            HorizonBase::Ocean => "ocean",
395            HorizonBase::Valley => "valley",
396        }
397    }
398
399    /// Whether this base generates a surround — compiler-built terrain outside
400    /// the map's own extent. `void` and `ocean` are pure ambient and build
401    /// nothing.
402    pub fn has_surround(self) -> bool {
403        matches!(self, HorizonBase::Valley)
404    }
405}
406
407/// The `horizon` object form: a `base` plus that base's params, all optional
408/// with pinned defaults ([`horizon_defaults`]).
409///
410/// The `valley` surround generator carries a second flora and a second surface
411/// palette (a cherry grove over `minecraft:cherry_grove`) and **this struct
412/// does not expose them.** Every engine surface owes a gallery element in the
413/// change that lands it; the element a second flora needs is a valley overlay,
414/// and one is writable now that a one-area campaign's single prefab states an
415/// extent ([`crate::placement::Extent`], `DW0855`) — the reason recorded here
416/// was that no two-file overlay could ring a map, and that reason is spent.
417/// What is left is that nothing has written the element, and a surface lands
418/// with its element or it does not land. The shape is flat rather than
419/// per-base tagged, and a param foreign to the declared base is refused
420/// (`DW0853`) — so an `ocean` cannot quietly carry a `rim_height` that nothing
421/// reads.
422#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
423#[serde(deny_unknown_fields)]
424pub struct HorizonSpec {
425    /// The base — what surrounds the map.
426    pub base: HorizonBase,
427    /// `valley`: the surround's total footprint as a multiple of the map's, on
428    /// each axis (`2.0..=3.0`, default 2.5).
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub ratio: Option<f64>,
431    /// `valley`: crest height of the rim over the gap floor (`16..=128`,
432    /// default 48).
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub rim_height: Option<i32>,
435}
436
437/// Pinned horizon param defaults. One table, so the doc comments, the resolver
438/// and the diagnostics cannot drift.
439pub mod horizon_defaults {
440    /// `valley.ratio`.
441    pub const RATIO: f64 = 2.5;
442    /// `valley.ratio` lower bound — below 2.0 the annulus has no room for a
443    /// gap floor and a slope run both.
444    pub const RATIO_MIN: f64 = 2.0;
445    /// `valley.ratio` upper bound — above 3.0 the surround is mostly terrain a
446    /// body never reaches, at a cost that is all shipped bytes.
447    pub const RATIO_MAX: f64 = 3.0;
448    /// `valley.rim_height`.
449    pub const RIM_HEIGHT: i32 = 48;
450    /// `valley.rim_height` lower bound — a rim under 16 does not close the
451    /// horizon from a body standing on the gap floor.
452    pub const RIM_HEIGHT_MIN: i32 = 16;
453    /// `valley.rim_height` upper bound — the build range is 384 blocks tall and
454    /// the surround has to fit under whatever the map puts above it.
455    pub const RIM_HEIGHT_MAX: i32 = 128;
456}
457
458/// A horizon with both wire forms desugared and every default applied — the
459/// only view downstream code reads.
460#[derive(Clone, Copy, Debug, PartialEq)]
461pub struct ResolvedHorizon {
462    /// The base.
463    pub base: HorizonBase,
464    /// `valley.ratio`.
465    pub ratio: f64,
466    /// `valley.rim_height`.
467    pub rim_height: i32,
468}
469
470impl Default for ResolvedHorizon {
471    fn default() -> Self {
472        ResolvedHorizon {
473            base: HorizonBase::Void,
474            ratio: horizon_defaults::RATIO,
475            rim_height: horizon_defaults::RIM_HEIGHT,
476        }
477    }
478}
479
480impl ResolvedHorizon {
481    /// The resolved horizon of `base` with every param at its pinned default.
482    pub fn of_base(base: HorizonBase) -> Self {
483        ResolvedHorizon {
484            base,
485            ..Default::default()
486        }
487    }
488}
489
490impl Horizon {
491    /// Desugar either wire form to the one resolved view, defaults applied.
492    pub fn resolved(&self) -> ResolvedHorizon {
493        match self {
494            Horizon::Name(base) => ResolvedHorizon::of_base(*base),
495            Horizon::Spec(s) => ResolvedHorizon {
496                base: s.base,
497                ratio: s.ratio.unwrap_or(horizon_defaults::RATIO),
498                rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT),
499            },
500        }
501    }
502
503    /// The resolved base.
504    pub fn base(&self) -> HorizonBase {
505        self.resolved().base
506    }
507
508    /// True when this declaration needs the horizon-library surface: the object
509    /// form, or a bare name for a base that did not exist before it.
510    ///
511    /// The two bases that predate the library stay writable as bare names at
512    /// the version that introduced them, and their emission does not move —
513    /// which is what makes this a widening rather than a break. What is fenced
514    /// is saying something the old surface had no spelling for.
515    pub fn needs_horizon_library(&self) -> bool {
516        match self {
517            Horizon::Spec(_) => true,
518            Horizon::Name(base) => match base {
519                HorizonBase::Void | HorizonBase::Ocean => false,
520                HorizonBase::Valley => true,
521            },
522        }
523    }
524}
525
526/// The resolved base of an optional stage-1 `horizon` field — `Void` when
527/// absent. The one helper every downstream consumer (placement, the ambient
528/// model, emission) goes through, so that a new base cannot be forgotten at one
529/// of them.
530pub fn horizon_base(horizon: &Option<Horizon>) -> HorizonBase {
531    horizon.as_ref().map(|h| h.base()).unwrap_or_default()
532}
533
534/// The resolved view of an optional stage-1 `horizon` field, with defaults
535/// applied for an absent one.
536pub fn resolved_horizon(horizon: &Option<Horizon>) -> ResolvedHorizon {
537    horizon.as_ref().map(|h| h.resolved()).unwrap_or_default()
538}
539
540/// The default boundary `margin` (blocks of horizontal breathing room added
541/// around the derived region). Separate function so `serde(default = …)` and the
542/// documented literal cannot drift.
543fn default_margin() -> u16 {
544    16
545}
546
547/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
548/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
549/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
550/// placed block − 8) — never authored — so "every anchor is inside" is structural.
551/// Enforcement is a per-second clock that returns any player outside the region to
552/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
553/// damage, no items lost.
554#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
555#[serde(deny_unknown_fields)]
556pub struct Boundary {
557    /// Horizontal breathing room in blocks added around the derived region on
558    /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
559    #[serde(default = "default_margin")]
560    pub margin: u16,
561    /// Actionbar message shown on return. Absent = the compiler's English default.
562    /// When set, it is inventoried under l10n key `world.boundary.message` and is
563    /// translated like every other player-facing string.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub message: Option<String>,
566}
567
568/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
569/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
570/// compiler owns the placement rule and block-light emission.
571#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
572#[serde(rename_all = "kebab-case")]
573pub enum Fixture {
574    /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
575    Torch,
576    /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
577    Lantern,
578    /// Floor campfire (block light 15); never on or adjacent to a required path
579    /// cell (it is a damage source).
580    Campfire,
581    /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
582    Shroomlight,
583}
584
585impl Fixture {
586    /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
587    pub fn token(self) -> &'static str {
588        match self {
589            Fixture::Torch => "torch",
590            Fixture::Lantern => "lantern",
591            Fixture::Campfire => "campfire",
592            Fixture::Shroomlight => "shroomlight",
593        }
594    }
595}
596
597/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
598/// presence puts the area on the relight path: the compiler guarantees every
599/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
600/// with `DW0211` if it cannot.
601#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
602#[serde(deny_unknown_fields)]
603pub struct AreaLighting {
604    /// The fixture the relight pass places.
605    pub fixture: Fixture,
606    /// The minimum block+sky light guaranteed on reachable walkable cells
607    /// (1..=14, default 7). Range-checked (`DW0196`).
608    #[serde(default = "default_min_light")]
609    pub min_light: u8,
610}
611
612/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
613fn default_min_light() -> u8 {
614    7
615}
616
617/// A per-area **darkness mitigation** declaration (DSL v0.6).
618///
619/// The first-class answer to "this area is meant to be dark, and the players are
620/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
621/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
622/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
623/// gap between the check and the feature.
624///
625/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
626/// for `night vision`: that accepted a renamed water bottle, so the gate passed
627/// while nothing in the world granted night vision (owner, island QA).
628#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
629#[serde(rename_all = "kebab-case")]
630pub enum AreaMitigation {
631    /// Every player inside the area's placed bounds is kept under
632    /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
633    NightVision,
634}
635
636/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
637///
638/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
639/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
640/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
641/// full jigsaw layout semantics are spec-0002's.
642#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
643#[serde(deny_unknown_fields)]
644pub struct Area {
645    /// Unique area id.
646    pub id: AreaId,
647    /// Player-facing area name.
648    pub name: String,
649    /// The single prefab bound to this area (mutually exclusive with
650    /// `prefab_pool`).
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub prefab: Option<PrefabId>,
653    /// The jigsaw prefab pool bound to this area (mutually exclusive with
654    /// `prefab`); requires `pieces`.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub prefab_pool: Option<PoolId>,
657    /// Jigsaw piece-count bounds (only with `prefab_pool`).
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub pieces: Option<Pieces>,
660    /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
661    /// present, the compiler's relight pass guarantees `min_light` on every
662    /// reachable walkable cell of this area by placing the declared fixture, or
663    /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
664    /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub lighting: Option<AreaLighting>,
667    /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
668    /// the compiler emit a clocked `effect give` over this area's placed bounds and
669    /// is the (only) declaration that satisfies `DW0210` without `lighting`.
670    /// Independent of `lighting`: an area may declare both (fixtures *and* the
671    /// effect), either, or neither.
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub mitigation: Option<AreaMitigation>,
674}
675
676/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
677#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
678#[serde(deny_unknown_fields)]
679pub struct Pieces {
680    /// Minimum number of pieces to assemble.
681    pub min: u32,
682    /// Maximum number of pieces to assemble.
683    pub max: u32,
684}
685
686// ---------------------------------------------------------------------------
687// Body traversal — the declaration every body that moves carries (DSL v0.11,
688// spec-0034)
689// ---------------------------------------------------------------------------
690
691/// How a body gets around.
692///
693/// The compiler DERIVES this from the entity id for every body (spiders climb,
694/// ghasts fly, `#minecraft:aquatic` swims, and everything else — including every
695/// id the table has never heard of — is [`Locomotion::Ground`], the checked
696/// class). [`BodyTraversal`] is the author's side of the same vocabulary: one
697/// enum, so a declaration and a derivation can never mean different things.
698///
699/// The vocabulary lives in this crate rather than in the compiler because it is
700/// now DSL surface; the compiler re-exports it and owns the derivation table
701/// (`compiler::traversal`).
702#[derive(
703    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
704)]
705#[serde(rename_all = "kebab-case")]
706pub enum Locomotion {
707    /// Walks, steps and jumps — the default and the CHECKED class.
708    Ground,
709    /// Climbs sheer vertical surfaces (vanilla's `Spider` class).
710    Climber,
711    /// Leaves the ground under its own power.
712    Flier,
713    /// A member of vanilla's `#minecraft:aquatic` tag. A ledger classification
714    /// that exempts nothing, which is why it may not be **declared**
715    /// (`DW0455`) — see [`BodyTraversal`].
716    Aquatic,
717}
718
719impl Locomotion {
720    /// The stable kebab token this class is written and reported under.
721    pub fn token(self) -> &'static str {
722        match self {
723            Locomotion::Ground => "ground",
724            Locomotion::Climber => "climber",
725            Locomotion::Flier => "flier",
726            Locomotion::Aquatic => "aquatic",
727        }
728    }
729
730    /// Every class, in ledger order — so a report can never silently drop a row
731    /// when a class is added.
732    pub const ALL: [Locomotion; 4] = [
733        Locomotion::Ground,
734        Locomotion::Climber,
735        Locomotion::Flier,
736        Locomotion::Aquatic,
737    ];
738}
739
740/// What a body can do when it moves, **declared by the author** (DSL v0.11,
741/// spec-0034).
742///
743/// Carried by every object class in the DSL that has a body and a position and
744/// is walked by a compiler-emitted route — the stage-2 [`Npc`] and the stage-5
745/// [`Actor`]. It is deliberately one shared type on both rather than a field
746/// per consumer: traversal is a property of a body that moves, not of the verb
747/// that first needed it (CLAUDE.md), and a second bespoke field would be the
748/// defect rather than the fix.
749///
750/// **A declaration is a claim the build holds you to, never an opt-out.** The
751/// compiler compares the verdicts this body earns under the declared class
752/// against the ones it earns under its species' derived class; a declaration
753/// that changes no verdict is inert and is `DW0454`. So declaring `climber` on
754/// a sheep is only accepted where that sheep's route really does go over a
755/// barrier line — the exception is authored and proven, instead of happening by
756/// accident and merely rendering.
757///
758/// **What is deliberately NOT here: `opens_gates`.** Passing a closed fence gate
759/// is a right-click, a scripted walk is a compiler-emitted `tp` polyline whose
760/// puppet performs no interaction at all, and no runtime verb changes a fence
761/// gate's block state. Declaring it would not make it true, so the error tier
762/// (`DW0452`) has no authorable exemption and a declaration can never reach it.
763#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
764#[serde(deny_unknown_fields)]
765pub struct BodyTraversal {
766    /// How this body gets around, overriding what its entity id implies.
767    pub locomotion: Locomotion,
768}
769
770/// One object class that has a **body the compiler stages**: a declared
771/// position, a declared species, and a compiler-emitted route.
772///
773/// A sum type rather than a flattened tuple, so **adding a body class is a
774/// compile error at every consumer** until each one says what it does with it.
775/// The alternative — each rule walking the classes it happens to remember — is
776/// the defect class CLAUDE.md names: a hand-rolled walk that
777/// enumerated three of five effect roots.
778///
779/// **Keyed to the object, not to the verb that first needed it.** This type was
780/// introduced for [`BodyTraversal`] and read, for a while, as "one consumer of
781/// `BodyTraversal`" — which is why its only enumeration
782/// ([`body_traversal_sites`]) was filtered to bodies declaring a traversal, and
783/// why the second property a body carries, its [`NpcSkin`], had no enumeration
784/// at all: the bake walked the stage-2 npc list by hand, and an actor's skin was
785/// emitted into the summon, never baked into the pack, and never refused.
786/// A body's properties belong to the body. [`body_sites`] is the unfiltered
787/// walk; a per-property enumeration is a *filter* over it, never a second walk.
788///
789/// Deliberately NOT a member: [`WaveMob`]. A wave mob has a body and a position,
790/// but it is driven by **native vanilla AI**, never by a compiler-emitted route,
791/// so the compiler makes no claim about the moves it makes and a locomotion
792/// declaration on it could change no verdict. It declares no `skin` either — the
793/// exclusion holds for both properties, and the schema says so
794/// (`crates/dsl/tests/body_skin_sites.rs`). It becomes a member the day the lane
795/// proof reasons about how its bodies move — and it joins here, through this
796/// same type, rather than through a field of its own.
797#[derive(Clone, Copy, Debug)]
798pub enum BodyRef<'a> {
799    /// A stage-2 NPC, walked by `move-npc`.
800    Npc(&'a Npc),
801    /// A stage-5 scripted actor, walked by `move-actor`.
802    Actor(&'a Actor),
803}
804
805impl<'a> BodyRef<'a> {
806    /// The declaring stage's wire name (`npcs` / `quests`) — also the stage
807    /// whose `dsl_version` fences this body's declaration.
808    pub fn stage(self) -> &'static str {
809        match self {
810            BodyRef::Npc(_) => "npcs",
811            BodyRef::Actor(_) => "quests",
812        }
813    }
814
815    /// The body's declared id.
816    pub fn id(self) -> &'a str {
817        match self {
818            BodyRef::Npc(n) => n.id.as_str(),
819            BodyRef::Actor(a) => a.id.as_str(),
820        }
821    }
822
823    /// The entity id written on the body. **Not necessarily the body that
824    /// ships**: a `skin` re-dresses it as a `minecraft:mannequin` — see
825    /// [`Self::worn_entity`].
826    pub fn declared_entity(self) -> &'a str {
827        match self {
828            BodyRef::Npc(n) => n.base_entity.as_str(),
829            BodyRef::Actor(a) => a.entity.as_str(),
830        }
831    }
832
833    /// **The entity id the body ships as**: `minecraft:mannequin` when it
834    /// declares a `skin`, else the declared entity. The one authority for that
835    /// rule — the compiler's geometric proofs (`nav::npc_body_entity`,
836    /// `nav::actor_body_entity`) and the equipment fit rule (`DW0898`) all read
837    /// it.
838    pub fn worn_entity(self) -> &'a str {
839        match self.skin() {
840            Some(_) => "minecraft:mannequin",
841            None => self.declared_entity(),
842        }
843    }
844
845    /// **The mark this body is placed on** — the anchor and offset the engine
846    /// summons it at, for every class alike (spec-0066).
847    ///
848    /// A body's placement is a property of the body, not of the stage list that
849    /// happens to declare it: a mark is a cell, and a cell holds one body. The
850    /// rule that reads this ([`crate::compiler`]'s `DW0896`, via
851    /// [`body_sites`]) therefore quantifies over npcs and actors in one pass
852    /// rather than over `actors[]`, which is where the seven-men-one-anchor
853    /// muster came from.
854    pub fn mark(self) -> Mark {
855        let (anchor, offset) = match self {
856            BodyRef::Npc(n) => (&n.anchor, n.offset),
857            BodyRef::Actor(a) => (&a.anchor, a.offset),
858        };
859        Mark {
860            anchor: anchor.clone(),
861            offset,
862        }
863    }
864
865    /// The area whose anchor table resolves [`Self::mark`]'s anchor first, when this
866    /// class declares one.
867    ///
868    /// A stage-2 npc names its area and is resolved inside it; a stage-5 actor
869    /// names none and is resolved across every placed piece, exactly as an
870    /// `open-gate` / `move-actor` destination is. Stated here so the resolution
871    /// rule is one rule over both classes and not a per-call-site habit.
872    pub fn area(self) -> Option<&'a AreaId> {
873        match self {
874            BodyRef::Npc(n) => Some(&n.area),
875            BodyRef::Actor(_) => None,
876        }
877    }
878
879    /// Whether this body stands on its mark from **world init**, with no effect
880    /// having to fire.
881    ///
882    /// A stage-2 npc does unless it is `deferred`; a stage-5 actor never does —
883    /// a puppet exists only from the `spawn-actor` that summons it, which is why
884    /// an actor no `spawn-actor` names never exists at all (`DW0477` says so of
885    /// a billed elite).
886    pub fn at_world_init(self) -> bool {
887        match self {
888            BodyRef::Npc(n) => !n.deferred,
889            BodyRef::Actor(_) => false,
890        }
891    }
892
893    /// Whether a **player** can end this body's life.
894    ///
895    /// An npc body is emitted `Invulnerable:1b` unconditionally, so nothing a
896    /// player does removes it; an actor's puppet is `Invulnerable` unless it
897    /// declares [`Actor::vulnerable`]. A body a player can kill is one whose
898    /// lifetime the compiler cannot bound, which is the whole of what this
899    /// answers.
900    pub fn killable_by_players(self) -> bool {
901        match self {
902            BodyRef::Npc(_) => false,
903            BodyRef::Actor(a) => a.vulnerable,
904        }
905    }
906
907    /// This body's traversal declaration, if it carries one.
908    pub fn traversal(self) -> Option<&'a BodyTraversal> {
909        match self {
910            BodyRef::Npc(n) => n.traversal.as_ref(),
911            BodyRef::Actor(a) => a.traversal.as_ref(),
912        }
913    }
914
915    /// This body's skin declaration, if it carries one.
916    ///
917    /// A skinned body of **either** class ships as a `minecraft:mannequin`
918    /// whose `profile.texture` resolves to `delvewright:npc/<campaign_id>/<texture_id>`,
919    /// so either one owes the same `skins/<texture_id>.png` under the same refusal
920    /// (`DW0309`). Answering it here is what stops the bake from being a
921    /// property of one class.
922    pub fn skin(self) -> Option<&'a NpcSkin> {
923        match self {
924            BodyRef::Npc(n) => n.skin.as_ref(),
925            BodyRef::Actor(a) => a.skin.as_ref(),
926        }
927    }
928
929    /// This class's name in the JSON Schema export (`delvec schema --stage all`).
930    ///
931    /// The join between the closed Rust set and the schema, which is the only
932    /// authority on *which object classes declare what*. `body_skin_sites.rs`
933    /// compares the two.
934    pub fn class(self) -> &'static str {
935        match self {
936            BodyRef::Npc(_) => "Npc",
937            BodyRef::Actor(_) => "Actor",
938        }
939    }
940
941    /// Every body class, by schema name. The closed set, stated once.
942    pub const ALL_CLASSES: [&'static str; 2] = ["Npc", "Actor"];
943}
944
945/// A staged body declaration, with the JSON pointer at the declaration itself.
946///
947/// The pointer is at the OBJECT (`/content/npcs/3`), not at any one of its
948/// fields: a per-property site appends its own field name. A pointer built per
949/// property is how two walks of one population start disagreeing about where a
950/// thing was declared.
951#[derive(Clone, Debug)]
952pub struct BodySite<'a> {
953    /// Which object class declared it, and the object itself.
954    pub body: BodyRef<'a>,
955    /// JSON pointer at the declaration, for a diagnostic path.
956    pub path: String,
957}
958
959/// **Every** body the campaign declares, in stage order: stage-2 npcs in
960/// declaration order, then stage-5 actors in declaration order.
961///
962/// The one walk of the campaign's bodies. A rule about a property a body carries
963/// is a *filter* over this ([`body_traversal_sites`], [`body_skin_sites`]) — never
964/// a second traversal, and never a hand-written loop over one stage's list,
965/// which is exactly how an actor's skin came to be emitted but never baked.
966pub fn body_sites(c: &crate::envelope::Campaign) -> Vec<BodySite<'_>> {
967    let mut out: Vec<BodySite<'_>> = Vec::new();
968    for (i, n) in c.npcs.content.npcs.iter().enumerate() {
969        out.push(BodySite {
970            body: BodyRef::Npc(n),
971            path: format!("/content/npcs/{i}"),
972        });
973    }
974    for (i, a) in c.quests.content.actors.iter().enumerate() {
975        out.push(BodySite {
976            body: BodyRef::Actor(a),
977            path: format!("/content/actors/{i}"),
978        });
979    }
980    out
981}
982
983/// A body that carries a [`BodyTraversal`] declaration, with the JSON pointer at
984/// it.
985#[derive(Clone, Debug)]
986pub struct BodyTraversalSite<'a> {
987    /// Which object class declared it, and the object itself.
988    pub body: BodyRef<'a>,
989    /// JSON pointer at the `traversal` field, for a diagnostic path.
990    pub path: String,
991    /// The declaration.
992    pub traversal: &'a BodyTraversal,
993}
994
995/// Every body in the campaign that DECLARES a traversal, in stage order.
996///
997/// The one enumeration of the declaration's consumers, shared by the DSL's value
998/// check (`DW0455`) and by the compiler's proof (`DW0454`), so "which object
999/// classes carry this" is answered in exactly one place.
1000pub fn body_traversal_sites(c: &crate::envelope::Campaign) -> Vec<BodyTraversalSite<'_>> {
1001    body_sites(c)
1002        .into_iter()
1003        .filter_map(|s| {
1004            s.body.traversal().map(|t| BodyTraversalSite {
1005                body: s.body,
1006                path: format!("{}/traversal", s.path),
1007                traversal: t,
1008            })
1009        })
1010        .collect()
1011}
1012
1013/// A body that carries an [`NpcSkin`] declaration, with the JSON pointer at it.
1014#[derive(Clone, Debug)]
1015pub struct BodySkinSite<'a> {
1016    /// Which object class declared it, and the object itself.
1017    pub body: BodyRef<'a>,
1018    /// JSON pointer at the `skin` field, for a diagnostic path.
1019    pub path: String,
1020    /// The declaration.
1021    pub skin: &'a NpcSkin,
1022}
1023
1024/// Every body in the campaign that DECLARES a skin, in stage order.
1025///
1026/// The one enumeration of what the resource-pack bake must serve and what
1027/// `DW0309` must refuse, for every class alike — a skinned npc and a skinned
1028/// actor are the same fact about two bodies.
1029///
1030/// **Declarations, not textures.** Two bodies may name one `texture_id` (an npc
1031/// and the puppet that plays it), and the caller decides what that means; the
1032/// bake reads each file once.
1033pub fn body_skin_sites(c: &crate::envelope::Campaign) -> Vec<BodySkinSite<'_>> {
1034    body_sites(c)
1035        .into_iter()
1036        .filter_map(|s| {
1037            s.body.skin().map(|k| BodySkinSite {
1038                body: s.body,
1039                path: format!("{}/skin", s.path),
1040                skin: k,
1041            })
1042        })
1043        .collect()
1044}
1045
1046/// The **mutable mirror** of [`body_skin_sites`]: every skin declaration in the
1047/// campaign, in the identical order, exposed mutably so one pass can rewrite what
1048/// every emitter will read ([`crate::l10n::namespace_skin_textures`]).
1049///
1050/// It carries no [`BodyRef`] and no pointer, because a rewrite needs neither and a
1051/// borrow of the whole body would forbid the field it is there to change. What it
1052/// does owe is the **same population**: a body class that declares a skin and is
1053/// missing here would keep an un-namespaced texture and collide with every other
1054/// delve, silently. `body_skin_sites_mut_is_the_same_walk`
1055/// (`crates/dsl/tests/body_skin_sites.rs`) pins that over a campaign carrying one
1056/// body of every class in [`BodyRef::ALL_CLASSES`] — the closed set the schema
1057/// export is compared against in the same file, so a new body class turns that
1058/// coverage red and both walks are visited together.
1059pub fn body_skins_mut(c: &mut crate::envelope::Campaign) -> Vec<&mut NpcSkin> {
1060    let mut out: Vec<&mut NpcSkin> = Vec::new();
1061    out.extend(
1062        c.npcs
1063            .content
1064            .npcs
1065            .iter_mut()
1066            .filter_map(|n| n.skin.as_mut()),
1067    );
1068    out.extend(
1069        c.quests
1070            .content
1071            .actors
1072            .iter_mut()
1073            .filter_map(|a| a.skin.as_mut()),
1074    );
1075    out
1076}
1077
1078// ---------------------------------------------------------------------------
1079// Stage 2 — npcs
1080// ---------------------------------------------------------------------------
1081
1082/// Stage 2 payload: the campaign's NPCs (casting sheets).
1083#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1084#[serde(deny_unknown_fields)]
1085pub struct NpcsContent {
1086    /// All NPCs in the campaign.
1087    pub npcs: Vec<Npc>,
1088}
1089
1090/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
1091///
1092/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
1093/// contract the stage-6 `dialogue` tree must honor.
1094#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1095#[serde(deny_unknown_fields)]
1096pub struct Npc {
1097    /// Unique NPC id.
1098    pub id: NpcId,
1099    /// Player-facing name.
1100    pub name: String,
1101    /// NPC role.
1102    pub role: Role,
1103    /// The area this NPC stands in (stage-1 ref).
1104    pub area: AreaId,
1105    /// The prefab anchor this NPC stands on.
1106    pub anchor: AnchorId,
1107    /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
1108    /// `[0, 0, 0]`): the NPC stands at the [`Mark`] the two fields spell.
1109    #[serde(default, skip_serializing_if = "is_zero3")]
1110    pub offset: [i32; 3],
1111    /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
1112    pub base_entity: String,
1113    /// The structured persona (character contract for stage 6).
1114    pub persona: Persona,
1115    /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
1116    /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
1117    /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
1118    /// Non-skinned NPCs are byte-identical to v0.3.
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub skin: Option<NpcSkin>,
1121    /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
1122    /// world init — its body and interaction hitbox only appear when a
1123    /// [`Verb::SpawnNpc`] fires, at this same `anchor`. The dual of
1124    /// `despawn-npc`: a character with a scripted entrance must not stand at its
1125    /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
1126    /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
1127    /// init, byte-identical to pre-0.6.
1128    #[serde(default, skip_serializing_if = "is_false")]
1129    pub deferred: bool,
1130    /// What this body can do when it moves (DSL v0.11, spec-0034). Absent = the
1131    /// class the compiler derives from `base_entity` (or from `minecraft:mannequin`
1132    /// when `skin` is set — the body that actually ships). See [`BodyTraversal`]:
1133    /// the declaration must change a verdict or it is `DW0454`, and it can never
1134    /// reach the error tier.
1135    #[serde(default, skip_serializing_if = "Option::is_none")]
1136    pub traversal: Option<BodyTraversal>,
1137}
1138
1139/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG is sourced from
1140/// the campaign dir's `skins/<texture_id>.png` and ships in the per-delve resource
1141/// pack at `assets/delvewright/textures/npc/<campaign_id>/<texture_id>.png`, which
1142/// is what the mannequin's `profile.texture` resolves to. The delve's own
1143/// directory is stamped on at emission ([`crate::l10n::namespace_skin_textures`])
1144/// — a client merges every applied pack's textures into ONE space, so two delves
1145/// that both cast a `keeper` would otherwise wear each other's faces. Nothing a
1146/// creator writes or names on disk carries it.
1147#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1148#[serde(deny_unknown_fields)]
1149pub struct NpcSkin {
1150    /// Skin id: the PNG basename under `skins/`, and the last segment of the
1151    /// resource-pack texture path (a bare kebab token; validated by `DW0190`).
1152    pub texture_id: String,
1153    /// Player model. **Required** (spec-0009): an omitted model renders slim, so
1154    /// a wide skin on a slim model is distorted — the compiler always emits it.
1155    pub model: SkinModel,
1156}
1157
1158/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
1159/// Alex). Emitted verbatim into the mannequin `profile.model`.
1160#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1161#[serde(rename_all = "kebab-case")]
1162pub enum SkinModel {
1163    /// Classic 4-pixel arms (Steve).
1164    Wide,
1165    /// Slim 3-pixel arms (Alex).
1166    Slim,
1167}
1168
1169impl SkinModel {
1170    /// The vanilla `profile.model` token.
1171    pub fn token(self) -> &'static str {
1172        match self {
1173            SkinModel::Wide => "wide",
1174            SkinModel::Slim => "slim",
1175        }
1176    }
1177}
1178
1179/// A structured casting sheet. Structure lives in the
1180/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
1181/// are required; the rest are optional.
1182#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1183#[serde(deny_unknown_fields)]
1184pub struct Persona {
1185    /// One-line character archetype (required).
1186    pub archetype: String,
1187    /// How the NPC speaks — register, tics, formality (required).
1188    pub speech_style: String,
1189    /// Emotional bearing toward the player (optional).
1190    #[serde(default, skip_serializing_if = "Option::is_none")]
1191    pub demeanor: Option<String>,
1192    /// What the NPC wants (required).
1193    pub motivation: String,
1194    /// Something the NPC hides (optional).
1195    #[serde(default, skip_serializing_if = "Option::is_none")]
1196    pub secret: Option<String>,
1197    /// Backstory colour (optional).
1198    #[serde(default, skip_serializing_if = "Option::is_none")]
1199    pub backstory: Option<String>,
1200    /// Attitudes toward other same-stage NPCs (optional; refs validated).
1201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1202    pub relationships: Vec<Relationship>,
1203}
1204
1205/// One persona relationship: an attitude toward another same-stage NPC.
1206#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1207#[serde(deny_unknown_fields)]
1208pub struct Relationship {
1209    /// The other NPC (stage-2 ref, validated within stage 2).
1210    pub npc: NpcId,
1211    /// Free-text attitude toward that NPC.
1212    pub attitude: String,
1213}
1214
1215/// What a speaking part does. A schema enum offers what the engine accepts,
1216/// so there are two of them: how hard a fight is billed is [`EncounterTier`] on
1217/// the body that fights (a `waves[]` entry or a stage-5 actor), not a role here.
1218#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1219#[serde(rename_all = "kebab-case")]
1220pub enum Role {
1221    /// Gives and advances quests.
1222    QuestGiver,
1223    /// Flavor only.
1224    Flavor,
1225}
1226
1227// ---------------------------------------------------------------------------
1228// Stage 6 — dialogue
1229// ---------------------------------------------------------------------------
1230
1231/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
1232#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1233#[serde(deny_unknown_fields)]
1234pub struct DialogueContent {
1235    /// One tree per NPC (1:1 with stage 2, both directions).
1236    pub dialogues: Vec<NpcDialogue>,
1237}
1238
1239impl DialogueContent {
1240    /// The dialogue tree for an NPC id, if present.
1241    pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
1242        self.dialogues.iter().find(|t| t.npc.as_str() == npc)
1243    }
1244}
1245
1246/// One NPC's dialogue tree: a root node plus a set of nodes.
1247#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1248#[serde(deny_unknown_fields)]
1249pub struct NpcDialogue {
1250    /// The NPC this tree belongs to (stage-2 ref).
1251    pub npc: NpcId,
1252    /// The entry node id; every node must be reachable from it.
1253    pub root: DialogueId,
1254    /// The dialogue nodes.
1255    pub nodes: Vec<DialogueNode>,
1256}
1257
1258impl NpcDialogue {
1259    /// The ids of the nodes reachable from `roots` by following option `next`
1260    /// edges, ignoring every option gate.
1261    ///
1262    /// **The one authority for "what can this tree show, entered here".** A
1263    /// dialogue tree has more than one entry point — the stage-6 `root`, and
1264    /// every node a quest's `cast` ledger names as a scene — so "reachable" is
1265    /// always relative to a root SET, and each consumer supplies the set its own
1266    /// question is about. `DW0120`/`DW0123` ask about every entry point at once;
1267    /// the cast ledger's `DW0858` asks about the scenes live during one
1268    /// objective. Gates are ignored on purpose: an option's flag gate is
1269    /// `DW0191`'s question, not this one's.
1270    ///
1271    /// Root ids that name no node of this tree contribute nothing (a dangling
1272    /// scene root is `DW0464`, and a dangling stage-6 `root` is `DW0121`).
1273    pub fn reachable_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1274        let by_id: BTreeMap<&'a str, &'a DialogueNode> =
1275            self.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
1276        let mut seen: BTreeSet<&'a str> = BTreeSet::new();
1277        let mut stack: Vec<&'a str> = roots
1278            .iter()
1279            .filter_map(|r| by_id.get_key_value(*r).map(|(k, _)| *k))
1280            .collect();
1281        while let Some(cur) = stack.pop() {
1282            if !seen.insert(cur) {
1283                continue;
1284            }
1285            let Some(node) = by_id.get(cur) else { continue };
1286            for opt in &node.options {
1287                if let Some(next) = &opt.next
1288                    && let Some((k, _)) = by_id.get_key_value(next.as_str())
1289                {
1290                    stack.push(k);
1291                }
1292            }
1293        }
1294        seen
1295    }
1296
1297    /// The objective ids some option reachable from `roots` completes — what a
1298    /// player entering this tree at those scenes can actually finish.
1299    pub fn completes_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1300        let seen = self.reachable_from(roots);
1301        let mut out: BTreeSet<&'a str> = BTreeSet::new();
1302        for node in &self.nodes {
1303            if !seen.contains(node.id.as_str()) {
1304                continue;
1305            }
1306            for opt in &node.options {
1307                for eff in &opt.effects {
1308                    if let DialogueEffect::CompleteObjective { objective } = eff {
1309                        out.insert(objective.as_str());
1310                    }
1311                }
1312            }
1313        }
1314        out
1315    }
1316}
1317
1318/// One dialogue node: text plus branching options.
1319#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1320#[serde(deny_unknown_fields)]
1321pub struct DialogueNode {
1322    /// Node id (unique within this NPC's dialogue).
1323    pub id: DialogueId,
1324    /// The line the NPC speaks.
1325    pub text: String,
1326    /// Branching options; empty closes the dialog.
1327    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1328    pub options: Vec<DialogueOption>,
1329}
1330
1331/// One selectable dialogue option.
1332#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1333#[serde(deny_unknown_fields)]
1334pub struct DialogueOption {
1335    /// Button label.
1336    pub label: String,
1337    /// The full line this button is the caption of (DSL v0.8). Vanilla's dialog button codec
1338    /// (`CommonButtonData`) carries an optional `tooltip` component beside
1339    /// `label`, and the client hangs it on the button as a real hover tooltip —
1340    /// so a caption on the button and the sentence the character actually says
1341    /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
1342    /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
1343    /// scrolls. Player-visible, so it translates like the label
1344    /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
1345    #[serde(default, skip_serializing_if = "Option::is_none")]
1346    pub tooltip: Option<String>,
1347    /// Next node; omitted closes the dialog.
1348    #[serde(default, skip_serializing_if = "Option::is_none")]
1349    pub next: Option<DialogueId>,
1350    /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
1351    /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
1352    /// gated option is hidden until every referenced flag has been set by a
1353    /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
1354    /// option cannot make a critical-path node unreachable (`DW0191`).
1355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1356    pub requires_flags: Vec<FlagId>,
1357    /// Negative flag gate (DSL v0.6): the option is
1358    /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
1359    /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
1360    /// option counts as *gated* for the `DW0191` deadlock guard: it can be
1361    /// suppressed at any point, so it cannot be the only completing path.
1362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1363    pub forbids_flags: Vec<FlagId>,
1364    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
1365    /// hold for this gate to be open. The third field of the one gate, carried by
1366    /// every gate consumer — never by the verb that first wanted it. Default
1367    /// empty, so a pre-0.10 campaign is byte-identical.
1368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1369    pub requires_state: Vec<StateCompare>,
1370    /// Effects fired when this option is chosen.
1371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1372    pub effects: Vec<DialogueEffect>,
1373    /// What choosing this option does to the story (spec-0025). Required for a
1374    /// **story-weight** beat
1375    /// — an option carrying a `set-flag` effect, which is how a player's choice
1376    /// forks the world (`DW0481`). An option that only walks the tree or
1377    /// completes an objective needs none: the objective already declares one.
1378    #[serde(default, skip_serializing_if = "Option::is_none")]
1379    pub happening: Option<Happening>,
1380}
1381
1382/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
1383/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
1384/// dialogue choice, enabling flag-gated options/objectives/triggers).
1385#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1386#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
1387pub enum DialogueEffect {
1388    /// Marks a stage-5 objective complete.
1389    CompleteObjective {
1390        /// The objective to complete (resolved at the stage-5 boundary).
1391        objective: ObjectiveId,
1392    },
1393    /// Sets a campaign flag (DSL v0.4), mirroring [`Verb::SetFlag`].
1394    SetFlag {
1395        /// The flag to set.
1396        flag: FlagId,
1397    },
1398    /// Cuts the world time (DSL v0.5), mirroring [`Verb::SetTime`].
1399    SetTime {
1400        /// The time state to cut to.
1401        time: WorldTime,
1402    },
1403    /// Cuts the weather (DSL v0.5), mirroring [`Verb::SetWeather`].
1404    SetWeather {
1405        /// The weather state to cut to.
1406        weather: WorldWeather,
1407    },
1408    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
1409    /// [`Verb::SetCheckpoint`] — usable from a dialogue outcome.
1410    SetCheckpoint {
1411        /// The prefab checkpoint anchor the party respawns at.
1412        anchor: AnchorId,
1413        /// Per-player effects re-run on respawn while this checkpoint is active
1414        /// (scene reset). Empty = no hook.
1415        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1416        on_respawn: Vec<QuestEffect>,
1417    },
1418    /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
1419    /// [`Verb::SpawnNpc`] — a character who walks in mid-conversation.
1420    SpawnNpc {
1421        /// The NPC (stage-2 ref) to summon.
1422        npc: NpcId,
1423    },
1424}
1425
1426impl DialogueEffect {
1427    /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
1428    /// (`set-time`/`set-weather`, spec-0010).
1429    pub fn v05_effect(&self) -> Option<&'static str> {
1430        match self {
1431            DialogueEffect::SetTime { .. } => Some("set-time"),
1432            DialogueEffect::SetWeather { .. } => Some("set-weather"),
1433            _ => None,
1434        }
1435    }
1436
1437    /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
1438    /// (`set-checkpoint`, spec-0012; `spawn-npc`).
1439    pub fn v06_effect(&self) -> Option<&'static str> {
1440        match self {
1441            DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
1442            DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
1443            _ => None,
1444        }
1445    }
1446
1447    /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
1448    pub fn spawn_npc(&self) -> Option<&NpcId> {
1449        match self {
1450            DialogueEffect::SpawnNpc { npc } => Some(npc),
1451            _ => None,
1452        }
1453    }
1454
1455    /// **The body this dialogue effect puts into the world**, by id — the
1456    /// dialogue half of [`QuestEffect::body_entry`].
1457    ///
1458    /// A body can enter the world from a conversation as well as from a quest
1459    /// bundle, and a rule about what is standing where has to enumerate **every**
1460    /// entry point or it is a gate with a door beside it. This enum carries no
1461    /// exit at all: nothing a dialogue option does removes a body.
1462    pub fn body_entry(&self) -> Option<&str> {
1463        match self {
1464            DialogueEffect::SpawnNpc { npc } => Some(npc.as_str()),
1465            _ => None,
1466        }
1467    }
1468
1469    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
1470    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
1471        match self {
1472            DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
1473                Some((anchor, on_respawn.as_slice()))
1474            }
1475            _ => None,
1476        }
1477    }
1478
1479    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
1480    /// **effect root 5** ([`crate::effects`]). Named separately from
1481    /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
1482    /// same accessor modulo mutability, which is what lets one macro body generate
1483    /// both.
1484    pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
1485        match self {
1486            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
1487            _ => None,
1488        }
1489    }
1490
1491    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
1492    /// mutably so the localization pass can rewrite the player-visible strings
1493    /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
1494    /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
1495    /// that reaches it read-only needs a way to reach it writable too.
1496    pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
1497        match self {
1498            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
1499            _ => None,
1500        }
1501    }
1502}
1503
1504impl DialogueEffect {
1505    /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
1506    pub fn set_flag(&self) -> Option<&FlagId> {
1507        match self {
1508            DialogueEffect::SetFlag { flag } => Some(flag),
1509            _ => None,
1510        }
1511    }
1512
1513    /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
1514    /// (`set-flag`).
1515    pub fn v04_effect(&self) -> Option<&'static str> {
1516        match self {
1517            DialogueEffect::SetFlag { .. } => Some("set-flag"),
1518            _ => None,
1519        }
1520    }
1521
1522    /// The target time if this is a v0.5 `set-time` dialogue effect.
1523    pub fn set_time(&self) -> Option<WorldTime> {
1524        match self {
1525            DialogueEffect::SetTime { time } => Some(*time),
1526            _ => None,
1527        }
1528    }
1529
1530    /// The target weather if this is a v0.5 `set-weather` dialogue effect.
1531    pub fn set_weather(&self) -> Option<WorldWeather> {
1532        match self {
1533            DialogueEffect::SetWeather { weather } => Some(*weather),
1534            _ => None,
1535        }
1536    }
1537}
1538
1539// ---------------------------------------------------------------------------
1540// Stage 3 — classes
1541// ---------------------------------------------------------------------------
1542
1543/// Stage 3 payload: 1..4 selectable classes.
1544#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1545#[serde(deny_unknown_fields)]
1546pub struct ClassesContent {
1547    /// The selectable classes.
1548    pub classes: Vec<Class>,
1549}
1550
1551/// A player class with a starting kit.
1552#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1553#[serde(deny_unknown_fields)]
1554pub struct Class {
1555    /// Unique class id.
1556    pub id: ClassId,
1557    /// Player-facing name.
1558    pub name: String,
1559    /// Selection-screen blurb.
1560    pub blurb: String,
1561    /// Granted items.
1562    pub kit: Vec<KitItem>,
1563}
1564
1565/// One item in a class kit.
1566///
1567/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
1568/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
1569/// document using them is rejected as an unknown field (`DW0100`).
1570#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1571#[serde(deny_unknown_fields)]
1572pub struct KitItem {
1573    /// Vanilla item id, validated against the pinned 1.21.11 registry.
1574    pub item: String,
1575    /// Stack count.
1576    pub count: u32,
1577    /// Optional display name.
1578    #[serde(default, skip_serializing_if = "Option::is_none")]
1579    pub name: Option<String>,
1580    /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
1581    /// per-player gear by construction — every player who picks the class gets the
1582    /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
1583    /// enters the party, given to the first player to pick this class.
1584    #[serde(default, skip_serializing_if = "Option::is_none")]
1585    pub carrier: Option<Carrier>,
1586    /// **The flask** (DSL v0.8, spec-0016 §1): this kit
1587    /// entry is the class's recovery item, and resting at a bonfire replenishes
1588    /// it to exactly `count`. A campaign that places a `bonfire` and declares no
1589    /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
1590    /// dying an investment, so a souls campaign without one is a build error, not
1591    /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
1592    #[serde(default, skip_serializing_if = "is_false")]
1593    pub flask: bool,
1594    /// **What is in the bottle** (DSL v0.8, spec-0016 §1): the vanilla
1595    /// `minecraft:potion_contents` component of a
1596    /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
1597    /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
1598    /// nothing — which is exactly the placeholder flask this field exists to
1599    /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
1600    /// no `contents` is `DW0487`.
1601    #[serde(default, skip_serializing_if = "Option::is_none")]
1602    pub contents: Option<PotionContents>,
1603}
1604
1605/// The items whose vanilla item definition carries a `minecraft:potion_contents`
1606/// component — the only items a kit `contents` may be declared on (`DW0486`).
1607///
1608/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
1609/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
1610/// misode/mcmeta ref `crates/delvec/data/PROVENANCE.md` pins): exactly these
1611/// four items declare the component, and on any other item the game drops the
1612/// data on the floor.
1613pub const POTION_BEARING_ITEMS: &[&str] = &[
1614    "minecraft:lingering_potion",
1615    "minecraft:potion",
1616    "minecraft:splash_potion",
1617    "minecraft:tipped_arrow",
1618];
1619
1620/// True if `item_id` (optionally un-namespaced) is one of the four
1621/// [`POTION_BEARING_ITEMS`].
1622pub fn is_potion_bearing_item(item_id: &str) -> bool {
1623    let norm = if item_id.contains(':') {
1624        item_id.to_string()
1625    } else {
1626        format!("minecraft:{item_id}")
1627    };
1628    POTION_BEARING_ITEMS.contains(&norm.as_str())
1629}
1630
1631/// The two **instantaneous** status effects: they are applied once, on the tick
1632/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
1633/// `isInstantenous` before any effect instance is ever added), so a `duration` on
1634/// one is a statement the game never reads — `DW0486` says so rather than letting
1635/// an author believe they wrote a thirty-second heal.
1636pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
1637
1638/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
1639/// modelled field for field on vanilla rather than invented: a **named** potion,
1640/// a list of **custom effects**, or both, plus the bottle-colour override.
1641///
1642/// Vanilla resolves a drink as the named potion's effects followed by the custom
1643/// ones, and derives the bottle colour from those effects unless `color`
1644/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
1645/// Potion of Healing II a player would brew, not an approximation of one.
1646#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1647#[serde(deny_unknown_fields)]
1648pub struct PotionContents {
1649    /// A named vanilla potion id (`minecraft:strong_healing`,
1650    /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
1651    /// `potion` registry (`DW0486`).
1652    #[serde(default, skip_serializing_if = "Option::is_none")]
1653    pub potion: Option<String>,
1654    /// Custom effects applied on top of (or instead of) the named potion — the
1655    /// escape hatch for a recovery item vanilla has no brew for.
1656    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1657    pub effects: Vec<PotionEffect>,
1658    /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
1659    /// colour vanilla derives from the effects themselves.
1660    #[serde(default, skip_serializing_if = "Option::is_none")]
1661    pub color: Option<String>,
1662}
1663
1664/// One entry of a potion's `custom_effects` list.
1665#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1666#[serde(deny_unknown_fields)]
1667pub struct PotionEffect {
1668    /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
1669    /// against the pinned registry (`DW0486`).
1670    pub effect: String,
1671    /// How long it lasts, in **ticks** (20 = one second). Required for every
1672    /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
1673    #[serde(default, skip_serializing_if = "Option::is_none")]
1674    pub duration: Option<u32>,
1675    /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
1676    #[serde(default, skip_serializing_if = "Option::is_none")]
1677    pub amplifier: Option<u32>,
1678}
1679
1680impl PotionEffect {
1681    /// True if this effect is applied once on drinking rather than over time.
1682    pub fn is_instant(&self) -> bool {
1683        let norm = crate::registry::namespaced_effect_id(&self.effect);
1684        INSTANT_EFFECTS.contains(&norm.as_str())
1685    }
1686}
1687
1688/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
1689/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
1690/// need is refused — while a duration typed in *milliseconds*, or one that would
1691/// overflow vanilla's int, is caught (`DW0486`).
1692pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
1693
1694/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
1695/// unsigned byte, so 255 is not a policy but the end of the field.
1696pub const MAX_POTION_AMPLIFIER: u32 = 255;
1697
1698/// The largest `seconds` a [`Verb::GiveEffect`] may declare, derived from
1699/// [`MAX_POTION_DURATION_TICKS`] rather than chosen again: the two are the same
1700/// quantity in different units, and a second independently-picked ceiling is how
1701/// two limits for one fact drift apart. ≈13.9 hours, past the 10-hour delve
1702/// ceiling, so nothing a delve can legally need is refused — while a duration
1703/// typed in *ticks* or in milliseconds is caught (`DW0541`).
1704pub const MAX_EFFECT_SECONDS: u32 = MAX_POTION_DURATION_TICKS / 20;
1705
1706/// The canonical English title of the bonfire rest dialog. Baked at emit time
1707/// when the campaign authors no `prompt`, in the
1708/// `world.boundary.message` tradition: a compiler default is not inventoried, an
1709/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
1710pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
1711/// The canonical English label of the **rest and save** option.
1712pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
1713/// The canonical English label of the **save only** option.
1714pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
1715
1716/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
1717/// leaves the compiler's canonical English in place
1718/// ([`QuestEffect::bonfire_labels`]).
1719#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1720pub struct BonfireLabels<'a> {
1721    /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
1722    pub prompt: Option<&'a str>,
1723    /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
1724    pub rest_label: Option<&'a str>,
1725    /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
1726    pub save_label: Option<&'a str>,
1727}
1728
1729impl BonfireLabels<'_> {
1730    /// The dialog title actually emitted.
1731    pub fn prompt_or_default(&self) -> &str {
1732        self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
1733    }
1734    /// The **rest and save** label actually emitted.
1735    pub fn rest_or_default(&self) -> &str {
1736        self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
1737    }
1738    /// The **save only** label actually emitted.
1739    pub fn save_or_default(&self) -> &str {
1740        self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
1741    }
1742}
1743
1744// ---------------------------------------------------------------------------
1745// Stage 4 — quest-plan
1746// ---------------------------------------------------------------------------
1747
1748/// Stage 4 payload: the quest dependency plan.
1749#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1750#[serde(deny_unknown_fields)]
1751pub struct QuestPlanContent {
1752    /// Planned quests (expanded in stage 5).
1753    pub quests: Vec<PlannedQuest>,
1754    /// The quest whose completion ends the campaign.
1755    pub finale: QuestId,
1756    /// The campaign's declared **story forks** (DSL v0.8, spec-0025). Empty/absent = a campaign that claims to have no
1757    /// branch — which the compiler then *verifies* rather than assumes: any flag
1758    /// that gates casts, staging or structure and is set on some playthroughs and
1759    /// not others belongs to no declared point and is `DW0480`.
1760    ///
1761    /// Enumerated branches are the **product of the declared points**, so the
1762    /// branch set is authored and small — never a combinatorial sweep of every
1763    /// flag in the campaign.
1764    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1765    pub branch_points: Vec<BranchPoint>,
1766}
1767
1768impl QuestPlanContent {
1769    /// **The ONE authority on which quests are the spine**: the finale and every
1770    /// quest its `depends_on` chain transitively demands — the quests a body
1771    /// cannot reach the finale without.
1772    ///
1773    /// The capability belongs here, on the stage-4 document, because the spine is
1774    /// a fact about the quest plan and about nothing else. It had grown two
1775    /// derivations of the same closure in two files — one inline in
1776    /// [`crate::validate`]'s `DW0132` convergence check, one a private
1777    /// `mandatory_quests` in [`crate::layout`] read by the layout binding and by
1778    /// the critical-path spine obligation. Both were correct and neither said it
1779    /// was the authority, which is exactly the shape a later clean merge turns
1780    /// into two rules that disagree.
1781    ///
1782    /// **Why the closure is taken over the raw `depends_on` edges, unfiltered.**
1783    /// The `validate` copy first dropped every dep naming a quest the plan does
1784    /// not declare. That filtering is not this function's question: a dangling
1785    /// `depends_on` is `DW0112`'s finding, and silently pruning it here would
1786    /// make the set disagree with the document it is derived from. So an id the
1787    /// plan does not declare is reported in the spine and expands no further —
1788    /// and the one reader that could care, `DW0132`, only ever asks whether a
1789    /// **declared** quest is a member, so an undeclared member cannot change its
1790    /// verdict.
1791    ///
1792    /// Cycle-safe by construction (a quest already in the set is not expanded
1793    /// again), so a plan `DW0130` will refuse still yields a set rather than
1794    /// hanging.
1795    ///
1796    /// **Not the same question as the `mandatory` field**, and the name says so
1797    /// deliberately. Today the two sets always coincide, because `DW0132` demands
1798    /// every declared quest be a transitive dependency of the finale and `DW0866`
1799    /// demands every quest set `mandatory: true`. If `mandatory: false` ever
1800    /// becomes legal those coincide no longer, and this function keeps answering
1801    /// the graph question it has always answered.
1802    #[must_use]
1803    pub fn spine(&self) -> BTreeSet<&str> {
1804        let deps: BTreeMap<&str, &[QuestId]> = self
1805            .quests
1806            .iter()
1807            .map(|q| (q.id.as_str(), q.depends_on.as_slice()))
1808            .collect();
1809        let mut spine: BTreeSet<&str> = BTreeSet::new();
1810        let mut stack = vec![self.finale.as_str()];
1811        while let Some(q) = stack.pop() {
1812            if !spine.insert(q) {
1813                continue;
1814            }
1815            for dep in deps.get(q).copied().unwrap_or(&[]) {
1816                stack.push(dep.as_str());
1817            }
1818        }
1819        spine
1820    }
1821
1822    /// **The ONE authority on which quests are elective** (spec-0051): the
1823    /// quests declaring `mandatory: false`.
1824    ///
1825    /// The counterpart to [`Self::spine`], and deliberately a *different*
1826    /// question. `spine` asks what the graph demands; this asks what the author
1827    /// claims. `DW0866`/`DW0867` are exactly the rules that keep the two
1828    /// answers honest about each other, and they can only do that while each
1829    /// side has one derivation — which is the defect the spine function was
1830    /// created to end, and which a second private `!q.mandatory` filter in the
1831    /// compiler would re-introduce on the other half.
1832    #[must_use]
1833    pub fn optional(&self) -> BTreeSet<&str> {
1834        self.quests
1835            .iter()
1836            .filter(|q| !q.mandatory)
1837            .map(|q| q.id.as_str())
1838            .collect()
1839    }
1840}
1841
1842/// One declared story fork (DSL v0.8, spec-0025).
1843///
1844/// A branch point names the flag set the story forks on, the quest at which the
1845/// fork opens, and every branch it offers. Each branch pins the point's whole
1846/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
1847/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
1848/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
1849#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1850#[serde(deny_unknown_fields)]
1851pub struct BranchPoint {
1852    /// Unique branch-point id.
1853    pub id: BranchPointId,
1854    /// The quest at which the fork opens — every branch's divergent content is at
1855    /// or after it in the stage-4 DAG.
1856    pub opens_at: QuestId,
1857    /// The flag set the story forks on. Every branch's `flags` is a subset of
1858    /// this, and the flags it does not list are pinned **unset** on that branch.
1859    pub forks_on: Vec<FlagId>,
1860    /// The alternatives (≥ 2).
1861    pub branches: Vec<BranchDecl>,
1862}
1863
1864/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
1865#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1866#[serde(deny_unknown_fields)]
1867pub struct BranchDecl {
1868    /// Unique branch id (campaign-wide — it names the emitted chronicle file).
1869    pub id: BranchId,
1870    /// The subset of the point's `forks_on` that is SET on this branch. The rest
1871    /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
1872    /// option" branch — as long as it is genuinely reachable.
1873    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1874    pub flags: Vec<FlagId>,
1875    /// Where this branch goes: either the `quest/<kebab>` the branches converge
1876    /// at, or the `ending/<kebab>` this branch runs to.
1877    ///
1878    /// One field, not two mutually exclusive ones, because the **id prefix
1879    /// already says which it is** — the same convention every cross-stage
1880    /// reference in the DSL uses. That is what makes "exactly one of them" an
1881    /// unrepresentable state rather than a rule some diagnostic has to police: a
1882    /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
1883    /// the ordinary malformed-id `DW0110`, and one that names nothing is the
1884    /// ordinary dangling-reference `DW0112`.
1885    pub leads_to: String,
1886}
1887
1888impl BranchDecl {
1889    /// The convergence quest, if [`Self::leads_to`] names one.
1890    pub fn converges_at(&self) -> Option<QuestId> {
1891        let q = QuestId(self.leads_to.clone());
1892        q.is_valid_syntax().then_some(q)
1893    }
1894
1895    /// The ending, if [`Self::leads_to`] names one.
1896    pub fn ending(&self) -> Option<EndingId> {
1897        let e = EndingId(self.leads_to.clone());
1898        e.is_valid_syntax().then_some(e)
1899    }
1900}
1901
1902/// What a story node does to the story (DSL v0.8, spec-0025).
1903///
1904/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
1905/// design that never got written down node by node cannot compile. It is
1906/// **node-local on purpose** — there is no parallel per-branch script document
1907/// that could itself drift from the graph.
1908///
1909/// `text` is authoring/validation metadata, never shown to a player, so it is
1910/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
1911/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
1912/// chronicle is assembled from.
1913#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1914#[serde(deny_unknown_fields)]
1915pub struct Happening {
1916    /// The structured event verb — the machine-decidable part.
1917    pub verb: HappeningVerb,
1918    /// One line of prose stating what this node does to the story.
1919    pub text: String,
1920    /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
1921    /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
1922    /// story token the campaign tracks by hand. Optional, because not every beat
1923    /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
1924    /// over the beats that name one.
1925    #[serde(default, skip_serializing_if = "Option::is_none")]
1926    pub subject: Option<String>,
1927}
1928
1929/// The structured event vocabulary (DSL v0.8, spec-0025).
1930///
1931/// Deliberately small and closed. These ten verbs are what make a subset of
1932/// narrative errors machine-decidable per branch (`DW0485`); everything else a
1933/// beat means lives in [`Happening::text`], which the compiler never interprets.
1934/// Extend only when a real campaign cannot state its beat with what is here.
1935#[derive(
1936    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1937)]
1938#[serde(rename_all = "kebab-case")]
1939pub enum HappeningVerb {
1940    /// The subject is killed / destroyed. Terminal: nothing the subject does may
1941    /// follow it on the same branch.
1942    Dies,
1943    /// The subject comes through alive — the explicit counterpart of `dies`,
1944    /// which is what lets a branch state that somebody *did not* die.
1945    Survives,
1946    /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
1947    Departs,
1948    /// The subject enters the stage.
1949    Arrives,
1950    /// Somebody learns a fact — the true-information beat.
1951    Learns,
1952    /// Somebody comes to believe something (whether or not it is true) — the beat
1953    /// that carries a wrong belief forward, which is where branch drift shows.
1954    Believes,
1955    /// The party (or the subject) gains a thing.
1956    Gains,
1957    /// The party (or the subject) loses a thing. A second `loses` with no
1958    /// intervening `gains` is spending what is already spent (`DW0485`).
1959    Loses,
1960    /// A way is opened.
1961    Opens,
1962    /// A way is sealed. Cleared by `opens`.
1963    Seals,
1964}
1965
1966/// One planned quest (dependency-graph node).
1967#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1968#[serde(deny_unknown_fields)]
1969pub struct PlannedQuest {
1970    /// Unique quest id.
1971    pub id: QuestId,
1972    /// Human-readable goal.
1973    pub goal: String,
1974    /// Area this quest takes place in (stage-1 ref).
1975    pub area: AreaId,
1976    /// NPCs involved (stage-2 refs).
1977    pub npcs: Vec<NpcId>,
1978    /// Prerequisite quests; edges must form a DAG.
1979    pub depends_on: Vec<QuestId>,
1980    /// `false` declares an optional quest (spec-0051).
1981    pub mandatory: bool,
1982    /// Act number (informational).
1983    pub act: u32,
1984}
1985
1986// ---------------------------------------------------------------------------
1987// Stage 5 — quests
1988// ---------------------------------------------------------------------------
1989
1990/// Stage 5 payload: quest expansions.
1991#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1992#[serde(deny_unknown_fields)]
1993pub struct QuestsContent {
1994    /// The expanded quests (1:1 with stage 4).
1995    pub quests: Vec<Quest>,
1996    /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
1997    /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
1998    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1999    pub waves: Vec<Wave>,
2000    /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
2001    /// watches an anchor for a strike / use / approach event and fires a bundle
2002    /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
2003    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2004    pub triggers: Vec<EnvTrigger>,
2005    /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
2006    /// compiler-emitted per-tick teleport. Distinct from stage-2 NPCs
2007    /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
2008    /// staging effects. Empty/absent before v0.6.
2009    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2010    pub actors: Vec<Actor>,
2011    /// Traps (DSL v0.6, spec-0011; command payloads spec-0022): environmental
2012    /// hazards, each at one point anchor an area's prefab provides. Each says
2013    /// what springs the trap, what it then does (a command `payload`, a legacy
2014    /// dispenser `effect`, or both), how dangerous it is, how it is disarmed and
2015    /// whether it re-arms. Empty/absent in pre-0.6 campaigns, so a v0.5-or-earlier campaign that declares none stays
2016    /// byte-identical.
2017    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2018    pub traps: Vec<Trap>,
2019    /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
2020    /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
2021    /// campaigns, so a campaign that declares none stays
2022    /// byte-identical.
2023    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2024    pub shortcuts: Vec<Shortcut>,
2025    /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
2026    /// springs them". Empty/absent in pre-0.6 campaigns.
2027    ///
2028    /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
2029    /// each ambush into `triggers`, so the canonical form of a campaign is its
2030    /// **desugared** form. That is what keeps the canonical round-trip idempotent
2031    /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
2032    /// second time and duplicate the trigger ids), and it means the sugar exists
2033    /// at exactly one layer boundary — the authored `.json` — with nothing
2034    /// downstream needing to know it was ever there. The list itself is kept in
2035    /// memory so diagnostics can name the ambush the author wrote.
2036    /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
2037    /// Empty/absent in pre-0.6 campaigns.
2038    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2039    pub timed_gates: Vec<TimedGate>,
2040    /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
2041    /// given contents at world init. Empty/absent in pre-0.6 campaigns
2042    ///.
2043    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2044    pub loot: Vec<Loot>,
2045    /// Runtime state data (DSL v0.10, spec-0031): named, scoped, integer-valued
2046    /// counters the campaign sets, adds to and clears at runtime, and compares
2047    /// against in any gate. A campaign that declares none emits none of it.
2048    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2049    pub state: Vec<StateDecl>,
2050    /// **The campaign's death beat** (DSL v0.10, spec-0031): effects run at the
2051    /// moment a player dies, for that player. Effect root **R7**
2052    /// ([`crate::EffectRootKind::OnDeath`]); a campaign that declares none emits
2053    /// none of it.
2054    ///
2055    /// **Why this is campaign-wide and not a field on a checkpoint.** The engine
2056    /// already has `on_respawn`, and it hangs off a `set-checkpoint` because
2057    /// *where you come back* is a property of the checkpoint. *That you died* is
2058    /// not: it is true at every point of the delve, under every checkpoint, and a
2059    /// bundle repeated on each checkpoint would be the same content written N
2060    /// times with N chances to forget one. So death is a moment in the campaign,
2061    /// and this is the one place it is named. Anything that should only happen in
2062    /// some phase of the delve is expressed by the ordinary per-effect
2063    /// `requires_flags` / `forbids_flags` gate every other root already carries —
2064    /// no second gating surface.
2065    ///
2066    /// **Audience is the dying player** (`Audience::Solo`, the audience
2067    /// `on_respawn` and `on_caught` already use): a death is one player's, and
2068    /// re-broadcasting it to the party would duplicate their narration and their
2069    /// kit. A beat the whole party should see is a `narrate` addressed by the
2070    /// author to the party through the effect's own vocabulary, not a different
2071    /// default here.
2072    ///
2073    /// **Timing.** It fires on the death edge while the player is still a corpse
2074    /// (`Health: 0.0f`, on the death screen) — see
2075    /// `emit::emit_checkpoint_functions`. That is the difference between this and
2076    /// `on_respawn`, which deliberately waits for the player to come back.
2077    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2078    pub on_death: Vec<QuestEffect>,
2079    /// Lethal volumes (DSL v0.10, spec-0031): declared boxes that kill whatever
2080    /// enters them. Empty/absent in pre-0.10 campaigns, so a
2081    /// campaign that declares none stays byte-identical.
2082    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2083    pub lethal_volumes: Vec<LethalVolume>,
2084    /// Shops (DSL v0.10, spec-0032): interaction points that open a list of
2085    /// gated offers. Empty/absent in pre-0.10 campaigns, so a
2086    /// campaign that declares none stays byte-identical.
2087    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2088    pub shops: Vec<Shop>,
2089    /// Recovery stakes (DSL v0.10, spec-0032): what a death forfeits, where the
2090    /// marker lands, and how it comes back. Empty/absent in pre-0.10 campaigns
2091    ///, so a campaign that declares none stays byte-identical.
2092    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2093    pub stakes: Vec<Stake>,
2094    #[serde(default, skip_serializing)]
2095    pub ambushes: Vec<Ambush>,
2096    /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
2097    /// authored `ambushes` are deliberately KEPT after expansion so validation
2098    /// and the counterplay proof can attribute diagnostics to the ambush the
2099    /// author actually wrote; this flag is what makes a second expansion a no-op.
2100    #[serde(skip)]
2101    pub ambushes_expanded: bool,
2102}
2103
2104impl QuestsContent {
2105    /// Every environment trigger this stage produces: the authored `triggers`
2106    /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
2107    /// declared order.
2108    ///
2109    /// **This is the single trigger authority.** Validation, the l10n
2110    /// inventory, the flag/wave producer scans, the nav proofs and emission all
2111    /// read triggers through it, so an ambush behaves exactly like the trigger
2112    /// an author would otherwise hand-write — there is no second code path for
2113    /// the sugar to drift down. Deterministic (declaration order, no hashing).
2114    pub fn all_triggers(&self) -> Vec<EnvTrigger> {
2115        let mut out = self.triggers.clone();
2116        out.extend(self.ambushes.iter().map(Ambush::to_trigger));
2117        out
2118    }
2119
2120    /// **Does the campaign itself answer a right-click at `anchor`?**
2121    ///
2122    /// One predicate, read by both consumers of the press-answer rule, so they can
2123    /// never disagree about what "the campaign answered it" means: the compiler's
2124    /// synthesis (`plan::collect_press_answers`, which stands down where this is
2125    /// true) and the obligation on a shortcut door (`DW0429`, which fires where it
2126    /// is false). Split across the two crates they would drift, and the drift
2127    /// would read as "the compiler refused a door I answered".
2128    ///
2129    /// Deliberately the widest reading — *any* `use` trigger anchored there.
2130    /// Pressing it already does something the author chose, and the engine does
2131    /// not adjudicate whether what they chose counts as an answer.
2132    pub fn answers_press_at(&self, anchor: &str) -> bool {
2133        self.all_triggers()
2134            .iter()
2135            .any(|t| matches!(t.on, TriggerOn::Use) && t.at_anchor() == Some(anchor))
2136    }
2137
2138    /// Desugar every `ambush` into a real environment trigger and clear the
2139    /// ambush list (spec-0016 §3). Called once, by
2140    /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
2141    /// (a second call sees no ambushes left to expand).
2142    pub fn expand_ambushes(&mut self) {
2143        if self.ambushes_expanded || self.ambushes.is_empty() {
2144            return;
2145        }
2146        self.triggers = self.all_triggers();
2147        self.ambushes_expanded = true;
2148    }
2149
2150    /// The declared datum with this id, if any (DSL v0.10).
2151    pub fn state_decl(&self, id: &str) -> Option<&StateDecl> {
2152        self.state.iter().find(|s| s.id.as_str() == id)
2153    }
2154
2155    /// The declared stake with this id, if any (DSL v0.10, spec-0032).
2156    pub fn stake_decl(&self, id: &str) -> Option<&Stake> {
2157        self.stakes.iter().find(|s| s.id.as_str() == id)
2158    }
2159}
2160
2161// ---------------------------------------------------------------------------
2162// Stage 5 — runtime state (DSL v0.10, spec-0031)
2163// ---------------------------------------------------------------------------
2164
2165/// Who holds a datum's value (DSL v0.10, spec-0031).
2166///
2167/// **Declared, never inferred.** A datum's multiplayer semantics is the one
2168/// thing about it that cannot be recovered from its uses: a purse read on a
2169/// `talk-to` looks identical whether every player has their own or the party
2170/// shares one, and the difference decides the whole design. spec-0031 states it
2171/// as a rule, and the type makes it un-omittable — there is no default.
2172#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2173#[serde(rename_all = "kebab-case")]
2174pub enum StateScope {
2175    /// Each player holds their own value. Read and written against the acting
2176    /// player, so a bundle with no acting player (the scheduler) cannot touch one
2177    /// (`DW0503`).
2178    Player,
2179    /// One value the whole party shares — the same holder story flags use
2180    /// (spec-0018). Readable and writable from every audience, including the
2181    /// scheduler.
2182    Party,
2183}
2184
2185impl StateScope {
2186    /// The wire token (`player` / `party`).
2187    pub fn token(self) -> &'static str {
2188        match self {
2189            StateScope::Player => "player",
2190            StateScope::Party => "party",
2191        }
2192    }
2193}
2194
2195/// One declared runtime datum (DSL v0.10, spec-0031): a named, scoped,
2196/// integer-valued counter.
2197///
2198/// This is what [`FlagId`] is not. A flag is boolean, party-wide and
2199/// **monotonic** — no verb clears one — which is exactly right for "this has
2200/// happened" and useless for a balance, a floor number, or "a ride is in
2201/// progress". A datum clears, counts down as well as up, and states its scope.
2202#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2203#[serde(deny_unknown_fields)]
2204pub struct StateDecl {
2205    /// Unique datum id (`state/<kebab>`).
2206    pub id: StateId,
2207    /// Who holds the value. Required — see [`StateScope`].
2208    pub scope: StateScope,
2209    /// The value the datum starts at, and the value `clear-state` returns it to.
2210    /// Defaults to `0`.
2211    ///
2212    /// One field rather than a separate `initial` and `cleared`: "the value this
2213    /// datum has when nothing has happened to it yet" is one fact, and two fields
2214    /// would let a campaign declare a datum that can never be returned to its own
2215    /// starting state.
2216    #[serde(default, skip_serializing_if = "is_zero_i32")]
2217    pub initial: i32,
2218    /// Free prose: what this datum means. Never machine-checked, never shown to a
2219    /// player — the forcing function that makes an author say what the number is,
2220    /// the same role `cast[].doing` plays.
2221    #[serde(default, skip_serializing_if = "Option::is_none")]
2222    pub note: Option<String>,
2223    /// The player-visible name of this datum (DSL v0.10, spec-0032).
2224    ///
2225    /// **A named datum is a currency.** There is no separate `currencies` section
2226    /// and there deliberately is not: a purse is a runtime datum that the player
2227    /// can see, and "the player can see it" is a property of the datum, not a
2228    /// different object class. A second struct carrying `id` + `scope` +
2229    /// `initial` + `name` would be a private copy of this one, which is the defect
2230    /// CLAUDE.md names second.
2231    ///
2232    /// Present ⇒ every `set-state` / `add-state` / `clear-state` on this datum
2233    /// also states the new balance to whoever holds it, on the action bar, as
2234    /// `<name>: <value>` with the value carried by vanilla's own `score`
2235    /// component. Absent ⇒ the datum is silent bookkeeping and emission is exactly
2236    /// what it was.
2237    ///
2238    /// Player-visible, so it is inventoried under `state.<id>.name` and
2239    /// translated like any other authored line.
2240    #[serde(default, skip_serializing_if = "Option::is_none")]
2241    pub name: Option<String>,
2242}
2243
2244/// serde `skip_serializing_if` helper: skip a zero `i32` (`StateDecl.initial`).
2245fn is_zero_i32(v: &i32) -> bool {
2246    *v == 0
2247}
2248
2249/// How a [`StateCompare`] relates a datum to its operand (DSL v0.10).
2250///
2251/// Four operators, not six: over integers `less-than n` is `at-most n-1` and
2252/// `greater-than n` is `at-least n+1`, so the extra spellings would add a second
2253/// way to say one thing and a second emission path to keep honest.
2254#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2255#[serde(rename_all = "kebab-case")]
2256pub enum CompareOp {
2257    /// The datum is exactly `value`.
2258    Equals,
2259    /// The datum is anything but `value`.
2260    NotEquals,
2261    /// The datum is `value` or more.
2262    AtLeast,
2263    /// The datum is `value` or less.
2264    AtMost,
2265}
2266
2267impl CompareOp {
2268    /// The wire token (`equals` / `not-equals` / `at-least` / `at-most`).
2269    pub fn token(self) -> &'static str {
2270        match self {
2271            CompareOp::Equals => "equals",
2272            CompareOp::NotEquals => "not-equals",
2273            CompareOp::AtLeast => "at-least",
2274            CompareOp::AtMost => "at-most",
2275        }
2276    }
2277}
2278
2279/// What one of the DSL v0.10 state verbs does to a datum — the value half of
2280/// [`QuestEffect::writes_state`].
2281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2282pub enum StateWrite {
2283    /// `set-state`: the datum becomes this value.
2284    Set(i32),
2285    /// `add-state`: the datum moves by this signed amount.
2286    Add(i32),
2287    /// `clear-state`: the datum returns to its declared `initial`.
2288    Clear,
2289}
2290
2291/// One numeric term of a gate (DSL v0.10, spec-0031): *this datum compares thus
2292/// to this value*.
2293///
2294/// It rides [`Gate`](crate::gate::Gate) — the shared gate, carried by every
2295/// consumer of `requires_flags`/`forbids_flags` — and not any one verb. The
2296/// comparison's consumers are exactly the gate's consumers ("this door opens at
2297/// 500", "this line is withheld below 200", "this lever does nothing while the
2298/// car is moving"), so hanging it off the first verb that asked would leave the
2299/// second with no surface and make a second bespoke field look like the fix.
2300#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2301#[serde(deny_unknown_fields)]
2302pub struct StateCompare {
2303    /// The datum to read. Must be declared in the stage-5 `state` list
2304    /// (`DW0502`).
2305    pub state: StateId,
2306    /// How to compare it.
2307    pub op: CompareOp,
2308    /// What to compare it against.
2309    pub value: i32,
2310}
2311
2312/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
2313/// barrel the prefab already placed.
2314///
2315/// The container is **hardware the prefab authored**, exactly like a trap's
2316/// dispenser: this declaration gives an already-placed, already-lit, already
2317/// composed piece of furniture its contents. The compiler never places the
2318/// container itself — if the anchor's cell does not already hold one, that is a
2319/// content defect and a build error (`DW0431`), not something to paper over by
2320/// setblock-ing a chest into a wall.
2321#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2322#[serde(deny_unknown_fields)]
2323pub struct Loot {
2324    /// Unique loot id (`loot/<kebab>`).
2325    pub id: LootId,
2326    /// The anchor whose cell holds the container to fill.
2327    pub anchor: AnchorId,
2328    /// Contents, in declaration order. Slot assignment is positional and
2329    /// deterministic — the first entry lands in `container.0`, the second in
2330    /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
2331    pub items: Vec<LootItem>,
2332}
2333
2334/// One stack inside a [`Loot`] container.
2335#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2336#[serde(deny_unknown_fields)]
2337pub struct LootItem {
2338    /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
2339    /// 1.21.11 item registry (`DW0143`).
2340    pub item: String,
2341    /// Stack size. Defaults to 1.
2342    #[serde(default = "one_u32")]
2343    pub count: u32,
2344    /// Optional custom item name. Enters the l10n string inventory exactly like
2345    /// a class kit item's name.
2346    #[serde(default, skip_serializing_if = "Option::is_none")]
2347    pub name: Option<String>,
2348    /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
2349    /// 1.21 `minecraft:enchantments` item component.
2350    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2351    pub enchantments: BTreeMap<String, u32>,
2352}
2353
2354fn one_u32() -> u32 {
2355    1
2356}
2357
2358/// A stage-5 trap (DSL v0.6, spec-0011; command payloads spec-0022): an
2359/// environmental hazard at one cell of a placed piece.
2360///
2361/// **What the prefab has to provide is one point anchor, and for most traps that
2362/// is all.** [`Trap::at`] names the trigger/hazard cell; the compiler models it
2363/// as a hazard for the completability proofs (`DW0342`) and, for a disarmable
2364/// trap, emits the disarm affordance. A [`payload`](Trap::payload) trap needs
2365/// nothing else: **the compiler owns the detection**, emitting a per-tick,
2366/// edge-latched `execute … if entity @a[<cell>]` and running the authored effect
2367/// bundle from it.
2368///
2369/// Two things a piece must pre-wire, each for one case and neither for the
2370/// common one:
2371///
2372/// * the legacy [`effect`](Trap::effect) — a `dispense` payload the prefab's own
2373///   redstone fires — needs the anchor's `dispenser` socket cell, which the
2374///   compiler fills. That is the case "harm is redstone-native" (spec-0011) was
2375///   written about, and the only one in which no detection is emitted.
2376/// * a **flag-gated** trap ([`requires_flags`](Trap::requires_flags) /
2377///   [`forbids_flags`](Trap::forbids_flags)) needs the anchor's `trigger_block`,
2378///   because gating removes the trigger block from the world while the gate is
2379///   shut and puts it back verbatim (`DW0363`).
2380///
2381/// Player-vs-mob distinguishing matters in a sealed box-garden with controlled
2382/// mobs, so `trapped-chest` (opened by a player) is called out as the only
2383/// player-distinct trigger.
2384#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2385#[serde(deny_unknown_fields)]
2386pub struct Trap {
2387    /// Unique trap id (`trap/<kebab>`).
2388    pub id: TrapId,
2389    /// **The point anchor this trap sits on** — any anchor an area's prefab
2390    /// provides, whatever it is called. Its cell is the trigger/hazard cell the
2391    /// compiler models, and for a `payload` trap that cell is the whole of what
2392    /// the piece has to provide: detection is the compiler's.
2393    ///
2394    /// The anchor additionally needs a `dispenser` socket for a legacy
2395    /// [`effect`](Trap::effect) trap, and a `trigger_block` for a flag-gated one
2396    /// (`DW0363`). `anchor/trap` is the name the shipped pieces use, and a name
2397    /// is all it is.
2398    pub at: AnchorId,
2399    /// What springs the trap (all redstone-native).
2400    pub trigger: TrapTrigger,
2401    /// The **legacy** redstone consequence (spec-0011): a static dispenser
2402    /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
2403    /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
2404    /// so existing campaigns build unchanged. Optional since spec-0022; a trap
2405    /// must declare `effect`, `payload`, or both (`DW0440`).
2406    #[serde(default, skip_serializing_if = "Option::is_none")]
2407    pub effect: Option<TrapEffect>,
2408    /// The **command payload** (spec-0022): an ordered effect list in the same
2409    /// vocabulary quests use, run when the trigger fires. This is where a trap's
2410    /// consequence lives now — the compiler owns the detection tick and the
2411    /// effect vocabulary, so a trap's payload is authored like any other effect
2412    /// bundle rather than built out of dust and repeaters. Expressiveness moves
2413    /// from "what dust can carry" to "what the effect vocabulary can say":
2414    /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
2415    /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
2416    ///
2417    /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
2418    /// emitted before (byte-identical).
2419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2420    pub payload: Vec<QuestEffect>,
2421    /// How dangerous the trap is. A `lethal` trap on the forced critical path
2422    /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
2423    /// carry none. Defaults to `harmful`.
2424    #[serde(default)]
2425    pub lethality: Lethality,
2426    /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
2427    /// turn the trap off — setting a flag and emptying the dispenser — before the
2428    /// trap cell is forced.
2429    #[serde(default, skip_serializing_if = "Option::is_none")]
2430    pub disarm: Option<TrapDisarm>,
2431    /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
2432    /// spent — the survivability path); `rearm` = re-fires each trigger (default).
2433    #[serde(default)]
2434    pub reset: TrapReset,
2435    /// Flags that must be set before the trap is considered active (mirrors
2436    /// [`EnvTrigger::requires_flags`]). Default empty.
2437    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2438    pub requires_flags: Vec<FlagId>,
2439    /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
2440    /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
2441    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2442    pub forbids_flags: Vec<FlagId>,
2443    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2444    /// hold for this gate to be open. The third field of the one gate, carried by
2445    /// every gate consumer — never by the verb that first wanted it. Default
2446    /// empty, so a pre-0.10 campaign is byte-identical.
2447    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2448    pub requires_state: Vec<StateCompare>,
2449}
2450
2451impl Trap {
2452    /// `(item, count)` if this trap declares a legacy `dispense` effect.
2453    pub fn dispense(&self) -> Option<(&str, u32)> {
2454        match &self.effect {
2455            Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
2456            None => None,
2457        }
2458    }
2459
2460    /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
2461    /// forced critical path).
2462    pub fn is_lethal(&self) -> bool {
2463        matches!(self.lethality, Lethality::Lethal)
2464    }
2465}
2466
2467/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
2468/// redstone-native — the hardware fires without any command — so the compiler
2469/// emits no detection for them; it only models the trigger cell as a hazard and
2470/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
2471/// is deliberately *not* a trap trigger: it is already fully expressible as an
2472/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
2473#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2474#[serde(rename_all = "kebab-case")]
2475pub enum TrapTrigger {
2476    /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
2477    PressurePlate,
2478    /// A tripwire line: any entity crossing it. Auto-rearms.
2479    Tripwire,
2480    /// A trapped chest: a *player opening it* (comparator pulse). The only
2481    /// player-distinct trigger — a controlled mob cannot spring it.
2482    TrappedChest,
2483}
2484
2485impl TrapTrigger {
2486    /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
2487    pub fn kind(&self) -> &'static str {
2488        match self {
2489            TrapTrigger::PressurePlate => "pressure-plate",
2490            TrapTrigger::Tripwire => "tripwire",
2491            TrapTrigger::TrappedChest => "trapped-chest",
2492        }
2493    }
2494}
2495
2496/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
2497/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
2498/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
2499/// block-destroying and unmodeled effects out of the schema by construction
2500/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
2501#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2502#[serde(rename_all = "kebab-case", deny_unknown_fields)]
2503pub enum TrapEffect {
2504    /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
2505    /// arrows, splash potions). The redstone fires it; terrain is untouched
2506    /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
2507    /// `Items` NBT — a deterministic, static payload.
2508    Dispense {
2509        /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
2510        item: String,
2511        /// How many to load into the dispenser stack.
2512        count: u32,
2513    },
2514}
2515
2516/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
2517/// forced-critical-path completability obligation (`DW0342`).
2518#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2519#[serde(rename_all = "kebab-case")]
2520pub enum Lethality {
2521    /// Can kill a full-health player — carries the `DW0342` obligation on the path.
2522    Lethal,
2523    /// Hurts but is not designed to kill (the default).
2524    #[default]
2525    Harmful,
2526    /// Cosmetic / trivial (a stumble, a scare).
2527    Nonlethal,
2528}
2529
2530/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
2531#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2532#[serde(rename_all = "kebab-case")]
2533pub enum TrapReset {
2534    /// Fires once, then is spent — the survivability path for a forced lethal trap
2535    /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
2536    /// soft-loop).
2537    Once,
2538    /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
2539    /// trap must be avoidable or disarmable, else `DW0342`.
2540    #[default]
2541    Rearm,
2542}
2543
2544/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
2545/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
2546/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
2547/// before the trap cell is forced. Discharges the `DW0342` obligation when the
2548/// affordance is reachable ahead of the trap without crossing it.
2549#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2550#[serde(deny_unknown_fields)]
2551pub struct TrapDisarm {
2552    /// The anchor the player interacts with to disarm.
2553    pub via: AnchorId,
2554    /// The flag set when the trap is disarmed (a new flag this trap produces; other
2555    /// objectives/triggers may read it via `requires_flags`).
2556    pub sets_flag: FlagId,
2557}
2558
2559/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
2560/// deterministic open/close clock, so passage is a timing read rather than a
2561/// permanent state.
2562///
2563/// **The proof is deliberately NOT all-phase passability** — a gate that
2564/// punishes bad timing is the entire point. What the
2565/// compiler requires is that the gate is *readable*: the set of entry phases from
2566/// which a walking player clears the span before it shuts must cover **≥ 20% of
2567/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
2568/// amount of learning the level makes it fair.
2569#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2570#[serde(deny_unknown_fields)]
2571pub struct TimedGate {
2572    /// Unique timed-gate id (`timed-gate/<kebab>`).
2573    pub id: TimedGateId,
2574    /// The gate anchor the clock drives. Its prefab metadata must declare a fill
2575    /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
2576    pub gate: AnchorId,
2577    /// Ticks the gate stays OPEN each cycle (> 0).
2578    pub open_ticks: u32,
2579    /// Ticks the gate stays CLOSED each cycle (> 0).
2580    pub closed_ticks: u32,
2581    /// Ticks after world init before the first open window begins (default 0) —
2582    /// how two gates in the same room are put out of step with each other. Must
2583    /// be less than the full cycle.
2584    #[serde(default, skip_serializing_if = "is_zero")]
2585    pub phase: u32,
2586    /// Whether the gate **kills** a player caught inside its region when it
2587    /// shuts (spec-0016 §4 addendum). A portcullis
2588    /// that merely shoves you aside teaches nothing; mistiming the crossing is
2589    /// supposed to be a death you learn from, which is the whole point of §4's
2590    /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
2591    /// absolute.
2592    ///
2593    /// This is a real judgement issued by command on the closing tick, not
2594    /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
2595    /// escapable, so it would make the portcullis a suggestion. `damage` at the
2596    /// closing edge is exact and unarguable.
2597    ///
2598    /// **Defaults to `false`**, so every campaign authored before this field
2599    /// existed compiles byte-identically; a delve opts its portcullis in.
2600    #[serde(default, skip_serializing_if = "is_false")]
2601    pub crush: bool,
2602    /// Optional **disarm** affordance (souls dossier §5.2): the third
2603    /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
2604    /// The real games' best timed hazards can be removed for good (Smouldering
2605    /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
2606    /// dance with is one rung short of the vocabulary.
2607    ///
2608    /// Interacting with the affordance suppresses the clock **permanently, with
2609    /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
2610    /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
2611    /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
2612    ///
2613    /// **Defaults to absent**, so every campaign authored before this field
2614    /// existed compiles byte-identically; a delve opts its portcullis in.
2615    #[serde(default, skip_serializing_if = "Option::is_none")]
2616    pub disarm: Option<TimedGateDisarm>,
2617}
2618
2619/// A [`TimedGate`]'s disarm affordance (souls dossier §5.2) — the
2620/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
2621/// grammar for every mechanism the party can switch off.
2622///
2623/// The player acts on the `via` anchor (a compiler-emitted interaction entity
2624/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
2625/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
2626/// put the gate back.
2627#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2628#[serde(deny_unknown_fields)]
2629pub struct TimedGateDisarm {
2630    /// The anchor the player interacts with to jam the gate. Must be an anchor
2631    /// some area's prefab provides, and never the gate anchor itself — the
2632    /// mechanism belongs beside the doorway, not inside the span that crushes.
2633    pub via: AnchorId,
2634    /// The flag set when the gate is disarmed (a new flag this gate produces;
2635    /// other objectives/triggers may read it via `requires_flags`).
2636    pub sets_flag: FlagId,
2637}
2638
2639/// serde `skip_serializing_if` helper: skip a `0`.
2640fn is_zero(n: &u32) -> bool {
2641    *n == 0
2642}
2643
2644/// A stage-5 **ambush** (spec-0016 §3) — one declaration for a beat that
2645/// otherwise takes a deferred actor set plus a hand-wired trigger.
2646///
2647/// **`telegraph` is optional, and that is a design ruling, not an oversight.**
2648/// The un-telegraphed ambush — the shove off the cliff you
2649/// could not have known about — is core souls vocabulary: 初见杀 is how the level
2650/// teaches. The engine does not sand that edge off.
2651///
2652/// What the engine *does* owe the player is **counterplay on the retry**: having
2653/// died once, an informed player must have something to do about it. Determinism
2654/// guarantees the second attempt meets the same ambushers in the same cells; the
2655/// compiler adds the missing half — `DW0376` proves the trigger cell is not a
2656/// sealed pocket, i.e. that with every ambusher standing where it will stand,
2657/// a route out still exists (a retreat, luring ground, a positioning line).
2658/// Dying uninformed is a lesson; dying with no play available is a broken beat.
2659#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2660#[serde(deny_unknown_fields)]
2661pub struct Ambush {
2662    /// Unique ambush id (`ambush/<kebab>`).
2663    pub id: AmbushId,
2664    /// The anchor the trigger watches — the corner, doorway or ledge the player
2665    /// walks into.
2666    pub at: AnchorId,
2667    /// The stage-5 actors that spring (1..N). Each is summoned at its own
2668    /// declared anchor and immediately unleashed to real AI.
2669    pub actors: Vec<ActorId>,
2670    /// What springs it (`approach{range}` / `strike` / `use`), exactly the v0.4
2671    /// environment-trigger vocabulary.
2672    pub trigger: TriggerOn,
2673    /// The **optional** tell, fired at the trigger before the ambushers exist:
2674    /// a sound, a shadow, a line of narration. Empty = un-telegraphed, which is
2675    /// fully legal.
2676    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2677    pub telegraph: Vec<QuestEffect>,
2678    /// What the spring does to the story (DSL v0.8, spec-0025; required at
2679    /// 0.8.0, `DW0481`).
2680    ///
2681    /// **The declaration lives here because this is the object the author
2682    /// wrote.** An ambush desugars into a `spawn-actor` plus an `unleash-actor`
2683    /// per listed actor, and every one of those is a story node `DW0481` demands
2684    /// a `happening` from — on effects the author never wrote and cannot reach.
2685    /// So for as long as this field did not exist, an `ambushes[]` entry could
2686    /// not compile at 0.8.0 or above at all: declared in the schema, accepted by
2687    /// the schema check, and refused at validation with a prescription naming a
2688    /// field that was not there. Nothing caught it because no campaign and no
2689    /// fixture had ever written an ambush at a version where the obligation was
2690    /// live — which is the case spec-0039 exists to make impossible.
2691    ///
2692    /// An ambush is **one** beat, not `2N` of them: the ambushers appearing and
2693    /// coming at you is a single dramatic moment, so one declaration covers the
2694    /// whole spring. [`Self::to_trigger`] stamps it onto the first generated
2695    /// `spawn-actor` so the chronicle carries the line at the right position,
2696    /// and `branch::check_happenings` reads the obligation off the ambush rather
2697    /// than off the beats derived from it. Repeating it on all `2N` is the
2698    /// obvious alternative and it is wrong twice over: the chronicle would gain
2699    /// `N` duplicate lines, and `DW0485` would see one subject act repeatedly
2700    /// and be right to call it a contradiction.
2701    #[serde(default, skip_serializing_if = "Option::is_none")]
2702    pub happening: Option<Happening>,
2703}
2704
2705impl Ambush {
2706    /// The environment trigger this ambush desugars to (spec-0016 §3): a
2707    /// one-shot trigger at `at` whose effects are the telegraph, then a
2708    /// `spawn-actor` + `unleash-actor` per listed actor, in declared order.
2709    ///
2710    /// This is the **single** expansion authority. Every consumer — validation,
2711    /// the l10n inventory, the flag/wave producer scans, nav, emission — reads
2712    /// triggers through [`QuestsContent::all_triggers`], so the sugar cannot
2713    /// diverge from a hand-written equivalent, and an ambush is exactly as
2714    /// debuggable as the trigger an author would otherwise type.
2715    pub fn to_trigger(&self) -> EnvTrigger {
2716        let mut effects = self.telegraph.clone();
2717        for (i, a) in self.actors.iter().enumerate() {
2718            effects.push(QuestEffect {
2719                when: None,
2720                // The ambush declaration, on the beat where the spring becomes
2721                // real. Only the FIRST, for the reason the field documents: one
2722                // ambush is one beat, and stamping the line on every generated
2723                // effect would pad the chronicle and trip `DW0485`.
2724                happening: if i == 0 { self.happening.clone() } else { None },
2725                verb: Verb::SpawnActor { actor: a.clone() },
2726            });
2727        }
2728        for a in &self.actors {
2729            effects.push(Verb::UnleashActor { actor: a.clone() }.into());
2730        }
2731        EnvTrigger {
2732            id: TriggerId(format!(
2733                "trigger/{}",
2734                crate::l10n::local_id(self.id.as_str())
2735            )),
2736            at: Some(self.at.clone()),
2737            on: self.trigger.clone(),
2738            requires_flags: Vec::new(),
2739            forbids_flags: Vec::new(),
2740            requires_state: Vec::new(),
2741            once: true,
2742            // An ambush is a party beat by construction — it springs actors at
2743            // the room, not a reply to the one who walked in.
2744            audience: TriggerAudience::Party,
2745            effects,
2746        }
2747    }
2748}
2749
2750/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
2751///
2752/// The owner's definition of the pattern: between two rest points there are two
2753/// routes. The **short** one starts sealed and holds nothing; the **long** one is
2754/// full of enemies and mechanisms. You earn the far side the hard way, pull one
2755/// mechanism, and the short route opens **forever**. That moment is the design.
2756///
2757/// The compiler owns three obligations, none of them optional:
2758/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
2759///    long route genuinely exists (`DW0373`);
2760/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
2761///    pays nothing is a leak, not a shortcut (`DW0360`);
2762/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
2763///    (`DW0372`). There is no re-sealing verb to reach for.
2764///
2765/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
2766/// untouched by this — the two verbs are deliberately disjoint.
2767#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2768#[serde(deny_unknown_fields)]
2769pub struct Shortcut {
2770    /// Unique shortcut id (`shortcut/<kebab>`).
2771    pub id: ShortcutId,
2772    /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
2773    /// carries the physical fill), and its metadata must declare the fill `block`
2774    /// the compiler clears — the same requirement `close-gate` has.
2775    pub gate: AnchorId,
2776    /// The FAR-side anchor whose interaction fires the permanent open. The
2777    /// compiler summons the affordance there and polls it, reusing the v0.4
2778    /// interaction-entity `use` primitive.
2779    pub unlock: AnchorId,
2780    /// Effects fired once, when the shortcut opens — the bar lifting, the
2781    /// elevator descending, the sound of a door you will never have to earn
2782    /// again. Emitted server-source-safe (the poll lives on the tick).
2783    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2784    pub on_unlock: Vec<QuestEffect>,
2785}
2786
2787/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
2788/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
2789/// entity's attack/interaction records; `approach` is a `distance` selector on
2790/// the tick. Look-at / break-attempt detection is excluded on principle (no
2791/// vanilla primitive).
2792#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2793#[serde(deny_unknown_fields)]
2794pub struct EnvTrigger {
2795    /// Unique trigger id (`trigger/<kebab>`).
2796    pub id: TriggerId,
2797    /// The anchor this trigger watches. Required for `strike` / `use` /
2798    /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
2799    /// v0.6), which watches a *character* and names it in `on.npc` instead —
2800    /// there is no cell for the author to supply and no cell the compiler
2801    /// would use. Either mismatch is `DW0194`.
2802    #[serde(default, skip_serializing_if = "Option::is_none")]
2803    pub at: Option<AnchorId>,
2804    /// The event that fires it.
2805    pub on: TriggerOn,
2806    /// Flags that must be set before the trigger can fire (DSL v0.4).
2807    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2808    pub requires_flags: Vec<FlagId>,
2809    /// Negative flag gate (DSL v0.6): the trigger is
2810    /// **suppressed** while ANY listed flag is set (by any player — flags are
2811    /// campaign state). The dual of `requires_flags`, so an "armed between two
2812    /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
2813    /// retaliation trigger with `requires_flags: [flag/sealed]` and
2814    /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
2815    /// the moment the wake beat takes over.
2816    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2817    pub forbids_flags: Vec<FlagId>,
2818    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2819    /// hold for this gate to be open. The third field of the one gate, carried by
2820    /// every gate consumer — never by the verb that first wanted it. Default
2821    /// empty, so a pre-0.10 campaign is byte-identical.
2822    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2823    pub requires_state: Vec<StateCompare>,
2824    /// Fire at most once (default `true`, mirroring objective completion). Set
2825    /// `false` to allow re-firing every time the condition is met.
2826    #[serde(default = "default_true")]
2827    pub once: bool,
2828    /// **Who the trigger's effects address** (DSL v0.11). Default
2829    /// [`TriggerAudience::Party`], so every campaign written before this field
2830    /// existed is byte-identical.
2831    ///
2832    /// A trigger is two different things depending on what the author means by
2833    /// it. A pressure plate that opens a gate and narrates the room is a **party
2834    /// beat**: everyone should see it, and it does not matter who stepped on the
2835    /// plate. A barred door that answers *"this cannot be opened from this
2836    /// side"* is a **reply to one person**: broadcasting it tells four players
2837    /// about a door three of them are nowhere near.
2838    ///
2839    /// Until this field the second was inexpressible, which is why the two verbs
2840    /// that needed it (`close-gate`'s seal answer, and nothing at all for a
2841    /// shortcut door) grew their own private reply machinery instead. The
2842    /// capability belongs to the press, not to the verb.
2843    #[serde(default, skip_serializing_if = "TriggerAudience::is_party")]
2844    pub audience: TriggerAudience,
2845    /// Effects fired when the trigger matches.
2846    pub effects: Vec<QuestEffect>,
2847}
2848
2849impl EnvTrigger {
2850    /// The anchor this trigger watches, if it watches a place at all. `None`
2851    /// for `strike-npc`, whose target is a character.
2852    pub fn at_anchor(&self) -> Option<&str> {
2853        self.at.as_ref().map(|a| a.as_str())
2854    }
2855
2856    /// Whether this trigger's bundle is addressed to the player who pressed it.
2857    pub fn addresses_presser(&self) -> bool {
2858        self.audience == TriggerAudience::Presser
2859    }
2860}
2861
2862/// Who an [`EnvTrigger`]'s effects address (DSL v0.11).
2863///
2864/// **This is a dispatch decision, not a cosmetic one.** A `party` trigger is
2865/// polled on the tick with no executor, so `@s` does not exist and every
2866/// player-facing command addresses `@a`. A `presser` trigger is dispatched by a
2867/// `minecraft:player_interacted_with_entity` advancement — the one vanilla
2868/// primitive that runs a function *as the player who clicked* — so `@s` is the
2869/// presser and the bundle addresses them alone.
2870///
2871/// That primitive exists for **right-clicks only**. Vanilla records a left-click
2872/// on an interaction entity in NBT (which names a UUID no command can become) and
2873/// offers no criterion for it, so `presser` on a `strike` is refused (`DW0427`)
2874/// rather than approximated: per CLAUDE.md's no-hack rule, a capability with no
2875/// vanilla primitive under it is excluded, never faked downstream.
2876#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2877#[serde(rename_all = "kebab-case")]
2878pub enum TriggerAudience {
2879    /// The whole party (the default, and what every trigger did before v0.11).
2880    #[default]
2881    Party,
2882    /// The one player whose click fired it.
2883    Presser,
2884}
2885
2886impl TriggerAudience {
2887    /// Serde skip predicate: the default needs no field on the wire, so a
2888    /// canonical round-trip of a pre-0.11 campaign is byte-identical.
2889    fn is_party(&self) -> bool {
2890        *self == TriggerAudience::Party
2891    }
2892}
2893
2894/// The event an [`EnvTrigger`] watches (DSL v0.4).
2895#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2896#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
2897pub enum TriggerOn {
2898    /// The player attacks (left-clicks) the interaction entity at the anchor.
2899    Strike,
2900    /// The player uses (right-clicks) the interaction entity at the anchor.
2901    Use,
2902    /// The player comes within `range` blocks of the anchor.
2903    Approach {
2904        /// Approach radius (blocks).
2905        range: u32,
2906    },
2907    /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6).
2908    ///
2909    /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
2910    /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
2911    /// body eclipses that cell (`DW0359`), so the click never reaches the
2912    /// trigger — the owner's island round-7 finding. This form has no cell. It
2913    /// rides the interaction entity the NPC already owns, which is the entity a
2914    /// click on that NPC reaches by construction.
2915    ///
2916    /// Right-click and left-click stay separate all the way down: a
2917    /// `minecraft:interaction` records them in two distinct NBT fields
2918    /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
2919    /// and this trigger takes the left-click, on one shared hitbox.
2920    StrikeNpc {
2921        /// The NPC (stage-2 ref) whose body is the target.
2922        npc: NpcId,
2923    },
2924}
2925
2926impl TriggerOn {
2927    /// The kebab tag (`strike` / `use` / `approach` / `strike-npc`).
2928    pub fn kind(&self) -> &'static str {
2929        match self {
2930            TriggerOn::Strike => "strike",
2931            TriggerOn::Use => "use",
2932            TriggerOn::Approach { .. } => "approach",
2933            TriggerOn::StrikeNpc { .. } => "strike-npc",
2934        }
2935    }
2936
2937    /// Whether this event needs an `at` anchor — true for everything that
2938    /// watches a place, false for `strike-npc`, which watches a character.
2939    pub fn needs_anchor(&self) -> bool {
2940        !matches!(self, TriggerOn::StrikeNpc { .. })
2941    }
2942
2943    /// The NPC whose body this event watches (`strike-npc` only).
2944    pub fn npc_target(&self) -> Option<&NpcId> {
2945        match self {
2946            TriggerOn::StrikeNpc { npc } => Some(npc),
2947            _ => None,
2948        }
2949    }
2950}
2951
2952/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
2953/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
2954/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
2955/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
2956/// and the `kill` objective completes when the count reaches zero.
2957#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2958#[serde(deny_unknown_fields)]
2959pub struct Wave {
2960    /// Unique wave id.
2961    pub id: WaveId,
2962    /// The anchor the wave's mobs spawn at.
2963    pub anchor: AnchorId,
2964    /// The mobs that make up the wave (1..N).
2965    pub mobs: Vec<WaveMob>,
2966    /// Re-seat this wave every time the party rests at (or respawns from) a
2967    /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
2968    /// enemies come back. The compiler kills any survivor carrying the wave tag
2969    /// and re-runs the wave's own spawn function, so the room is restored to its
2970    /// authored composition and spawn cells.
2971    ///
2972    /// Inert without a `bonfire` in the campaign, which is a compile error
2973    /// (`DW0370`) rather than a silent no-op.
2974    #[serde(default, skip_serializing_if = "is_false")]
2975    pub respawns_on_rest: bool,
2976    /// Tower-defense lane routing (spec-0016 §6): march this wave along a
2977    /// waypoint polyline while distant, hand it to native AI the instant a
2978    /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
2979    /// stand), byte-identical.
2980    #[serde(default, skip_serializing_if = "Option::is_none")]
2981    pub lane: Option<WaveLane>,
2982    /// Where the wave's mobs materialize (spec-0016 §6). Absent =
2983    /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
2984    /// wave `anchor`.
2985    #[serde(default, skip_serializing_if = "Option::is_none")]
2986    pub summon: Option<WaveSummon>,
2987    /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
2988    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
2989    ///
2990    /// This is a **declaration, not a knob**: the compiler never scales content
2991    /// from it (spec-0023 "Out of scope"). It exists because the validation
2992    /// ladder's inverted floor gate needs to know which fights the content
2993    /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
2994    /// on its first attempt is reported as too easy for its billing. Marking it
2995    /// is how the author opts into that scrutiny; the alternative — inferring
2996    /// "elite" from how tuned a stack looks — is exactly the downstream folklore
2997    /// CLAUDE.md's no-hack rule forbids.
2998    ///
2999    /// **It does reach emission in exactly one place** (spec-0016 §1): in a
3000    /// campaign with a `bonfire`, a billed `elite`/
3001    /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
3002    /// rest *while it is still standing* — deleted and re-seated at full count
3003    /// and full health, so chipping it down one life at a time is never a path.
3004    /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
3005    /// `respawns_on_rest` at once.
3006    #[serde(default, skip_serializing_if = "Option::is_none")]
3007    pub tier: Option<EncounterTier>,
3008}
3009
3010/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
3011/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
3012/// — by one emission site: a bonfire refreshes a billed wave that is still
3013/// standing. Nothing about the encounter itself is scaled from it.
3014#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3015#[serde(rename_all = "kebab-case")]
3016pub enum EncounterTier {
3017    /// Trash / pressure. No floor expectation: a bot that wins it cold proves
3018    /// nothing either way. The default.
3019    #[default]
3020    Ordinary,
3021    /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
3022    /// and spawn-and-unleash vocabulary).
3023    Elite,
3024    /// A campaign's named fight. Same floor rule as `elite`; the distinction is
3025    /// for the run report a human reads.
3026    Boss,
3027}
3028
3029impl EncounterTier {
3030    /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
3031    pub fn token(self) -> &'static str {
3032        match self {
3033            EncounterTier::Ordinary => "ordinary",
3034            EncounterTier::Elite => "elite",
3035            EncounterTier::Boss => "boss",
3036        }
3037    }
3038
3039    /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
3040    /// content bills as hard carries an expectation the bot can measure; an
3041    /// ordinary one does not.
3042    pub fn has_floor_expectation(self) -> bool {
3043        matches!(self, EncounterTier::Elite | EncounterTier::Boss)
3044    }
3045}
3046
3047/// Where a wave's mobs materialize (spec-0016 §6).
3048#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3049#[serde(rename_all = "kebab-case")]
3050pub enum WaveSummon {
3051    /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
3052    /// behaviour and the default.
3053    Anchor,
3054    /// **Spirit-summoned at the edge of perception**:
3055    /// each mob appears on the ring at its own `attributes.follow_range` from
3056    /// the wave `anchor`, so it acquires a target the instant it exists and
3057    /// closes under pure native AI. Species without patrol AI never march a
3058    /// lane; this is what they do instead — never a spawn on top of the party,
3059    /// never a mob that brushes past.
3060    ///
3061    /// With this mode the wave `anchor` is the **defended point** (what the ring
3062    /// is drawn around), not the spawn point. Each mob stack must declare its
3063    /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
3064    /// never guessed from a vanilla defaults table the compiler cannot verify.
3065    AggroEdge,
3066}
3067
3068/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
3069/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
3070/// (`docs/notes/td-routing-spike.md`).
3071///
3072/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
3073/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
3074/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
3075/// From that instant the mob is a plain native hostile. "Combat preempts
3076/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
3077/// no target — so the owner's rule (march while distant, fight with NATIVE AI
3078/// once aggroed, never brush past) falls out of the primitive unforced.
3079///
3080/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
3081/// pillager must keep its crossbow (`DW0384`).
3082#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3083#[serde(deny_unknown_fields)]
3084pub struct WaveLane {
3085    /// The lane polyline, in march order: anchors some area's prefab provides.
3086    /// At least one; consecutive legs (counting the wave `anchor` as the origin)
3087    /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
3088    /// within 10 blocks of arrival, so a tighter lane is a lane the engine
3089    /// quietly stops following (`DW0386`).
3090    pub waypoints: Vec<AnchorId>,
3091    /// The release radius, in blocks: the compiler sets every lane mob's
3092    /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
3093    /// at the same distance. The two MUST be equal — a patrolling raider that
3094    /// targets a player it cannot engage holds ground instead of marching, so a
3095    /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
3096    pub aggro_radius: u32,
3097}
3098
3099/// One mob stack in a [`Wave`].
3100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3101#[serde(deny_unknown_fields)]
3102pub struct WaveMob {
3103    /// Vanilla entity id, validated against the pinned 1.21.11 registry.
3104    pub entity: String,
3105    /// How many to spawn.
3106    pub count: u32,
3107    /// Optional custom name (shown above the mob).
3108    #[serde(default, skip_serializing_if = "Option::is_none")]
3109    pub name: Option<String>,
3110    /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
3111    /// components. Enables e.g. a weakened live warden as a survivable stealth
3112    /// threat. Omitted = vanilla defaults.
3113    #[serde(default, skip_serializing_if = "Option::is_none")]
3114    pub attributes: Option<MobAttributes>,
3115    /// Optional permanent, ambient status effects (DSL v0.4).
3116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3117    pub effects: Vec<MobEffect>,
3118    /// Optional worn/held equipment (DSL v0.6). A helmet is the
3119    /// sanctioned fix for daylight-burning undead — never
3120    /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
3121    /// (`DW0143`, the give-item family); every emitted slot carries drop
3122    /// chance 0 so players can never farm wave gear (no-grind constitution).
3123    #[serde(default, skip_serializing_if = "Option::is_none")]
3124    pub equipment: Option<MobEquipment>,
3125    /// What this mob leaves behind when it dies (DSL v0.9). Only an `elite`/`boss` wave may declare it (`DW0491`)
3126    /// — an ordinary mob's kit is never farmable. Empty = drop chance 0 on
3127    /// every slot.
3128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3129    pub drops: Vec<MobDrop>,
3130}
3131
3132/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
3133/// id for the matching vanilla equipment slot; an unset slot stays empty —
3134/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
3135/// wither-skeleton sword) still applies unless overridden. Emitted as the
3136/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
3137/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
3138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3139#[serde(deny_unknown_fields)]
3140pub struct MobEquipment {
3141    /// Head slot.
3142    #[serde(default, skip_serializing_if = "Option::is_none")]
3143    pub head: Option<EquipItem>,
3144    /// Chest slot.
3145    #[serde(default, skip_serializing_if = "Option::is_none")]
3146    pub chest: Option<EquipItem>,
3147    /// Legs slot.
3148    #[serde(default, skip_serializing_if = "Option::is_none")]
3149    pub legs: Option<EquipItem>,
3150    /// Feet slot.
3151    #[serde(default, skip_serializing_if = "Option::is_none")]
3152    pub feet: Option<EquipItem>,
3153    /// Main-hand slot. Overrides the compiler's armed-mob default.
3154    #[serde(default, skip_serializing_if = "Option::is_none")]
3155    pub main_hand: Option<EquipItem>,
3156    /// Off-hand slot.
3157    #[serde(default, skip_serializing_if = "Option::is_none")]
3158    pub off_hand: Option<EquipItem>,
3159    /// Body slot: horse armour, wolf armour, a llama's carpet, a nautilus's
3160    /// armour, a happy ghast's harness (spec-0067). Shown only on a body whose
3161    /// entity type draws it (`DW0898`).
3162    #[serde(default, skip_serializing_if = "Option::is_none")]
3163    pub body: Option<EquipItem>,
3164    /// Saddle slot (spec-0067). Shown only on a body whose entity type draws a
3165    /// saddle (`DW0898`).
3166    #[serde(default, skip_serializing_if = "Option::is_none")]
3167    pub saddle: Option<EquipItem>,
3168}
3169
3170impl MobEquipment {
3171    /// Every slot as `(dsl_field_name, piece)`, in [`EquipSlot::ALL`]'s order —
3172    /// the single iteration source for validation paths and emission.
3173    pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); EquipSlot::ALL.len()] {
3174        EquipSlot::ALL.map(|s| (s.field(), self.filled(s)))
3175    }
3176
3177    /// Every slot as `(slot, piece)`, in [`EquipSlot::ALL`]'s order.
3178    pub fn pieces(&self) -> [(EquipSlot, Option<&EquipItem>); EquipSlot::ALL.len()] {
3179        EquipSlot::ALL.map(|s| (s, self.filled(s)))
3180    }
3181
3182    /// The piece this equipment declaration puts in `slot`, if any. The single
3183    /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
3184    /// actually wears (`DW0490`).
3185    pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
3186        match slot {
3187            EquipSlot::Head => self.head.as_ref(),
3188            EquipSlot::Chest => self.chest.as_ref(),
3189            EquipSlot::Legs => self.legs.as_ref(),
3190            EquipSlot::Feet => self.feet.as_ref(),
3191            EquipSlot::MainHand => self.main_hand.as_ref(),
3192            EquipSlot::OffHand => self.off_hand.as_ref(),
3193            EquipSlot::Body => self.body.as_ref(),
3194            EquipSlot::Saddle => self.saddle.as_ref(),
3195        }
3196    }
3197}
3198
3199/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
3200/// fills it. The DSL name and the summon-NBT key differ (`main_hand` vs
3201/// `mainhand`), so both live here and nowhere else.
3202///
3203/// **The set is the pinned game's equipment-slot set** (spec-0067 §2), a
3204/// [`crate::metrics::Provenance::VanillaRule`]: the eight values the
3205/// `minecraft:equippable` component's `slot` field takes, per the Minecraft
3206/// Wiki page *Data component format/equippable* for Java 1.21.11, which are the
3207/// serialised names of the client's `EquipmentSlot` enum. The pinned item data
3208/// is the cross-check, not the source: every `slot` value an item declares is
3209/// asserted to be one of these, and no item declares `mainhand`, because a hand
3210/// takes anything.
3211#[derive(
3212    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
3213)]
3214#[serde(rename_all = "snake_case")]
3215pub enum EquipSlot {
3216    /// Head slot.
3217    Head,
3218    /// Chest slot.
3219    Chest,
3220    /// Legs slot.
3221    Legs,
3222    /// Feet slot.
3223    Feet,
3224    /// Main-hand slot.
3225    MainHand,
3226    /// Off-hand slot.
3227    OffHand,
3228    /// Body slot (horse armour, wolf armour, carpet, harness).
3229    Body,
3230    /// Saddle slot.
3231    Saddle,
3232}
3233
3234impl EquipSlot {
3235    /// Every slot, in emission order: the two hands, the four armour slots,
3236    /// then `body` and `saddle`. The one enumeration every slot list derives
3237    /// from — `MobEquipment::slots()`, the summon `equipment` / `drop_chances`
3238    /// compounds and the drop-strip line.
3239    pub const ALL: [EquipSlot; 8] = [
3240        EquipSlot::MainHand,
3241        EquipSlot::OffHand,
3242        EquipSlot::Head,
3243        EquipSlot::Chest,
3244        EquipSlot::Legs,
3245        EquipSlot::Feet,
3246        EquipSlot::Body,
3247        EquipSlot::Saddle,
3248    ];
3249
3250    /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
3251    pub fn field(self) -> &'static str {
3252        match self {
3253            EquipSlot::Head => "head",
3254            EquipSlot::Chest => "chest",
3255            EquipSlot::Legs => "legs",
3256            EquipSlot::Feet => "feet",
3257            EquipSlot::MainHand => "main_hand",
3258            EquipSlot::OffHand => "off_hand",
3259            EquipSlot::Body => "body",
3260            EquipSlot::Saddle => "saddle",
3261        }
3262    }
3263
3264    /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`) — also the
3265    /// game's own name for the slot, as an `equippable` component spells it.
3266    pub fn nbt(self) -> &'static str {
3267        match self {
3268            EquipSlot::Head => "head",
3269            EquipSlot::Chest => "chest",
3270            EquipSlot::Legs => "legs",
3271            EquipSlot::Feet => "feet",
3272            EquipSlot::MainHand => "mainhand",
3273            EquipSlot::OffHand => "offhand",
3274            EquipSlot::Body => "body",
3275            EquipSlot::Saddle => "saddle",
3276        }
3277    }
3278
3279    /// The slot the game names `name` (`mainhand`), if it is one.
3280    pub fn from_nbt(name: &str) -> Option<EquipSlot> {
3281        EquipSlot::ALL.into_iter().find(|s| s.nbt() == name)
3282    }
3283
3284    /// Whether this is a hand: a hand takes any item, so an item's own declared
3285    /// slot never contradicts it.
3286    pub fn is_hand(self) -> bool {
3287        matches!(self, EquipSlot::MainHand | EquipSlot::OffHand)
3288    }
3289}
3290
3291/// One declared drop of an elite or boss (DSL v0.9).
3292///
3293/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
3294/// usually one piece and never automatically everything. Two forms, told apart
3295/// by which field is present:
3296///
3297/// ```json
3298/// { "slot": "main_hand" }                                  // its axe
3299/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
3300/// ```
3301///
3302/// A `slot` entry must name a slot the same entity's `equipment` really fills
3303/// (`DW0490`) — the drop is the piece the player has been *looking at* through
3304/// the whole fight, so the two declarations cannot disagree. An `item` entry is
3305/// a token the fight *yields* rather than wears, and rides the entity's own
3306/// death loot table.
3307///
3308/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
3309/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
3310#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3311#[serde(untagged)]
3312pub enum MobDrop {
3313    /// A worn/held piece, named by its equipment slot.
3314    Slot(SlotDrop),
3315    /// A quest token the fight yields (not worn).
3316    Item(ItemDrop),
3317}
3318
3319/// The worn-piece form of a [`MobDrop`].
3320#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3321#[serde(deny_unknown_fields)]
3322pub struct SlotDrop {
3323    /// The equipment slot whose piece drops. Must be filled by the entity's own
3324    /// `equipment` declaration (`DW0490`).
3325    pub slot: EquipSlot,
3326}
3327
3328/// The quest-token form of a [`MobDrop`].
3329#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3330#[serde(deny_unknown_fields)]
3331pub struct ItemDrop {
3332    /// Vanilla item id, validated against the pinned 1.21.11 registry
3333    /// (`DW0143`, the give-item family).
3334    pub item: String,
3335    /// Display name the dropped stack carries as a `custom_name` component.
3336    /// Player-visible, so it enters the l10n string inventory and translates
3337    /// like any other line.
3338    #[serde(default, skip_serializing_if = "Option::is_none")]
3339    pub name: Option<String>,
3340}
3341
3342impl MobDrop {
3343    /// The equipment slot this entry names, or `None` for a quest-item drop.
3344    pub fn slot(&self) -> Option<EquipSlot> {
3345        match self {
3346            MobDrop::Slot(s) => Some(s.slot),
3347            MobDrop::Item(_) => None,
3348        }
3349    }
3350
3351    /// The item id this entry names, or `None` for a worn-piece drop.
3352    pub fn item(&self) -> Option<&str> {
3353        match self {
3354            MobDrop::Slot(_) => None,
3355            MobDrop::Item(i) => Some(&i.item),
3356        }
3357    }
3358
3359    /// The declared display name of a quest-item drop, when set.
3360    pub fn name(&self) -> Option<&String> {
3361        match self {
3362            MobDrop::Slot(_) => None,
3363            MobDrop::Item(i) => i.name.as_ref(),
3364        }
3365    }
3366
3367    /// Mutable access to the display name — the l10n traversal's hook.
3368    pub fn name_mut(&mut self) -> Option<&mut String> {
3369        match self {
3370            MobDrop::Slot(_) => None,
3371            MobDrop::Item(i) => i.name.as_mut(),
3372        }
3373    }
3374}
3375
3376/// One equipped item: either a bare item id, or an id carrying enchantments.
3377///
3378/// The plain form is the common case and stays a plain JSON string, which is
3379/// what keeps every campaign written before enchantments existed byte-identical
3380/// on re-serialisation:
3381///
3382/// ```json
3383/// "main_hand": "minecraft:netherite_sword"
3384/// "head": { "item": "minecraft:netherite_helmet",
3385///           "enchantments": { "minecraft:protection": 4 } }
3386/// ```
3387#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3388#[serde(untagged)]
3389pub enum EquipItem {
3390    /// A bare item id — no enchantments.
3391    Plain(String),
3392    /// An item id plus its enchantments.
3393    Enchanted(EnchantedItem),
3394}
3395
3396impl EquipItem {
3397    /// The item id, whichever form was authored.
3398    pub fn item(&self) -> &str {
3399        match self {
3400            EquipItem::Plain(s) => s,
3401            EquipItem::Enchanted(e) => &e.item,
3402        }
3403    }
3404
3405    /// The enchantments on this piece — empty for the plain form. `BTreeMap`
3406    /// ordered, so emission order is the id order and never hash order
3407    /// (ADR-0006).
3408    pub fn enchantments(&self) -> &BTreeMap<String, u32> {
3409        static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
3410            std::sync::LazyLock::new(BTreeMap::new);
3411        match self {
3412            EquipItem::Plain(_) => &EMPTY,
3413            EquipItem::Enchanted(e) => &e.enchantments,
3414        }
3415    }
3416}
3417
3418/// The enchanted form of [`EquipItem`].
3419#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3420#[serde(deny_unknown_fields)]
3421pub struct EnchantedItem {
3422    /// Item id (e.g. `minecraft:netherite_chestplate`).
3423    pub item: String,
3424    /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
3425    /// the 1.21 `minecraft:enchantments` item component.
3426    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3427    pub enchantments: BTreeMap<String, u32>,
3428}
3429
3430/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
3431/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
3432#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3433#[serde(deny_unknown_fields)]
3434pub struct MobAttributes {
3435    /// `minecraft:max_health` base value.
3436    #[serde(default, skip_serializing_if = "Option::is_none")]
3437    pub max_health: Option<f64>,
3438    /// `minecraft:attack_damage` base value.
3439    #[serde(default, skip_serializing_if = "Option::is_none")]
3440    pub attack_damage: Option<f64>,
3441    /// `minecraft:movement_speed` base value.
3442    #[serde(default, skip_serializing_if = "Option::is_none")]
3443    pub movement_speed: Option<f64>,
3444    /// `minecraft:follow_range` base value.
3445    #[serde(default, skip_serializing_if = "Option::is_none")]
3446    pub follow_range: Option<f64>,
3447}
3448
3449/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
3450/// non-expiring `effect give`.
3451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3452#[serde(deny_unknown_fields)]
3453pub struct MobEffect {
3454    /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
3455    /// pinned registry (`DW0192`).
3456    pub effect: String,
3457    /// Amplifier (0 = level I).
3458    pub amplifier: u32,
3459}
3460
3461/// One expanded quest.
3462#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3463#[serde(deny_unknown_fields)]
3464pub struct Quest {
3465    /// Quest id (matches a stage-4 planned quest).
3466    pub id: QuestId,
3467    /// What starts the quest.
3468    pub trigger: Trigger,
3469    /// Ordered objectives (intra-quest DAG via `after`).
3470    pub objectives: Vec<Objective>,
3471    /// Effects fired when a given objective completes.
3472    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3473    pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
3474    /// Effects fired when the whole quest completes.
3475    pub on_complete: Vec<QuestEffect>,
3476    /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
3477    /// live during this quest — spawned and not explicitly removed — where it
3478    /// stands, what it is doing, and what its right-click offers *for this
3479    /// quest's duration*.
3480    ///
3481    /// The ledger exists because an NPC's dialogue used to be one tree for the
3482    /// whole campaign: after the climactic escape a crew member still offered
3483    /// "Tell me what he is." — a premise question absurd once the story moved on.
3484    /// Declaring the scene per quest makes the compiler able to check it
3485    /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
3486    /// emitted right-click shows the root this quest declares, so a stale root
3487    /// retires *because the ledger says so*, not because an author remembered a
3488    /// flag.
3489    ///
3490    /// Every NPC live during the quest owes an entry (`DW0460`).
3491    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3492    pub cast: BTreeMap<NpcId, CastEntry>,
3493    /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
3494    /// `DW0481`).
3495    #[serde(default, skip_serializing_if = "Option::is_none")]
3496    pub happening: Option<Happening>,
3497}
3498
3499/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
3500///
3501/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
3502/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
3503/// per-branch casts, each gated by the flags that select its branch (spec-0020
3504/// proof 4: where the effect history is branch-dependent, a single flat
3505/// declaration cannot hold on every reachable branch, and `DW0462` says so).
3506#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3507#[serde(untagged)]
3508pub enum CastEntry {
3509    /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
3510    /// placement whose `at` is that keyword and which carries no `doing` or
3511    /// `dialogue` — a character who is not in the world has no business and
3512    /// answers no right-click.
3513    Absent(CastAbsence),
3514    /// A single placement that must hold on every reachable branch.
3515    Placement(CastPlacement),
3516    /// Per-branch placements (spec-0020 proof 4). Each carries the
3517    /// `requires_flags`/`forbids_flags` that select its branch.
3518    Branches(Vec<CastPlacement>),
3519}
3520
3521impl CastEntry {
3522    /// Every placement this entry declares, in declared order. The bare-keyword
3523    /// form yields none — there is no scene to check.
3524    pub fn placements(&self) -> Vec<&CastPlacement> {
3525        match self {
3526            CastEntry::Absent(_) => Vec::new(),
3527            CastEntry::Placement(p) => vec![p],
3528            CastEntry::Branches(ps) => ps.iter().collect(),
3529        }
3530    }
3531
3532    /// Every placement this entry declares, mutably (the l10n traversal).
3533    pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
3534        match self {
3535            CastEntry::Absent(_) => Vec::new(),
3536            CastEntry::Placement(p) => vec![p],
3537            CastEntry::Branches(ps) => ps.iter_mut().collect(),
3538        }
3539    }
3540
3541    /// The bare-keyword absence this entry declares, if it is that form.
3542    pub fn absence(&self) -> Option<CastAbsence> {
3543        match self {
3544            CastEntry::Absent(a) => Some(*a),
3545            _ => None,
3546        }
3547    }
3548
3549    /// True if this entry declares the NPC out of the world entirely — the bare
3550    /// keyword, or every placement's `at` being a keyword.
3551    pub fn is_absent(&self) -> bool {
3552        match self {
3553            CastEntry::Absent(_) => true,
3554            CastEntry::Placement(p) => p.at.absence().is_some(),
3555            CastEntry::Branches(ps) => {
3556                !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
3557            }
3558        }
3559    }
3560}
3561
3562/// A declared absence: the NPC is deliberately not in the world.
3563#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3564#[serde(rename_all = "kebab-case")]
3565pub enum CastAbsence {
3566    /// Removed from the story for good (must match a `despawn-npc` with no later
3567    /// `spawn-npc`).
3568    Dead,
3569    /// Not in the world for this quest, but may return (must match a
3570    /// `despawn-npc`).
3571    Offstage,
3572}
3573
3574impl CastAbsence {
3575    /// The authored keyword (`dead` / `offstage`).
3576    pub fn token(self) -> &'static str {
3577        match self {
3578            CastAbsence::Dead => "dead",
3579            CastAbsence::Offstage => "offstage",
3580        }
3581    }
3582}
3583
3584/// Where a cast entry puts an NPC: a prefab anchor, a mark, or a declared
3585/// absence.
3586#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3587#[serde(untagged)]
3588pub enum CastPlace {
3589    /// `"dead"` or `"offstage"` — explicitly not in the world.
3590    Absent(CastAbsence),
3591    /// The anchor the NPC stands on for this quest's duration. Must equal the
3592    /// position the effect history actually produces (`DW0461`): declaring an
3593    /// anchor does not teleport anybody.
3594    Anchor(AnchorId),
3595    /// The mark the NPC stands on for this quest's duration (spec-0066): the
3596    /// spelling for a body that stands at an offset from its anchor. `DW0461`
3597    /// compares anchor and offset both.
3598    Mark(Mark),
3599}
3600
3601impl CastPlace {
3602    /// The anchor this place names, if it names one.
3603    pub fn anchor(&self) -> Option<&AnchorId> {
3604        match self {
3605            CastPlace::Anchor(a) => Some(a),
3606            CastPlace::Mark(m) => Some(&m.anchor),
3607            CastPlace::Absent(_) => None,
3608        }
3609    }
3610
3611    /// The mark this place names, if it names one: a bare anchor is the mark at
3612    /// a zero offset.
3613    pub fn mark(&self) -> Option<Mark> {
3614        match self {
3615            CastPlace::Anchor(a) => Some(Mark::at(a.clone())),
3616            CastPlace::Mark(m) => Some(m.clone()),
3617            CastPlace::Absent(_) => None,
3618        }
3619    }
3620
3621    /// The declared absence, if this place is one.
3622    pub fn absence(&self) -> Option<CastAbsence> {
3623        match self {
3624            CastPlace::Absent(a) => Some(*a),
3625            CastPlace::Anchor(_) | CastPlace::Mark(_) => None,
3626        }
3627    }
3628
3629    /// The authored token, for diagnostics.
3630    pub fn token(&self) -> String {
3631        match self {
3632            CastPlace::Absent(a) => a.token().to_string(),
3633            CastPlace::Anchor(a) => a.as_str().to_string(),
3634            CastPlace::Mark(m) => m.display(),
3635        }
3636    }
3637}
3638
3639/// One declared scene for one NPC in one quest.
3640#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3641#[serde(deny_unknown_fields)]
3642pub struct CastPlacement {
3643    /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
3644    pub at: CastPlace,
3645    /// What the character is *doing* — free prose, never machine-checked.
3646    ///
3647    /// This field is the forcing function, which is the whole reason it is
3648    /// required (`DW0463`) despite being unverifiable: you cannot fill it in
3649    /// without deciding the character's business in this beat, and stage 6
3650    /// receives it as the context the NPC's lines are written against.
3651    #[serde(default, skip_serializing_if = "Option::is_none")]
3652    pub doing: Option<String>,
3653    /// What right-click offers during this quest. Required for an on-stage
3654    /// placement (`DW0463`) — including the explicit `"none"`.
3655    #[serde(default, skip_serializing_if = "Option::is_none")]
3656    pub dialogue: Option<CastDialogue>,
3657    /// Branch gate (per-branch casts): this placement describes the world only
3658    /// once every listed flag is set. Mirrors an option's `requires_flags`.
3659    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3660    pub requires_flags: Vec<FlagId>,
3661    /// Negative branch gate: this placement describes the world only while no
3662    /// listed flag is set. Mirrors an option's `forbids_flags`.
3663    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3664    pub forbids_flags: Vec<FlagId>,
3665    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
3666    /// hold for this gate to be open. The third field of the one gate, carried by
3667    /// every gate consumer — never by the verb that first wanted it. Default
3668    /// empty, so a pre-0.10 campaign is byte-identical.
3669    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3670    pub requires_state: Vec<StateCompare>,
3671}
3672
3673/// What an NPC's right-click offers for a quest's duration.
3674///
3675/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
3676/// `{"barks": […]}` pool, then a dialogue root id.
3677#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3678#[serde(untagged)]
3679pub enum CastDialogue {
3680    /// A keyword: `"none"` or `"unchanged"`.
3681    Keyword(CastDialogueKeyword),
3682    /// A bark pool: right-click yields one inconsequential in-character line —
3683    /// no tree, no options, no consequences.
3684    Barks(CastBarks),
3685    /// A dialogue root id: right-click opens this node of the NPC's stage-6
3686    /// tree. Must be a node of *that* NPC's tree (`DW0464`).
3687    Root(DialogueId),
3688}
3689
3690/// The keyword forms of [`CastDialogue`].
3691#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3692#[serde(rename_all = "kebab-case")]
3693pub enum CastDialogueKeyword {
3694    /// Genuinely no reaction: the right-click is recorded and consumed, and
3695    /// nothing opens. Legal, but a last resort — if a body is clickable, the
3696    /// world should answer; prefer a bark.
3697    None,
3698    /// Carry forward whatever this NPC's dialogue was at its **previous**
3699    /// appearance in the quest-DAG ordering.
3700    ///
3701    /// The point is that carrying dialogue forward is a *conscious, declared
3702    /// act*. It is never an implicit default — an omitted `dialogue` is
3703    /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
3704    /// intent without re-spelling a root id that then drifts out of sync. It
3705    /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
3706    /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
3707    ///
3708    /// Emission is a **no-op** — no root swap is emitted for that NPC at that
3709    /// quest — which is what makes the sugar cheap and byte-stable.
3710    Unchanged,
3711}
3712
3713/// A bark pool: inconsequential in-character lines, cycled deterministically.
3714#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3715#[serde(deny_unknown_fields)]
3716pub struct CastBarks {
3717    /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
3718    /// they enter the l10n inventory like any narrate text.
3719    pub barks: Vec<String>,
3720}
3721
3722/// What triggers a quest.
3723#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3724#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3725pub enum Trigger {
3726    /// Fires when the campaign starts.
3727    CampaignStart,
3728    /// Fires when another quest completes.
3729    QuestComplete {
3730        /// The prerequisite quest.
3731        quest: QuestId,
3732    },
3733}
3734
3735/// A quest objective.
3736///
3737/// Every variant may carry `requires_flags`:
3738/// flag-gated activation, satisfied only once each referenced flag has been set
3739/// by a `set-flag` effect.
3740#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3741#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3742pub enum Objective {
3743    /// Completed by a dialogue option's `complete-objective` effect.
3744    TalkTo {
3745        /// Objective id.
3746        id: ObjectiveId,
3747        /// Short player-facing objective name (v0.3, optional).
3748        #[serde(default, skip_serializing_if = "Option::is_none")]
3749        title: Option<String>,
3750        /// One-line location/direction hint (v0.3, optional).
3751        #[serde(default, skip_serializing_if = "Option::is_none")]
3752        hint: Option<String>,
3753        /// The NPC to talk to.
3754        npc: NpcId,
3755        /// Prerequisite objectives (intra-quest ordering).
3756        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3757        after: Vec<ObjectiveId>,
3758        /// Flags that must be set before this objective activates (v0.3).
3759        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3760        requires_flags: Vec<FlagId>,
3761        /// Negative flag gate (DSL v0.6): the
3762        /// objective is suppressed (cannot activate or complete) while ANY listed
3763        /// flag is set for the player — the dual of `requires_flags`.
3764        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3765        forbids_flags: Vec<FlagId>,
3766        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3767        /// must hold for this gate to be open. The third field of the one gate,
3768        /// carried by every gate consumer — never by the verb that first wanted
3769        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3770        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3771        requires_state: Vec<StateCompare>,
3772        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3773        /// bot should traverse sneaking (sprint disabled). Emitted into
3774        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3775        /// hint; no datapack effect.
3776        #[serde(default, skip_serializing_if = "is_false")]
3777        stealth: bool,
3778        /// What this objective does to the story (DSL v0.8, spec-0025; required
3779        /// at 0.8.0, `DW0481`).
3780        #[serde(default, skip_serializing_if = "Option::is_none")]
3781        happening: Option<Happening>,
3782    },
3783    /// Completed by reaching an anchor once prerequisites are met.
3784    ReachAnchor {
3785        /// Objective id.
3786        id: ObjectiveId,
3787        /// Short player-facing objective name (v0.3, optional).
3788        #[serde(default, skip_serializing_if = "Option::is_none")]
3789        title: Option<String>,
3790        /// One-line location/direction hint (v0.3, optional).
3791        #[serde(default, skip_serializing_if = "Option::is_none")]
3792        hint: Option<String>,
3793        /// The anchor to reach.
3794        anchor: AnchorId,
3795        /// Completion radius (blocks).
3796        radius: u32,
3797        /// Prerequisite objectives (intra-quest ordering).
3798        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3799        after: Vec<ObjectiveId>,
3800        /// Flags that must be set before this objective activates (v0.3).
3801        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3802        requires_flags: Vec<FlagId>,
3803        /// Negative flag gate (DSL v0.6): the
3804        /// objective is suppressed (cannot activate or complete) while ANY listed
3805        /// flag is set for the player — the dual of `requires_flags`.
3806        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3807        forbids_flags: Vec<FlagId>,
3808        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3809        /// must hold for this gate to be open. The third field of the one gate,
3810        /// carried by every gate consumer — never by the verb that first wanted
3811        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3812        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3813        requires_state: Vec<StateCompare>,
3814        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3815        /// bot should traverse sneaking (sprint disabled). Emitted into
3816        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3817        /// hint; no datapack effect.
3818        #[serde(default, skip_serializing_if = "is_false")]
3819        stealth: bool,
3820        /// What this objective does to the story (DSL v0.8, spec-0025; required
3821        /// at 0.8.0, `DW0481`).
3822        #[serde(default, skip_serializing_if = "Option::is_none")]
3823        happening: Option<Happening>,
3824    },
3825    /// Completed when the referenced wave is fully slain (v0.3).
3826    Kill {
3827        /// Objective id.
3828        id: ObjectiveId,
3829        /// Short player-facing objective name (v0.3, optional).
3830        #[serde(default, skip_serializing_if = "Option::is_none")]
3831        title: Option<String>,
3832        /// One-line location/direction hint (v0.3, optional).
3833        #[serde(default, skip_serializing_if = "Option::is_none")]
3834        hint: Option<String>,
3835        /// The wave (stage-5 `waves` ref) whose mobs must be slain.
3836        wave: WaveId,
3837        /// Prerequisite objectives.
3838        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3839        after: Vec<ObjectiveId>,
3840        /// Flags that must be set before this objective activates (v0.3).
3841        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3842        requires_flags: Vec<FlagId>,
3843        /// Negative flag gate (DSL v0.6): the
3844        /// objective is suppressed (cannot activate or complete) while ANY listed
3845        /// flag is set for the player — the dual of `requires_flags`.
3846        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3847        forbids_flags: Vec<FlagId>,
3848        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3849        /// must hold for this gate to be open. The third field of the one gate,
3850        /// carried by every gate consumer — never by the verb that first wanted
3851        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3852        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3853        requires_state: Vec<StateCompare>,
3854        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3855        /// bot should traverse sneaking (sprint disabled). Emitted into
3856        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3857        /// hint; no datapack effect.
3858        #[serde(default, skip_serializing_if = "is_false")]
3859        stealth: bool,
3860        /// What this objective does to the story (DSL v0.8, spec-0025; required
3861        /// at 0.8.0, `DW0481`).
3862        #[serde(default, skip_serializing_if = "Option::is_none")]
3863        happening: Option<Happening>,
3864    },
3865    /// Completed when `count` of `item` have been collected (v0.3).
3866    ///
3867    /// The items are provided in a container: the compiler's own chest at
3868    /// `anchor` by default, or — since DSL v0.8 — the prefab's existing
3869    /// chest/barrel at [`Objective::Collect::container`], optionally carrying an
3870    /// [`Objective::Collect::item_name`] and padded to read full with
3871    /// [`Objective::Collect::fill_count`].
3872    Collect {
3873        /// Objective id.
3874        id: ObjectiveId,
3875        /// Short player-facing objective name (v0.3, optional).
3876        #[serde(default, skip_serializing_if = "Option::is_none")]
3877        title: Option<String>,
3878        /// One-line location/direction hint (v0.3, optional).
3879        #[serde(default, skip_serializing_if = "Option::is_none")]
3880        hint: Option<String>,
3881        /// Vanilla item id to collect (validated against the registry).
3882        item: String,
3883        /// How many are required.
3884        count: u32,
3885        /// The anchor items are provided at (chest / pickup).
3886        anchor: AnchorId,
3887        /// **Adopt the container the prefab already placed**: the anchor whose
3888        /// assembled-world cell holds
3889        /// a `chest` / `trapped_chest` / `barrel` this collect fills instead of
3890        /// conjuring its own chest at [`Objective::Collect::anchor`].
3891        ///
3892        /// Same division of labour a `loot` entry and a trap's dispenser already
3893        /// keep with the prefab: furniture belongs in the piece. A beach camp's
3894        /// barrel is scenery the player has been walking past since minute one —
3895        /// having the compiler `setblock` a *second*, floating chest beside it to
3896        /// hold the quest item is exactly the downstream workaround the no-hack
3897        /// rule forbids. A `container` whose cell holds no container is a build
3898        /// error (`DW0438`), never a silent fill into a wall.
3899        ///
3900        /// The critical-path step's position follows the container (the bot opens
3901        /// *this* block), and no chest is placed at `anchor` when it is set.
3902        #[serde(default, skip_serializing_if = "Option::is_none")]
3903        container: Option<AnchorId>,
3904        /// **The item comes off a body, not out of a box**: the wave whose
3905        /// declared `drops[]` yield
3906        /// this objective's item. No container is placed — not the compiler's own
3907        /// chest at `anchor`, not a prefab one — and `container` is therefore
3908        /// mutually exclusive with it (`DW0100`-adjacent; `DW0492`).
3909        ///
3910        /// This is what makes "kill the boss → pick up its key → open the door"
3911        /// a *proved* chain rather than an authoring intention. The compiler
3912        /// requires (a) that the named wave really declares an `{item}` drop of
3913        /// this item (`DW0492`), and (b) that a `kill` objective for that wave
3914        /// precedes this collect in the objective graph (`DW0493`). The existing
3915        /// flow machinery then carries the ordering the rest of the way: the
3916        /// door's `requires_flags` hangs off this collect exactly as it would off
3917        /// a chest one.
3918        ///
3919        /// **Waves only.** An actor's death is not observable by any objective —
3920        /// there is no vanilla-side signal the flow machinery could consume — so
3921        /// an actor-gated collect would be an unprovable claim, and per the
3922        /// no-hack doctrine it is excluded rather than approximated. An actor may
3923        /// still declare `drops[]`; those drops just cannot gate a quest.
3924        #[serde(default, skip_serializing_if = "Option::is_none")]
3925        dropped_by: Option<WaveId>,
3926        /// Display name for the collected item (DSL v0.8), emitted as the vanilla `custom_name` item component.
3927        ///
3928        /// A quest item is a *named thing* in the story ("Cheese", "Tide
3929        /// Ledger"), and a player who opens the barrel must read that name — an
3930        /// unnamed `minecraft:pumpkin_pie` says nothing about what the quest asked
3931        /// for. Player-visible, so it enters the l10n string inventory
3932        /// (`obj.<quest>.<obj>.item_name`) and translates like any other line.
3933        ///
3934        /// Naming changes nothing about adjudication: the completion advancement
3935        /// and the per-tick held check both match on the ITEM ID, which a named
3936        /// stack still carries.
3937        #[serde(default, skip_serializing_if = "Option::is_none")]
3938        item_name: Option<String>,
3939        /// Padding stacks that make the container **read full**. Default `0` =
3940        /// the single required
3941        /// stack and nothing else.
3942        ///
3943        /// A barrel of cheese that opens on one lonely wheel reads as a bug, and
3944        /// vanilla's notion of "full" is *occupied slots*, not stack size — so
3945        /// this counts SLOTS: the objective's own stack lands in `container.0` and
3946        /// each padding stack repeats it in `container.1`, `container.2`, … Slot
3947        /// assignment is positional and total, the same determinism story `loot`
3948        /// tells (ADR-0006): no RNG, no loot tables, nothing to reseed.
3949        ///
3950        /// The padding is the same item, so taking the whole barrel still
3951        /// completes the objective and never over- or under-counts it.
3952        #[serde(default, skip_serializing_if = "is_zero")]
3953        fill_count: u32,
3954        /// Prerequisite objectives.
3955        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3956        after: Vec<ObjectiveId>,
3957        /// Flags that must be set before this objective activates (v0.3).
3958        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3959        requires_flags: Vec<FlagId>,
3960        /// Negative flag gate (DSL v0.6): the
3961        /// objective is suppressed (cannot activate or complete) while ANY listed
3962        /// flag is set for the player — the dual of `requires_flags`.
3963        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3964        forbids_flags: Vec<FlagId>,
3965        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3966        /// must hold for this gate to be open. The third field of the one gate,
3967        /// carried by every gate consumer — never by the verb that first wanted
3968        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3969        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3970        requires_state: Vec<StateCompare>,
3971        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3972        /// bot should traverse sneaking (sprint disabled). Emitted into
3973        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3974        /// hint; no datapack effect.
3975        #[serde(default, skip_serializing_if = "is_false")]
3976        stealth: bool,
3977        /// What this objective does to the story (DSL v0.8, spec-0025; required
3978        /// at 0.8.0, `DW0481`).
3979        #[serde(default, skip_serializing_if = "Option::is_none")]
3980        happening: Option<Happening>,
3981    },
3982    /// Completed by interacting with an entity at `anchor`; if `requires_item` is
3983    /// set, the item must be **held in the main hand** (v0.3; held semantics since
3984    /// DSL v0.7 — see [`Objective::Interact::requires_item`]).
3985    Interact {
3986        /// Objective id.
3987        id: ObjectiveId,
3988        /// Short player-facing objective name (v0.3, optional).
3989        #[serde(default, skip_serializing_if = "Option::is_none")]
3990        title: Option<String>,
3991        /// One-line location/direction hint (v0.3, optional).
3992        #[serde(default, skip_serializing_if = "Option::is_none")]
3993        hint: Option<String>,
3994        /// The anchor the interaction entity stands at.
3995        anchor: AnchorId,
3996        /// Item the player must be **holding in the main hand** for the
3997        /// interaction to complete (optional).
3998        ///
3999        /// Held, not merely possessed: presenting the
4000        /// item IS the action — a player who right-clicks a sleeping giant with a
4001        /// sharpened stake buried in their backpack has not stabbed anything.
4002        /// Before this ruling the gate read the whole inventory, which made every
4003        /// `requires_item` interaction fire the moment the item was picked up
4004        /// anywhere, whatever the player was actually doing with their hands.
4005        #[serde(default, skip_serializing_if = "Option::is_none")]
4006        requires_item: Option<String>,
4007        /// Diegetic feedback for a click that arrives without the required item in
4008        /// hand (DSL v0.7): narrated to that player in
4009        /// chat instead of the silence the gate used to answer with. Requires
4010        /// `requires_item` (`DW0437`).
4011        ///
4012        /// Only fires while the objective is genuinely open — same activation gate
4013        /// as the affordance itself — so a finished or not-yet-active interaction
4014        /// stays quiet.
4015        #[serde(default, skip_serializing_if = "Option::is_none")]
4016        missing_item_hint: Option<String>,
4017        /// Prop block that IS the interaction affordance (DSL v0.4, spec-0008
4018        /// §2): the compiler `setblock`s it at the anchor on activation (exactly
4019        /// as `collect` uses a real chest). Omitted = the glowing-lantern
4020        /// hologram marker (the v0.3 fallback).
4021        #[serde(default, skip_serializing_if = "Option::is_none")]
4022        prop: Option<Prop>,
4023        /// Prerequisite objectives.
4024        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4025        after: Vec<ObjectiveId>,
4026        /// Flags that must be set before this objective activates (v0.3).
4027        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4028        requires_flags: Vec<FlagId>,
4029        /// Negative flag gate (DSL v0.6): the
4030        /// objective is suppressed (cannot activate or complete) while ANY listed
4031        /// flag is set for the player — the dual of `requires_flags`.
4032        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4033        forbids_flags: Vec<FlagId>,
4034        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4035        /// must hold for this gate to be open. The third field of the one gate,
4036        /// carried by every gate consumer — never by the verb that first wanted
4037        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4038        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4039        requires_state: Vec<StateCompare>,
4040        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4041        /// bot should traverse sneaking (sprint disabled). Emitted into
4042        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4043        /// hint; no datapack effect.
4044        #[serde(default, skip_serializing_if = "is_false")]
4045        stealth: bool,
4046        /// What this objective does to the story (DSL v0.8, spec-0025; required
4047        /// at 0.8.0, `DW0481`).
4048        #[serde(default, skip_serializing_if = "Option::is_none")]
4049        happening: Option<Happening>,
4050    },
4051}
4052
4053/// A prop block for an `interact` objective (DSL v0.4). The block is the
4054/// affordance the player interacts with; its id is validated against the pinned
4055/// 1.21.11 block registry (`DW0193`).
4056#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4057#[serde(deny_unknown_fields)]
4058pub struct Prop {
4059    /// Vanilla block id (e.g. `minecraft:lever`).
4060    pub block: String,
4061}
4062
4063// ---------------------------------------------------------------------------
4064// Stage 5 — scripted actors (DSL v0.6, spec-0014)
4065// ---------------------------------------------------------------------------
4066
4067/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
4068/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
4069/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
4070/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
4071/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
4072/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
4073/// by a real-AI twin with `unleash-actor`.
4074#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4075#[serde(deny_unknown_fields)]
4076pub struct Actor {
4077    /// Unique actor id (`actor/<kebab>`).
4078    pub id: ActorId,
4079    /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
4080    /// pinned 1.21.11 entity registry (`DW0173`).
4081    pub entity: String,
4082    /// Optional custom name shown above the puppet.
4083    #[serde(default, skip_serializing_if = "Option::is_none")]
4084    pub name: Option<String>,
4085    /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
4086    #[serde(default, skip_serializing_if = "Option::is_none")]
4087    pub skin: Option<NpcSkin>,
4088    /// The anchor the puppet is summoned on (resolved across areas, like an
4089    /// `open-gate` / `move-npc` destination).
4090    pub anchor: AnchorId,
4091    /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
4092    /// `[0, 0, 0]`): the puppet stands at the [`Mark`] the two fields spell, so
4093    /// a rank of bodies is one anchor and an offset apiece.
4094    #[serde(default, skip_serializing_if = "is_zero3")]
4095    pub offset: [i32; 3],
4096    /// Initial facing (default `south`). The puppet spawns yawed this way.
4097    #[serde(default, skip_serializing_if = "Option::is_none")]
4098    pub facing: Option<Facing>,
4099    /// If `true`, the puppet is damageable (a tower-defense creep) but stays
4100    /// knockback-immune; default `false` (fully `Invulnerable`).
4101    #[serde(default, skip_serializing_if = "is_false")]
4102    pub vulnerable: bool,
4103    /// Gear the actor wears and holds, in the same shape a wave mob uses
4104    /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
4105    /// unleashed twin, so the dormant elite the player has been circling is
4106    /// visibly the same armoured thing that stands up. Drop chances are zero —
4107    /// wave gear and actor gear are never farmable (no-grind constitution).
4108    #[serde(default, skip_serializing_if = "Option::is_none")]
4109    pub equipment: Option<MobEquipment>,
4110    /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
4111    /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
4112    /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
4113    /// elite the party fights is the elite the author tuned; without it an actor
4114    /// was stuck at vanilla base values while every wave mob could be tuned,
4115    /// which is what blocked elite authoring. A `vulnerable` actor's
4116    /// knockback-immunity is emitted first and is not authorable.
4117    #[serde(default, skip_serializing_if = "Option::is_none")]
4118    pub attributes: Option<MobAttributes>,
4119    /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
4120    /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
4121    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
4122    ///
4123    /// A wave is not the only shape an elite takes. The set-piece souls fight —
4124    /// the armoured thing kneeling among the graves that stands up when you hit
4125    /// it — is an **actor**: staged by `spawn-actor`, given AI by
4126    /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
4127    /// this field the validation ladder's inverted floor gate could only see
4128    /// `waves[].tier`, so such a boss was *structurally invisible* to it and an
4129    /// empty finding list read as a pass while covering nothing.
4130    ///
4131    /// Like the wave field this is a **declaration, not a knob**: the compiler
4132    /// never scales an actor from it, and emission is unchanged whichever tier is
4133    /// declared. What it buys is scrutiny — the actor enters
4134    /// `validation/combat-plan.json`, and the compiler states, per tiered actor,
4135    /// whether the floor gate can measure it and why not when it cannot
4136    /// (`DW0477`).
4137    #[serde(default, skip_serializing_if = "Option::is_none")]
4138    pub tier: Option<EncounterTier>,
4139    /// What this actor leaves behind when a player kills it. Only an
4140    /// `elite`/`boss` actor may
4141    /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
4142    /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
4143    /// not to one of its two lifecycles. A `despawn-actor` strips the
4144    /// declaration off the body before removing it, so re-caging an elite (a
4145    /// souls re-seat) never scatters its axe.
4146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4147    pub drops: Vec<MobDrop>,
4148    /// What this body can do when it moves (DSL v0.11, spec-0034) — the same
4149    /// [`BodyTraversal`] a stage-2 [`Npc`] carries, because traversal belongs to
4150    /// the body and not to the stage that declares it. Absent = the class the
4151    /// compiler derives from `entity` (or from `minecraft:mannequin` when `skin`
4152    /// is set).
4153    #[serde(default, skip_serializing_if = "Option::is_none")]
4154    pub traversal: Option<BodyTraversal>,
4155}
4156
4157/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
4158/// (MC: yaw 0 = +z/south).
4159#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4160#[serde(rename_all = "kebab-case")]
4161pub enum Facing {
4162    /// Facing +z (yaw 0) — the default.
4163    South,
4164    /// Facing -z (yaw 180).
4165    North,
4166    /// Facing -x (yaw 90).
4167    West,
4168    /// Facing +x (yaw 270).
4169    East,
4170}
4171
4172impl Facing {
4173    /// The kebab token (`south` / `north` / `west` / `east`).
4174    pub fn token(self) -> &'static str {
4175        match self {
4176            Facing::South => "south",
4177            Facing::North => "north",
4178            Facing::West => "west",
4179            Facing::East => "east",
4180        }
4181    }
4182}
4183
4184/// How a `despawn-actor` removes its puppet (DSL v0.6).
4185#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4186#[serde(rename_all = "kebab-case")]
4187pub enum DespawnStyle {
4188    /// Silent removal (`kill @e` with no death animation is not possible; the
4189    /// compiler removes the entity via `/kill` on an `Invulnerable` puppet, or a
4190    /// data-driven removal — see the emitter — so no death particles/sound show).
4191    Vanish,
4192    /// Plays the vanilla death animation (a cutscene death).
4193    Kill,
4194}
4195
4196impl DespawnStyle {
4197    /// The kebab token (`vanish` / `kill`).
4198    pub fn token(self) -> &'static str {
4199        match self {
4200            DespawnStyle::Vanish => "vanish",
4201            DespawnStyle::Kill => "kill",
4202        }
4203    }
4204}
4205
4206/// One step of a [`Verb::Sequence`] (DSL v0.6): a group of effects fired at
4207/// an exact tick offset from the sequence's start.
4208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4209#[serde(deny_unknown_fields)]
4210pub struct SequenceStep {
4211    /// Tick offset from the sequence start at which `effects` fire.
4212    pub at_ticks: u32,
4213    /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
4214    /// `sequence` (rejected with `DW0329`).
4215    pub effects: Vec<QuestEffect>,
4216}
4217
4218impl Objective {
4219    /// This objective's id.
4220    pub fn id(&self) -> &ObjectiveId {
4221        match self {
4222            Objective::TalkTo { id, .. }
4223            | Objective::ReachAnchor { id, .. }
4224            | Objective::Kill { id, .. }
4225            | Objective::Collect { id, .. }
4226            | Objective::Interact { id, .. } => id,
4227        }
4228    }
4229
4230    /// This objective's prerequisites.
4231    pub fn after(&self) -> &[ObjectiveId] {
4232        match self {
4233            Objective::TalkTo { after, .. }
4234            | Objective::ReachAnchor { after, .. }
4235            | Objective::Kill { after, .. }
4236            | Objective::Collect { after, .. }
4237            | Objective::Interact { after, .. } => after,
4238        }
4239    }
4240
4241    /// The short player-facing objective title (v0.3, optional).
4242    pub fn title(&self) -> Option<&str> {
4243        match self {
4244            Objective::TalkTo { title, .. }
4245            | Objective::ReachAnchor { title, .. }
4246            | Objective::Kill { title, .. }
4247            | Objective::Collect { title, .. }
4248            | Objective::Interact { title, .. } => title.as_deref(),
4249        }
4250    }
4251
4252    /// The one-line location/direction hint (v0.3, optional).
4253    pub fn hint(&self) -> Option<&str> {
4254        match self {
4255            Objective::TalkTo { hint, .. }
4256            | Objective::ReachAnchor { hint, .. }
4257            | Objective::Kill { hint, .. }
4258            | Objective::Collect { hint, .. }
4259            | Objective::Interact { hint, .. } => hint.as_deref(),
4260        }
4261    }
4262
4263    /// Mutable access to the optional player-facing title (i18n localization).
4264    pub fn title_mut(&mut self) -> &mut Option<String> {
4265        match self {
4266            Objective::TalkTo { title, .. }
4267            | Objective::ReachAnchor { title, .. }
4268            | Objective::Kill { title, .. }
4269            | Objective::Collect { title, .. }
4270            | Objective::Interact { title, .. } => title,
4271        }
4272    }
4273
4274    /// Mutable access to the optional one-line hint (i18n localization).
4275    pub fn hint_mut(&mut self) -> &mut Option<String> {
4276        match self {
4277            Objective::TalkTo { hint, .. }
4278            | Objective::ReachAnchor { hint, .. }
4279            | Objective::Kill { hint, .. }
4280            | Objective::Collect { hint, .. }
4281            | Objective::Interact { hint, .. } => hint,
4282        }
4283    }
4284
4285    /// The flags that must be set before this objective activates (v0.3).
4286    pub fn requires_flags(&self) -> &[FlagId] {
4287        match self {
4288            Objective::TalkTo { requires_flags, .. }
4289            | Objective::ReachAnchor { requires_flags, .. }
4290            | Objective::Kill { requires_flags, .. }
4291            | Objective::Collect { requires_flags, .. }
4292            | Objective::Interact { requires_flags, .. } => requires_flags,
4293        }
4294    }
4295
4296    /// The negative flag gate (DSL v0.6): flags whose being set **suppresses**
4297    /// this objective. The dual of [`Objective::requires_flags`].
4298    pub fn forbids_flags(&self) -> &[FlagId] {
4299        match self {
4300            Objective::TalkTo { forbids_flags, .. }
4301            | Objective::ReachAnchor { forbids_flags, .. }
4302            | Objective::Kill { forbids_flags, .. }
4303            | Objective::Collect { forbids_flags, .. }
4304            | Objective::Interact { forbids_flags, .. } => forbids_flags,
4305        }
4306    }
4307
4308    /// The numeric gate terms (DSL v0.10, spec-0031): comparisons that must hold
4309    /// before this objective activates. See [`StateCompare`].
4310    pub fn requires_state(&self) -> &[StateCompare] {
4311        match self {
4312            Objective::TalkTo { requires_state, .. }
4313            | Objective::ReachAnchor { requires_state, .. }
4314            | Objective::Kill { requires_state, .. }
4315            | Objective::Collect { requires_state, .. }
4316            | Objective::Interact { requires_state, .. } => requires_state,
4317        }
4318    }
4319
4320    /// What this objective does to the story (DSL v0.8, spec-0025).
4321    pub fn happening(&self) -> Option<&Happening> {
4322        match self {
4323            Objective::TalkTo { happening, .. }
4324            | Objective::ReachAnchor { happening, .. }
4325            | Objective::Kill { happening, .. }
4326            | Objective::Collect { happening, .. }
4327            | Objective::Interact { happening, .. } => happening.as_ref(),
4328        }
4329    }
4330
4331    /// The bot stealth hint (DSL v0.4): traverse this leg sneaking.
4332    pub fn stealth(&self) -> bool {
4333        match self {
4334            Objective::TalkTo { stealth, .. }
4335            | Objective::ReachAnchor { stealth, .. }
4336            | Objective::Kill { stealth, .. }
4337            | Objective::Collect { stealth, .. }
4338            | Objective::Interact { stealth, .. } => *stealth,
4339        }
4340    }
4341
4342    /// The container this objective ADOPTS (DSL v0.8), if it is a `collect` that
4343    /// declares one: the anchor whose prefab-placed chest/barrel it fills instead
4344    /// of conjuring its own chest. `None` on every other objective and on a
4345    /// `collect` that keeps the compiler-placed chest.
4346    pub fn collect_container(&self) -> Option<&AnchorId> {
4347        match self {
4348            Objective::Collect { container, .. } => container.as_ref(),
4349            _ => None,
4350        }
4351    }
4352
4353    /// The wave whose declared drops provide this objective's item (DSL v0.9),
4354    /// if it is a `collect` that declares one. `None` on every other objective
4355    /// and on a `collect` fed by a container.
4356    pub fn collect_dropped_by(&self) -> Option<&WaveId> {
4357        match self {
4358            Objective::Collect { dropped_by, .. } => dropped_by.as_ref(),
4359            _ => None,
4360        }
4361    }
4362
4363    /// The padding-stack count of a `collect` (DSL v0.8); `0` for every other
4364    /// objective and for a `collect` that fills the single required stack only.
4365    pub fn collect_fill_count(&self) -> u32 {
4366        match self {
4367            Objective::Collect { fill_count, .. } => *fill_count,
4368            _ => 0,
4369        }
4370    }
4371
4372    /// The `interact` prop block (DSL v0.4), if this is an `interact` with a prop.
4373    pub fn prop(&self) -> Option<&Prop> {
4374        match self {
4375            Objective::Interact { prop, .. } => prop.as_ref(),
4376            _ => None,
4377        }
4378    }
4379
4380    /// The kebab type tag.
4381    pub fn kind(&self) -> &'static str {
4382        match self {
4383            Objective::TalkTo { .. } => "talk-to",
4384            Objective::ReachAnchor { .. } => "reach-anchor",
4385            Objective::Kill { .. } => "kill",
4386            Objective::Collect { .. } => "collect",
4387            Objective::Interact { .. } => "interact",
4388        }
4389    }
4390
4391    /// The v0.3 verb name if this objective is one of the verbs introduced in
4392    /// DSL v0.3 (`kill`/`collect`/`interact`). These validate in v0.3 campaigns
4393    ///.
4394    pub fn v03_verb(&self) -> Option<&'static str> {
4395        match self {
4396            Objective::Kill { .. } => Some("kill"),
4397            Objective::Collect { .. } => Some("collect"),
4398            Objective::Interact { .. } => Some("interact"),
4399            Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => None,
4400        }
4401    }
4402}
4403
4404/// The condition under which an effect fires, as one object.
4405///
4406/// The three axes of [`crate::gate::Gate`] in their declared form: the flags that
4407/// must be set, the flags that must not be, and the numeric comparisons that must
4408/// hold. Declared once and carried by [`QuestEffect::when`], so every verb is
4409/// gatable on exactly the same terms and a fourth axis is one field here.
4410#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
4411#[serde(deny_unknown_fields)]
4412pub struct Guard {
4413    /// Flags that must ALL be set (per party) for the effect to fire. Emission
4414    /// wraps the effect's commands in `execute if score #party dw.f_<flag> matches 1`.
4415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4416    pub requires_flags: Vec<FlagId>,
4417    /// Flags whose being set SUPPRESSES the effect — the dual of `requires_flags`,
4418    /// emitted as `execute unless score #party dw.f_<flag> matches 1`.
4419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4420    pub forbids_flags: Vec<FlagId>,
4421    /// Numeric comparisons that must ALL hold (spec-0031), emitted as
4422    /// `execute if score <holder> dw.s_<state> matches <range>`.
4423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4424    pub requires_state: Vec<StateCompare>,
4425}
4426
4427/// An effect fired by quest progress: **one guard, one story note, one verb.**
4428///
4429/// The guard is a property of an effect, not of the verb that first wanted one, so
4430/// it is declared once in [`Guard`] and every verb carries it — including the
4431/// staging and souls vocabulary (`spawn-actor`, `move-actor`, `set-checkpoint`,
4432/// `bonfire`, `begin-stealth`, `sequence`, …) that could not be branch-gated while
4433/// the fields lived on the variants. A gate that can never open on a
4434/// `campaign-complete` is caught where it belongs, by the completability proof.
4435///
4436/// `verb` is `#[serde(flatten)]`, so the JSON is unchanged in shape apart from the
4437/// guard moving under `when`: `{"type": "open-gate", "anchor": "…", "when":
4438/// {"requires_flags": ["…"]}}`. [`Verb`] keeps `deny_unknown_fields`, which is what
4439/// the flattened deserializer applies to everything the outer struct did not claim
4440/// — an author's typo is still `DW0100`.
4441#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4442pub struct QuestEffect {
4443    /// When this effect fires. `None` is the always-open gate.
4444    #[serde(default, skip_serializing_if = "Option::is_none")]
4445    pub when: Option<Guard>,
4446    /// What this beat does to the story (spec-0025) — validation metadata with no
4447    /// emission of its own.
4448    #[serde(default, skip_serializing_if = "Option::is_none")]
4449    pub happening: Option<Happening>,
4450    /// What the effect does.
4451    #[serde(flatten)]
4452    pub verb: Verb,
4453}
4454
4455impl From<Verb> for QuestEffect {
4456    /// An unguarded effect with no story note — the shape a compiler-synthesized
4457    /// beat and most tests want.
4458    fn from(verb: Verb) -> Self {
4459        QuestEffect {
4460            when: None,
4461            happening: None,
4462            verb,
4463        }
4464    }
4465}
4466
4467/// What an effect does, without the guard: the closed set of verbs.
4468#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4469#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
4470pub enum Verb {
4471    /// Opens a prefab-declared gate (one-way).
4472    OpenGate {
4473        /// The gate anchor to open.
4474        anchor: AnchorId,
4475    },
4476    /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
4477    /// fills the gate anchor's region with the block the anchor declares (e.g. the
4478    /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
4479    /// The declared fill block is prefab metadata; a gate anchor with no `block` is
4480    /// rejected (`DW0343`). The completability model treats the region as **solid**
4481    /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
4482    /// clearing is modelled) — a critical path that must cross a gate after it seals
4483    /// fails the DW0311 reachability proof.
4484    CloseGate {
4485        /// The gate anchor to seal.
4486        anchor: AnchorId,
4487        /// 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
4488        /// and press: the compiler answers that press on the actionbar. Absent, the
4489        /// compiler's canonical English is baked in (`The way is sealed.`) exactly
4490        /// as `world.boundary.message` does; authored, the line is l10n-inventoried
4491        /// under `<effect-key>.sealed_hint` and translates like every other
4492        /// player-visible string.
4493        ///
4494        /// Unlike `happening`, this **does** print in the hand-written `Debug`
4495        /// below when present — it changes emission, so two otherwise-identical
4496        /// sequences that differ only in their seal's answer are different content.
4497        #[serde(default, skip_serializing_if = "Option::is_none")]
4498        sealed_hint: Option<String>,
4499    },
4500    /// Marks the campaign complete (final advancement + credits). Terminal — not
4501    /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
4502    /// so this variant carries no `requires_flags`.
4503    CampaignComplete {
4504        /// Which ENDING this is (DSL v0.8, spec-0025).
4505        /// A campaign with more than one `campaign-complete` has more than one
4506        /// ending, and a branch that runs to an ending names it here — so the
4507        /// terminality proof (`DW0482`) can state *which* ending a branch reached
4508        /// instead of merely that something ended. There is no separate `endings`
4509        /// section: the set of endings is exactly the set named here, the same
4510        /// rule flags follow.
4511        #[serde(default, skip_serializing_if = "Option::is_none")]
4512        ending: Option<EndingId>,
4513    },
4514    /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
4515    GiveItem {
4516        /// Vanilla item id to give (validated against the registry).
4517        item: String,
4518        /// How many to give.
4519        count: u32,
4520        /// Optional display name (DSL v0.4), matching [`KitItem::name`].
4521        #[serde(default, skip_serializing_if = "Option::is_none")]
4522        name: Option<String>,
4523        /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
4524        /// party member. `one` hands a single copy to the player whose action fired
4525        /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
4526        /// has no acting player.
4527        #[serde(default, skip_serializing_if = "Option::is_none")]
4528        carrier: Option<Carrier>,
4529    },
4530    /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
4531    SetFlag {
4532        /// The flag to set.
4533        flag: FlagId,
4534    },
4535    /// Writes a declared datum to an absolute value (DSL v0.10, spec-0031).
4536    SetState {
4537        /// The datum to write (stage-5 `state` ref).
4538        state: StateId,
4539        /// The value to write.
4540        value: i32,
4541    },
4542    /// Moves a declared datum by a signed amount (DSL v0.10, spec-0031).
4543    ///
4544    /// **Signed on purpose.** A purse that a shop debits and a stake that a death
4545    /// forfeits are the same operation with the sign flipped; a separate
4546    /// `subtract-state` would be a second verb for one mechanism, and the first
4547    /// campaign to need "add a negative" would have to choose between them.
4548    AddState {
4549        /// The datum to move (stage-5 `state` ref).
4550        state: StateId,
4551        /// How far to move it. Negative counts down.
4552        amount: i32,
4553    },
4554    /// Leaves a declared [`Stake`] behind for the acting player (DSL v0.10,
4555    /// spec-0032): forfeit the declared share of its datum, and place a
4556    /// collectable marker at the compile-time anchor for where they are.
4557    ///
4558    /// **Nothing about this verb says "death".** It is written in `on_death`
4559    /// because that is where a souls-shaped delve wants it, but the mechanism is
4560    /// "leave a recoverable cache where the acting player stands", and the effect
4561    /// carries the ordinary gate so any root may run it. What *is* death-specific
4562    /// — that the corpse stands on the death position, so the placement lookup has
4563    /// a position to key on — is a property of the `on_death` root, not of this
4564    /// verb.
4565    DropStake {
4566        /// The stake (stage-5 `stakes` ref) to leave.
4567        stake: StakeId,
4568    },
4569    /// Returns a declared datum to its declared `initial` (DSL v0.10,
4570    /// spec-0031) — the verb `FlagId` has never had.
4571    ClearState {
4572        /// The datum to clear (stage-5 `state` ref).
4573        state: StateId,
4574    },
4575    /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
4576    SpawnWave {
4577        /// The wave (stage-5 `waves` ref) to spawn.
4578        wave: WaveId,
4579    },
4580    /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
4581    /// l10n key inventory like any player-visible string.
4582    Narrate {
4583        /// The line shown to the player.
4584        text: String,
4585        /// Presentation channel (default `chat`).
4586        #[serde(default, skip_serializing_if = "Option::is_none")]
4587        style: Option<NarrateStyle>,
4588        /// Optional sound id played alongside the line.
4589        #[serde(default, skip_serializing_if = "Option::is_none")]
4590        sound: Option<String>,
4591    },
4592    /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
4593    /// placement. Block id validated against the pinned 1.21.11 block registry;
4594    /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
4595    /// accepted and passed through verbatim (DSL v0.6).
4596    SetBlock {
4597        /// The anchor to place the block at.
4598        anchor: AnchorId,
4599        /// Vanilla block id to place.
4600        block: String,
4601    },
4602    /// **Fill a declared region with a block** at runtime (DSL v0.10, spec-0031).
4603    ///
4604    /// The general spelling of the capability `open-gate` / `close-gate` carried
4605    /// privately: a region, filled or cleared, from a point in the quest DAG.
4606    /// `close-gate` is this verb with the region and the block read off a prefab
4607    /// gate anchor instead of authored; `set-block` is the one-cell case at a
4608    /// point anchor. All three lower through one emission
4609    /// (`emit::fill_region_command`) and are modelled by one completability rule
4610    /// (`plan::RegionEvent`), so a third consumer inherits the proof instead of
4611    /// re-deriving it.
4612    ///
4613    /// From the DAG point at which this fires, the completability model treats the
4614    /// filled cells as whatever the **block** makes them. A full-cube block leaves
4615    /// them **solid**, exactly as a `close-gate` seal is: a critical path that must
4616    /// cross the region afterwards fails `DW0311`. `minecraft:water` /
4617    /// `minecraft:lava` leave them **flooded** — impassable and never floor, because
4618    /// nothing stands on a fluid — and because a fill carries no `replace` filter it
4619    /// takes away whatever floor was in the box, so a forced leg that needed that
4620    /// footing fails `DW0544`.
4621    FillRegion {
4622        /// The volume to fill, as an anchor-centred box (`anchor ± extent`).
4623        ///
4624        /// Deliberately the existing [`StealthZone`] — the engine's one
4625        /// anchor-centred box object class, already shared by `damage-players`'s
4626        /// `in` filter, `collapse`'s `region_anchor`, a `volley` kill zone and a
4627        /// `lethal_volumes[]` region, and resolved through the single
4628        /// `Plan::zone_box`. A private twin with the same two fields would be
4629        /// `tools/ci/check-capability-ownership.py` check C by construction.
4630        ///
4631        /// An anchor-centred box rather than a prefab `region` anchor for the
4632        /// reason `collapse` states: the assembled model deletes every gate-region
4633        /// anchor's cells, so a slab declared that way would already be gone.
4634        region: StealthZone,
4635        /// The block the region is filled with (validated against the pinned
4636        /// 1.21.11 block registry, `DW0193`).
4637        block: String,
4638    },
4639    /// **Clear a declared region to air** at runtime (DSL v0.10, spec-0031) — the
4640    /// physical dual of [`Verb::FillRegion`], and the general spelling of
4641    /// what `open-gate` does to a gate anchor's region.
4642    ///
4643    /// The completability model treats the cleared cells as **passable** from the
4644    /// DAG point at which this fires, with one exception it states out loud: a
4645    /// cleared cell the model already floods stays impassable, because clearing a
4646    /// block does not remove water (`nav::World::with_cleared`).
4647    ClearRegion {
4648        /// The volume to clear, as an anchor-centred box (`anchor ± extent`) —
4649        /// the same object class [`Verb::FillRegion`] fills.
4650        region: StealthZone,
4651    },
4652    /// **Opens a placed piece's contingent way** (DSL v0.12, spec-0042 §2.4): the
4653    /// broken flight a beat repairs, the bridge a beat lowers, the rubble a beat
4654    /// clears.
4655    ///
4656    /// A piece's spatial contract may declare a traversal edge whose crossability
4657    /// depends on a named region — `laid` (empty as built, opening fills it) or
4658    /// `cleared` (built solid, opening voids it). The prefab checker proves the
4659    /// piece is severed as shipped and joined once that region is opened; this is
4660    /// the verb that opens it, and it is the only one, because a way is the object
4661    /// and opening it is the operation.
4662    ///
4663    /// **There is no region, no block and no sign on this effect, and that is the
4664    /// design rather than an omission.** All three are read from the piece's own
4665    /// exported metadata (`spatial_contract.edges[].way`), so the effect and the
4666    /// building cannot disagree about what a way is — two authorities plus an
4667    /// equality check is the defect this shape avoids, not a variant of the fix
4668    /// (spec-0042 AC8). What the campaign decides is *when*.
4669    ///
4670    /// Completability: the way is **shut until this fires**, and from the DAG
4671    /// point at which it fires the region is solid-and-footing (`laid`) or
4672    /// passable (`cleared`) — the same [`Verb::FillRegion`] /
4673    /// [`Verb::ClearRegion`] model, fed from metadata instead of from an
4674    /// authored box, so this verb inherits the forced-footing rule (`DW0546`)
4675    /// rather than restating it. Required content standing beyond a way that no
4676    /// forced opening precedes is `DW0548`, which names the way, the effect and
4677    /// the element.
4678    OpenWay {
4679        /// The placed piece whose way this opens (`prefab/<name>`).
4680        ///
4681        /// A piece, not an anchor: the way's cells are the contract's, not a gate
4682        /// anchor's, and a piece placed twice has two ways. The reference must
4683        /// name exactly one placement; naming none or several is `DW0547`.
4684        piece: PrefabId,
4685        /// The way's region name, as the piece's contract exports it
4686        /// (`spatial_contract.edges[].way.region`).
4687        way: String,
4688    },
4689    /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5).
4690    DespawnNpc {
4691        /// The NPC (stage-2 ref) to remove.
4692        npc: NpcId,
4693    },
4694    /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
4695    /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
4696    /// path** by A* over the solved voxel grid and emits per-tick teleport
4697    /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
4698    /// a solid affordance. An unroutable move is a compile error (`DW0307`).
4699    MoveNpc {
4700        /// The NPC (stage-2 ref) to move.
4701        npc: NpcId,
4702        /// The destination mark: an anchor and an optional offset (spec-0066).
4703        to: Mark,
4704        /// Optional travel speed in blocks/tick (defaults to ~0.15).
4705        #[serde(default, skip_serializing_if = "Option::is_none")]
4706        speed: Option<f64>,
4707        /// Effects fired once the NPC arrives at the destination cell — exact
4708        /// parity with [`Verb::MoveActor`]
4709        /// `on_arrive`: same arrival detection (the walk driver's final tick), same
4710        /// execution context, and every deep effect walker recurses into it via
4711        /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
4712        /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
4713        /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
4714        /// mark).
4715        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4716        on_arrive: Vec<QuestEffect>,
4717    },
4718    /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
4719    /// gamemode+position, spectator, then dolly two co-located cameras along a
4720    /// straight-line lerp between waypoints and alternate `spectate` between them
4721    /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
4722    /// no-op and is never emitted), and restore on completion. The compiler
4723    /// validates the dolly path passes only through non-solid blocks — cameras
4724    /// fly but must not clip a solid (`DW0308`).
4725    ///
4726    /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
4727    /// emission to face that world point from its own position, so the shot keeps
4728    /// its subject framed for the whole move; without it, the camera faces along
4729    /// the direction of travel (the segment it is currently traversing).
4730    ///
4731    /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
4732    /// inside one save/restore bracket (hard cut between shots). Two accepted,
4733    /// mutually exclusive spellings, both normalized by
4734    /// [`QuestEffect::cutscene_shots`]:
4735    /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
4736    /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
4737    ///   exactly equivalent to a one-entry `shots` list.
4738    ///
4739    /// Mixing or omitting both is `DW0199`.
4740    Cutscene {
4741        /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
4742        /// with the single-shot `path`/`seconds` fields (`DW0199`).
4743        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4744        shots: Vec<CameraShot>,
4745        /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
4746        /// lerp between them).
4747        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4748        path: Vec<Mark>,
4749        /// Single-shot form (DSL v0.4): shot duration in seconds.
4750        #[serde(default, skip_serializing_if = "Option::is_none")]
4751        seconds: Option<u32>,
4752        /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
4753        /// Absent = face along the direction of travel.
4754        #[serde(default, skip_serializing_if = "Option::is_none")]
4755        look_at: Option<Mark>,
4756    },
4757    /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
4758    /// Instantaneous (vanilla has no gradual transition); the state persists
4759    /// because the daylight cycle is frozen by sealing.
4760    SetTime {
4761        /// The time state to cut to.
4762        time: WorldTime,
4763    },
4764    /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
4765    /// Instantaneous; persists because the weather cycle is frozen by sealing.
4766    SetWeather {
4767        /// The weather state to cut to.
4768        weather: WorldWeather,
4769    },
4770    /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
4771    /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
4772    /// sound-event registry (`DW0326` unknown). `at` selects where the sound
4773    /// originates (default: each player's own position); `volume`/`pitch` map to
4774    /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
4775    /// vanilla).
4776    PlaySound {
4777        /// The vanilla sound-event id (`minecraft:` prefix optional).
4778        sound: String,
4779        /// Where the sound plays from (default: `players`).
4780        #[serde(default, skip_serializing_if = "Option::is_none")]
4781        at: Option<SoundAt>,
4782        /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
4783        #[serde(default, skip_serializing_if = "Option::is_none")]
4784        volume: Option<f64>,
4785        /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
4786        #[serde(default, skip_serializing_if = "Option::is_none")]
4787        pitch: Option<f64>,
4788    },
4789    /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
4790    /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
4791    /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
4792    /// acting player: at top level it damages every player once; inside a stealth
4793    /// `on_caught` it damages the caught player (the "caught → death → respawn at
4794    /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
4795    /// is lethal through golden apples / absorption. `within` (JSON `in`) narrows to
4796    /// acting players standing inside an anchor-centred box (the same box model as a
4797    /// stealth zone), keeping the per-`@s` semantics. `damage_type` is the damage
4798    /// type — a curated set of vanilla types that all respect `keepInventory` and do
4799    /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
4800    /// (The field is `damage_type`, not `type`, because the effect enum is
4801    /// internally tagged on `type`.)
4802    DamagePlayers {
4803        /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
4804        amount: u32,
4805        /// Optional spatial filter: only damage an acting player inside this
4806        /// anchor-centred box (`anchor ± extent`). Absent = every acting player.
4807        #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
4808        within: Option<StealthZone>,
4809        /// The damage type (default [`DamageKind::Generic`]).
4810        #[serde(default, skip_serializing_if = "Option::is_none")]
4811        damage_type: Option<DamageKind>,
4812    },
4813    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
4814    /// `spawnpoint @a` at the anchor cell and mirrors the coords into
4815    /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
4816    /// `set-checkpoint` always replaces an earlier one). The compiler proves the
4817    /// cell is standable (`DW0316`) and that the remaining critical path stays
4818    /// reachable from it (`DW0315`).
4819    SetCheckpoint {
4820        /// The prefab checkpoint anchor the party respawns at.
4821        anchor: AnchorId,
4822        /// Per-player effects re-run each time a player respawns while this
4823        /// checkpoint is the active one — scene reset (e.g. re-caging an
4824        /// unleashed actor). Emitted idempotently in declared order; empty = no
4825        /// hook. Respawn is detected via the vanilla `deathCount` criterion.
4826        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4827        on_respawn: Vec<QuestEffect>,
4828    },
4829    /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
4830    /// [`Verb::SetCheckpoint`] for souls-mode pacing. The effect *arms*
4831    /// the rest affordance (a `minecraft:interaction` the player right-clicks at
4832    /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
4833    /// only **when the party actually rests**. Resting fires `on_rest` — the
4834    /// scene reset that makes retry cheap: re-arming traps, re-seating waves
4835    /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
4836    /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
4837    /// so the world's answer to a death and to a rest is identical (spec-0016:
4838    /// death is an investment, never a tax).
4839    ///
4840    /// Proofs are inherited from the checkpoint machinery: the anchor must be
4841    /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
4842    /// the beat that arms the bonfire (the earliest moment a rest can happen).
4843    Bonfire {
4844        /// The prefab anchor the rest affordance stands at, and the cell the
4845        /// party respawns at once rested.
4846        anchor: AnchorId,
4847        /// Effects re-run on every rest **and** on every respawn at this bonfire
4848        /// — the scene reset. Emitted in declared order and expected to be
4849        /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
4850        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4851        on_rest: Vec<QuestEffect>,
4852        /// Title of the two-option rest dialog (DSL v0.8).
4853        /// Absent = the compiler's canonical English `Bonfire`,
4854        /// baked at emit time (the `world.boundary.message` precedent): an
4855        /// authored line is inventoried and translates like every other
4856        /// player-visible string.
4857        #[serde(default, skip_serializing_if = "Option::is_none")]
4858        prompt: Option<String>,
4859        /// Label of the **rest and save** button. Absent = `Rest and save`.
4860        /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
4861        /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
4862        #[serde(default, skip_serializing_if = "Option::is_none")]
4863        rest_label: Option<String>,
4864        /// Label of the **save only** button. Absent = `Save only`.
4865        #[serde(default, skip_serializing_if = "Option::is_none")]
4866        save_label: Option<String>,
4867    },
4868    /// Begins a stealth beat (DSL v0.6, spec-0014):
4869    /// zone presence alone = hidden — no sneak requirement, which collides with
4870    /// the spectator cutscene camera. While active, every player must be
4871    /// inside some `zone` each tick; a player outside every zone for
4872    /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
4873    /// Zone membership is read from the player's position. The compiler proves
4874    /// each zone is standable and reachable from the activating beat (`DW0327`).
4875    BeginStealth {
4876        /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
4877        zones: Vec<StealthZone>,
4878        /// Per-player effects fired when a player is caught (out of every zone
4879        /// for `grace_ticks`). Empty = no consequence.
4880        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4881        on_caught: Vec<QuestEffect>,
4882        /// Ticks a player may be exposed before `on_caught` fires (default 20).
4883        #[serde(default = "default_grace_ticks")]
4884        grace_ticks: u32,
4885    },
4886    /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
4887    EndStealth,
4888    /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
4889    /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
4890    /// scripted entrance a staged character needs. Idempotent: spawning an NPC
4891    /// already in the world is a no-op. Only meaningful for an NPC declared
4892    /// `deferred: true`; a non-deferred NPC is already in the world from init.
4893    SpawnNpc {
4894        /// The NPC (stage-2 ref) to summon.
4895        npc: NpcId,
4896    },
4897    // --- DSL v0.6 actor staging effects (spec-0014) ---
4898    /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
4899    /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
4900    SpawnActor {
4901        /// The actor (stage-5 `actors` ref) to summon.
4902        actor: ActorId,
4903    },
4904    /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
4905    /// animation (cutscene deaths); `vanish` is silent removal.
4906    DespawnActor {
4907        /// The actor (stage-5 `actors` ref) to remove.
4908        actor: ActorId,
4909        /// How the puppet is removed.
4910        style: DespawnStyle,
4911    },
4912    /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
4913    /// the assembled model, using the actor's hitbox footprint, yawed along the
4914    /// path tangent (DSL v0.6). Concurrent movers are allowed (a herded
4915    /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
4916    /// effects fire once the puppet reaches the destination cell.
4917    MoveActor {
4918        /// The actor (stage-5 `actors` ref) to move.
4919        actor: ActorId,
4920        /// The destination mark: an anchor and an optional offset (spec-0066).
4921        to: Mark,
4922        /// Optional travel speed in blocks/tick (defaults to ~0.15).
4923        #[serde(default, skip_serializing_if = "Option::is_none")]
4924        speed: Option<f64>,
4925        /// Effects fired once the puppet arrives at the destination cell.
4926        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4927        on_arrive: Vec<QuestEffect>,
4928    },
4929    /// Replaces an actor's puppet with a real-AI twin of the same type / position /
4930    /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
4931    /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
4932    /// special verb.
4933    UnleashActor {
4934        /// The actor (stage-5 `actors` ref) to unleash.
4935        actor: ActorId,
4936    },
4937    /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
4938    /// at exact tick offsets. Effects are any in the stage-5 set except a nested
4939    /// `sequence` (rejected with `DW0329`).
4940    Sequence {
4941        /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
4942        steps: Vec<SequenceStep>,
4943    },
4944    /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
4945    /// projectiles with real velocity vectors, fired from a gallery slot into a
4946    /// declared kill zone.
4947    ///
4948    /// The contract is **saturation, not sniping**.
4949    /// Every salvo puts one projectile on the trajectory to **every standable
4950    /// cell of `kill_zone`**, plus one aimed at the triggering player's
4951    /// fire-time position (which punishes standing still). A player therefore
4952    /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
4953    /// decision rather than a lucky step. Coverage is proven at compile time:
4954    /// `from_anchor` must have clear line of fire to every standable kill-zone
4955    /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
4956    /// on the stairs" is a build-time fact, not a hope.
4957    ///
4958    /// Projectiles are summoned `NoGravity` so the flown path is exactly the
4959    /// straight segment the coverage proof checks — proof and runtime share one
4960    /// geometry. Drag scales speed but not direction, so the line is preserved.
4961    Volley {
4962        /// Projectile entity id (default `minecraft:arrow`; validated against
4963        /// the pinned 1.21.11 registry, `DW0441`).
4964        #[serde(default, skip_serializing_if = "Option::is_none")]
4965        projectile: Option<String>,
4966        /// The gallery slot the volley is fired FROM — a point anchor. Its cell
4967        /// must be clear (a projectile spawned inside a wall never leaves it).
4968        from_anchor: AnchorId,
4969        /// The zone the volley must blanket, as an anchor-centred box
4970        /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
4971        /// `begin-stealth`'s `zones` use. Every standable cell in it receives
4972        /// fire each salvo.
4973        ///
4974        /// Deliberately NOT a bare prefab `region` anchor: the assembled model
4975        /// clears every gate-region anchor's cells unconditionally, so
4976        /// describing a zone that way would delete the geometry it names.
4977        kill_zone: StealthZone,
4978        /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
4979        #[serde(default, skip_serializing_if = "Option::is_none")]
4980        salvos: Option<u32>,
4981        /// Ticks between salvos (default 10, `DW0443` bounds it).
4982        #[serde(default, skip_serializing_if = "Option::is_none")]
4983        interval: Option<u32>,
4984    },
4985    /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
4986    /// drop them as `falling_block` entities — the buried-alive trap redstone
4987    /// cannot express at all.
4988    ///
4989    /// The post-collapse world is **modeled, not guessed**: the compiler clears
4990    /// the region, settles every dropped column through the existing gravity
4991    /// model (spec-0010), optionally paves the landing surface with
4992    /// `then_floor`, and re-runs the critical-path completability proof against
4993    /// that mutated world (`DW0445`). A collapse that buries the only route is a
4994    /// build error, exactly as a `shortcut` seal that strands the party is.
4995    Collapse {
4996        /// The volume whose blocks fall, as an anchor-centred box
4997        /// (`anchor ± extent`). Must hold blocks and sit above standable footing
4998        /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
4999        ///
5000        /// An anchor-centred box rather than a prefab `region` anchor for a
5001        /// load-bearing reason: the assembled model deletes every gate-region
5002        /// anchor's cells, so a ceiling slab declared that way would already be
5003        /// gone before the trap ever fired.
5004        region_anchor: StealthZone,
5005        /// The block the falling entities are made of (default
5006        /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
5007        #[serde(default, skip_serializing_if = "Option::is_none")]
5008        falling_block: Option<String>,
5009        /// Optional block the landing surface is paved with once the debris
5010        /// settles — the authored post-collapse floor the completability proof
5011        /// reasons over.
5012        #[serde(default, skip_serializing_if = "Option::is_none")]
5013        then_floor: Option<String>,
5014    },
5015    /// Grants a vanilla **status effect** for a stated duration (DSL v0.10,
5016    /// spec-0031). The engine has emitted status effects since v0.6 — the
5017    /// night-vision area mitigation is a self-rescheduling, region-scoped
5018    /// `effect give` — and exposed none, so an author who wanted blindness for a
5019    /// lift ride, slowness in deep water or regeneration at a shrine had no
5020    /// surface at all. This is that surface, over the whole pinned 1.21.11
5021    /// `mob_effect` registry (`DW0192` rejects an id outside it).
5022    ///
5023    /// **A grant carries a duration, and there is no way to spell one that does
5024    /// not.** Vanilla's `infinite` keyword is deliberately absent from this
5025    /// surface: an effect whose only removal is a later command is an effect the
5026    /// player keeps forever whenever that command does not run — a logout, a
5027    /// crash, a chain interrupted by a death. A duration expires on its own, with
5028    /// no cooperation from anything. `seconds` is therefore required and bounded
5029    /// (1..=[`MAX_EFFECT_SECONDS`], `DW0541`), and the *pattern* that reintroduces
5030    /// the same hazard — pairing a grant with a `clear-effect` that removes it
5031    /// while it is still live — is `DW0540`.
5032    ///
5033    /// `in` narrows to players inside an anchor-centred box, the same
5034    /// [`StealthZone`] a `begin-stealth` zone, a `damage-players` filter and a
5035    /// `lethal_volumes[]` region use, resolved through the one `Plan::zone_box`.
5036    /// It is what makes "blind whoever is riding the car" expressible without
5037    /// blinding the whole party.
5038    GiveEffect {
5039        /// Vanilla status-effect id (e.g. `minecraft:blindness`), validated
5040        /// against the pinned 1.21.11 registry (`DW0192`).
5041        effect: String,
5042        /// Duration in seconds. Required, `1..=`[`MAX_EFFECT_SECONDS`]
5043        /// (`DW0541`) — see the variant docs for why there is no infinite form.
5044        seconds: u32,
5045        /// Amplifier (0 = level I), `0..=`[`MAX_POTION_AMPLIFIER`] (`DW0541`).
5046        /// Absent = 0.
5047        #[serde(default, skip_serializing_if = "Option::is_none")]
5048        amplifier: Option<u32>,
5049        /// Suppress the swirling particles (vanilla's `hideParticles`). Absent =
5050        /// `false`, vanilla's own default.
5051        #[serde(default, skip_serializing_if = "Option::is_none")]
5052        hide_particles: Option<bool>,
5053        /// Optional spatial filter: only grant to a player inside this
5054        /// anchor-centred box (`anchor ± extent`). Absent = every player the
5055        /// effect's audience addresses.
5056        #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5057        within: Option<StealthZone>,
5058    },
5059    /// Removes a status effect (DSL v0.10, spec-0031) — vanilla's `effect clear`.
5060    ///
5061    /// This is **not** how a `give-effect` is supposed to end: a duration is. It
5062    /// exists for the effects the engine did not grant — a potion the player
5063    /// drank, a `wither` a mob applied, the whole set at a bonfire — which is why
5064    /// `effect` is optional (absent = clear everything, exactly as vanilla's
5065    /// `effect clear <targets>` does). Pairing it with a live grant of the same
5066    /// effect in the same bundle is `DW0540`.
5067    ClearEffect {
5068        /// The status-effect id to remove (`DW0192`). **Absent clears every
5069        /// effect**, matching `effect clear <targets>` with no id.
5070        #[serde(default, skip_serializing_if = "Option::is_none")]
5071        effect: Option<String>,
5072        /// Optional spatial filter, identical in shape and meaning to
5073        /// [`Verb::GiveEffect`]'s.
5074        #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5075        within: Option<StealthZone>,
5076    },
5077    /// Teleports **everything inside a declared volume** to an anchor (DSL v0.10,
5078    /// spec-0031).
5079    ///
5080    /// **The selector is a region, never a block.** "Whoever is standing on this
5081    /// block" has three different answers for a player half a foot over the edge,
5082    /// a player mid-jump and a player sneaking on the lip; a volume has one, and
5083    /// it is the same one every tick. `from` is the anchor-centred
5084    /// [`StealthZone`] box the rest of the engine already uses.
5085    ///
5086    /// **The selection is total over bodies.** Emission is a single `tp
5087    /// @e[<box>,tag=!dw_fixture] <cell>` with no `type=`, no `limit=` and no
5088    /// `sort=` — every body in the volume moves, and
5089    /// `crates/delvec/tests/v10_teleport.rs` asserts that from the emitted
5090    /// selector rather than from anyone's memory. A machinery-**type** exemption
5091    /// of the kind a `lethal_volumes[]` entry must carry was considered and
5092    /// **rejected**: an NPC is a body plus a co-located `minecraft:interaction`
5093    /// hitbox, so exempting `minecraft:interaction` — as the lethal volume does —
5094    /// would teleport the speaker and leave its dialogue box behind. Everyone on
5095    /// the car travels, players and entities
5096    /// alike; totality over bodies is how that is true.
5097    ///
5098    /// The one narrowing is a **class the engine's own furniture declares about
5099    /// itself**: `dw_fixture` means *my position IS engine state*. A bonfire's
5100    /// hitbox, a shortcut lever and a recovery stake's marker are places, not
5101    /// passengers, and carrying one does not move a thing — it rewrites a fact
5102    /// (for a stake, the ledger holds the marker's coordinates, and the next tick
5103    /// retires a marker nobody has a wager at). Places whose cell is known at
5104    /// compile time are refused outright instead, because the author can move
5105    /// them (`DW0542`); places the runtime puts down are excluded by the selector,
5106    /// because nobody can (`DW0545`). Nothing an author writes carries either tag,
5107    /// and no campaign JSON can turn either off.
5108    ///
5109    /// **A teleport is not a rescue.** Accumulated fall distance carries across
5110    /// one unchanged and is charged in full at the destination — measured Δ
5111    /// exactly `0.0000` in 46/46 trials on the pinned 1.21.11, including
5112    /// teleports 143 and 157 blocks straight *up*, with landing damage
5113    /// `floor(fall_distance) − 3` (`docs/notes/death-and-teleport-spike.md` §3).
5114    /// A platform that arrives under a falling player past ~20 blocks of fall is
5115    /// the surface they die on. The compiler does not try to reset the counter:
5116    /// what *does* reset it was explicitly not measured, and inventing a
5117    /// mechanism from recall is the folklore this project forbids.
5118    Teleport {
5119        /// The volume whose contents are moved, as an anchor-centred box
5120        /// (`anchor ± extent`) — the same shape a `begin-stealth` zone, a
5121        /// `damage-players` `in` filter and a `lethal_volumes[]` region take.
5122        ///
5123        /// Deliberately NOT a bare prefab `region` anchor, for the reason
5124        /// [`Verb::Collapse`] records: the assembled model clears every
5125        /// gate-region anchor's cells, so a volume described that way would
5126        /// delete the geometry it names.
5127        from: StealthZone,
5128        /// The destination mark (spec-0066). Resolved to a literal cell at build
5129        /// time, so the emitted `tp` carries absolute coordinates and no runtime
5130        /// search.
5131        to: Mark,
5132    },
5133    /// Fires a **firework rocket** from a mark (DSL v0.29, spec-0068).
5134    ///
5135    /// One effect at a point, the member of the same class as [`Verb::PlaySound`]
5136    /// — a one-shot thing that happens where the campaign says, beside the sound
5137    /// that goes with it. A display of many rockets is a [`Verb::Sequence`] of
5138    /// these, not a verb with timing of its own.
5139    ///
5140    /// # The burst height is a stated number, not a roll
5141    ///
5142    /// The emitter writes the entity's `LifeTime`
5143    /// ([`crate::firework::lifetime_ticks`]) rather than leaving it to the game,
5144    /// which randomises it at launch: two runs of one datapack would otherwise
5145    /// burst at two heights and nothing could be proven about where the burst is.
5146    /// Fixed at the floor of the game's range, the burst stands
5147    /// [`crate::firework::burst_height`] blocks over the mark.
5148    ///
5149    /// # A burst hurts, so the compiler asks where it is
5150    ///
5151    /// A build refuses a rocket whose column to that height is roofed, and one
5152    /// whose burst lies within [`crate::firework::BLAST_RADIUS`] blocks of a
5153    /// place the campaign posts a body (`DW0899`). Players are **not** posted:
5154    /// a player standing level with a burst takes up to
5155    /// [`crate::firework::worst_damage_hp`] HP, under a full body's twenty, and
5156    /// that is a hazard a player can see coming.
5157    Firework {
5158        /// The mark the rocket is launched from — the cell's centre, at the
5159        /// mark's own plane.
5160        at: Mark,
5161        /// Flight duration, 1–3 (the three the game crafts). Absent =
5162        /// [`crate::firework::MIN_FLIGHT`].
5163        #[serde(default, skip_serializing_if = "Option::is_none")]
5164        #[schemars(range(min = 1, max = 3))]
5165        flight: Option<u8>,
5166        /// One to seven bursts, in the order the component carries them.
5167        #[schemars(length(min = 1, max = 7))]
5168        explosions: Vec<FireworkExplosion>,
5169    },
5170}
5171
5172/// Default `grace_ticks` for [`Verb::BeginStealth`] (spec-0014).
5173fn default_grace_ticks() -> u32 {
5174    20
5175}
5176
5177/// Default projectile for [`Verb::Volley`] (spec-0022).
5178pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
5179/// Default salvo count for [`Verb::Volley`] (spec-0022).
5180pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
5181/// Default ticks between salvos for [`Verb::Volley`] (spec-0022).
5182pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
5183/// Largest admissible `salvos` — beyond this a volley is an entity-count
5184/// hazard rather than a trap (`DW0443`).
5185pub const MAX_VOLLEY_SALVOS: u32 = 16;
5186/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
5187/// slower than this is no longer one event the player reads as a trap.
5188pub const MAX_VOLLEY_INTERVAL: u32 = 200;
5189/// Default falling block for [`Verb::Collapse`] (spec-0022).
5190pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
5191
5192/// A stealth "shadow" region (DSL v0.6, spec-0014): an axis-aligned box centred
5193/// on `anchor`, extending `extent` blocks along each axis (so the box spans
5194/// `anchor ± extent`). Presented in-world via dark cells but judged purely by
5195/// region membership, so the check is deterministic and provable.
5196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5197#[serde(deny_unknown_fields)]
5198pub struct StealthZone {
5199    /// The prefab anchor at the centre of the zone box.
5200    pub anchor: AnchorId,
5201    /// Half-extents `[x, y, z]` in blocks from the anchor (each component ≥ 0);
5202    /// the zone AABB is `[anchor - extent, anchor + extent]`.
5203    pub extent: [u32; 3],
5204}
5205
5206/// Where a [`Verb::PlaySound`] originates (DSL v0.6, spec-0014). A sound
5207/// plays at fixed coordinates or at each listener's own position; the compiler
5208/// resolves no position for a live actor, so the `actor` variant is accepted by
5209/// the schema and rejected with `DW0335`.
5210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5211#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
5212pub enum SoundAt {
5213    /// Play the sound positioned at a resolved anchor, audible to all players.
5214    Anchor {
5215        /// The anchor the sound plays from.
5216        anchor: AnchorId,
5217        /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
5218        /// `[0, 0, 0]`): the sound plays at the [`Mark`] the two fields spell.
5219        #[serde(default, skip_serializing_if = "is_zero3")]
5220        offset: [i32; 3],
5221    },
5222    /// Play the sound at each player's own position (the default).
5223    Players,
5224    /// Play the sound at a scripted actor's position (rejected — `DW0335`; no
5225    /// actor position resolves at emission).
5226    Actor {
5227        /// The actor id (stage-5 `actors[]`).
5228        actor: String,
5229    },
5230}
5231
5232/// The damage type of a [`Verb::DamagePlayers`] effect (DSL v0.6). A
5233/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
5234/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
5235/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
5236/// `generic_kill` types are deliberately excluded, so a scripted consequence can
5237/// never silently void a player's held totem. Modelled as an enum (not a free
5238/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
5239/// registry / diagnostic. `generic` is the default: command damage that respects
5240/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
5241#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5242#[serde(rename_all = "kebab-case")]
5243pub enum DamageKind {
5244    /// `minecraft:generic` — armor-ignoring command damage (default).
5245    Generic,
5246    /// `minecraft:magic` — magical damage.
5247    Magic,
5248    /// `minecraft:wither` — the wither/withering effect's damage type.
5249    Wither,
5250    /// `minecraft:on_fire` — burning damage.
5251    Fire,
5252    /// `minecraft:drown` — drowning damage.
5253    Drown,
5254    /// `minecraft:freeze` — powder-snow freezing damage.
5255    Freeze,
5256    /// `minecraft:fall` — fall damage.
5257    Fall,
5258    /// `minecraft:lightning_bolt` — a lightning strike's damage type.
5259    LightningBolt,
5260    /// `minecraft:explosion` — a (non-player) explosion's damage type.
5261    Explosion,
5262}
5263
5264impl DamageKind {
5265    /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
5266    pub fn id(self) -> &'static str {
5267        match self {
5268            DamageKind::Generic => "minecraft:generic",
5269            DamageKind::Magic => "minecraft:magic",
5270            DamageKind::Wither => "minecraft:wither",
5271            DamageKind::Fire => "minecraft:on_fire",
5272            DamageKind::Drown => "minecraft:drown",
5273            DamageKind::Freeze => "minecraft:freeze",
5274            DamageKind::Fall => "minecraft:fall",
5275            DamageKind::LightningBolt => "minecraft:lightning_bolt",
5276            DamageKind::Explosion => "minecraft:explosion",
5277        }
5278    }
5279}
5280
5281/// A stage-5 **lethal volume** (DSL v0.10, spec-0031): a declared box that kills
5282/// whatever enters it, and states — in the campaign's own words — what killed it.
5283///
5284/// # A mechanism, not a fiction
5285///
5286/// The commissioning case was a cliff whose fall must be fatal, but nothing here
5287/// knows what a cliff is: a lava pit, an acid pool, an out-of-bounds plane and the
5288/// bottom of a lift shaft are the same declaration, differently dressed and
5289/// differently worded. The alternative considered and **rejected** for the cliff
5290/// was making the world's horizon void so the fall kills anyway — that changes
5291/// approved art to obtain a behaviour, and it serves exactly one fiction.
5292///
5293/// # It is geometry, so the completability proof owns it
5294///
5295/// A volume that kills is, for a route, a volume that cannot be crossed. The
5296/// compiler models its cells as impassable in the same navigation world every
5297/// other reachability proof runs on, exactly as a `close-gate`'s sealed region is
5298/// modelled solid — so a forced path that has no way to an objective except
5299/// through a lethal volume is a build failure (`DW0510`) naming the volume, never
5300/// a shipped delve that kills the player on the critical path. A respawn seat
5301/// inside one is the death loop that failure mode ends in, and is its own
5302/// error (`DW0511`).
5303///
5304/// # It rides the death edge that already exists
5305///
5306/// The kill is a `/damage`, exactly like `damage-players`, a trap payload or a
5307/// timed gate's crush. Everything downstream — the vanilla `deathCount` edge
5308/// (`dw.deaths` / `dw.death_ack`), the checkpoint re-seat (`cp_respawn_check`),
5309/// `keep_inventory` — sees an ordinary death and needs no second detector.
5310#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5311#[serde(deny_unknown_fields)]
5312pub struct LethalVolume {
5313    /// Unique lethal-volume id (`lethal/<kebab>`).
5314    pub id: LethalVolumeId,
5315    /// The volume: an anchor-centred box (`anchor ± extent`).
5316    ///
5317    /// **Deliberately the existing zone type**, not a second struct with the same
5318    /// two fields. `StealthZone` is already the engine's anchor-centred box object
5319    /// class — `damage-players`'s `in` filter reuses it, and the compiler resolves
5320    /// every one of them through the single `Plan::zone_box`. A private twin here
5321    /// would be `tools/ci/check-capability-ownership.py` check C by construction, and
5322    /// would fork the resolution the very next time a box grew a capability.
5323    pub region: StealthZone,
5324    /// What this volume says when it kills — a player-visible line, inventoried
5325    /// under `lethal.<id>.message` and translated like every other one.
5326    ///
5327    /// Required, and deliberately so: a volume with no words is a player who dies
5328    /// with no idea why, and there is no compiler-owned default that could be
5329    /// right for a cliff, a lava pit and an acid pool at once.
5330    pub message: String,
5331    /// The damage type the kill is dealt with (default [`DamageKind::Generic`]).
5332    ///
5333    /// This is what words vanilla's own broadcast — `fall` says the party member
5334    /// fell from a high place, `on_fire` that they burnt to a crisp — while
5335    /// [`Self::message`] says what the *place* was. The curated enum is shared
5336    /// with `damage-players`, so a lethal volume can no more void a held totem
5337    /// than a scripted hit can.
5338    #[serde(default, skip_serializing_if = "Option::is_none")]
5339    pub damage_type: Option<DamageKind>,
5340    /// **The blocks that show a player this floor kills** (spec-0062 §3).
5341    ///
5342    /// A lava surface, a magma floor, a bed of spikes, a burning strip: the
5343    /// danger is level with the footing because the block *is* the signal. This
5344    /// is where a volume says so, and it is a claim about the assembled bytes
5345    /// rather than a word that switches a rule off — `DW0891` checks it per
5346    /// caught cell against the block under or in that cell, refuses a listed
5347    /// block no caught cell bears out, and refuses at validation any id vanilla
5348    /// does not hurt a body with.
5349    ///
5350    /// Empty is the ordinary case and means the ordinary thing: this volume
5351    /// catches no floor the party walks, because it sits at the bottom of a pit
5352    /// or a course under a lake's surface. It does **not** exempt a cell from
5353    /// the walk graph — a visible hazard is still a hazard, and the router
5354    /// refuses every cell of the keep-out either way.
5355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5356    pub shown_by: Vec<String>,
5357}
5358
5359// ---------------------------------------------------------------------------
5360// Stage 5 — trade and the recovery stake (DSL v0.10, spec-0032)
5361// ---------------------------------------------------------------------------
5362
5363/// A stage-5 **shop** (DSL v0.10, spec-0032): an interaction point that opens a
5364/// list of offers, each of which is a gate and a bundle of effects.
5365///
5366/// # It is the rest flow with different buttons, and that is the whole design
5367///
5368/// A bonfire is already an interaction entity, a player-interaction advancement
5369/// that supplies the acting player, a `minecraft:multi_action` dialog, buttons
5370/// that run `/trigger`, and tick dispatch. A shop is that same hardware with an
5371/// authored button list, so nothing here is new machinery — it is the machinery
5372/// spec-0016 §1 shipped, made author-visible.
5373///
5374/// # There is no price field, and that is deliberate
5375///
5376/// A price is *"may this happen yet?"*, which is the question
5377/// [`Gate`](crate::gate::Gate) answers for six other object classes. An offer is
5378/// therefore the **seventh gate consumer**: it carries `requires_flags`,
5379/// `forbids_flags` and `requires_state`, and the numeric comparison it needs is
5380/// the one spec-0031 put in the gate rather than in the verb that first asked.
5381/// A `price` field would be a second comparison surface for one meaning, which is
5382/// the defect CLAUDE.md names first.
5383///
5384/// # Villager `Offers` is excluded
5385///
5386/// Three independent reasons, any one sufficient (spec-0032): a vanilla trade
5387/// cost can only ever be an item, never a scoreboard value; right-click on a
5388/// villager body is already allocated to dialogue; and the data-driven trade
5389/// registry post-dates the pinned 1.21.11.
5390#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5391#[serde(deny_unknown_fields)]
5392pub struct Shop {
5393    /// Unique shop id (`shop/<kebab>`).
5394    pub id: ShopId,
5395    /// The prefab anchor the shop's interaction point stands on.
5396    pub anchor: AnchorId,
5397    /// The dialog's title line. Player-visible, inventoried under
5398    /// `shop.<id>.title`.
5399    pub title: String,
5400    /// The item the visible marker renders as (default `minecraft:emerald`).
5401    /// Cosmetic only: the marker is a `minecraft:item_display`, so it never
5402    /// occupies the cell or obstructs the hitbox.
5403    #[serde(default, skip_serializing_if = "Option::is_none")]
5404    pub marker_item: Option<String>,
5405    /// The offers, in declaration order — which is button order, and the order
5406    /// the `/trigger` routing values are assigned in.
5407    pub offers: Vec<ShopOffer>,
5408}
5409
5410/// One button in a [`Shop`]: a gate, a label, and the effects choosing it runs.
5411///
5412/// **Effect root R8.** The bundle hangs off an object with runtime machinery of
5413/// its own (an advancement, a dialog, a trigger objective and a tick dispatch),
5414/// which is spec-0031's stated rule for adding a root rather than desugaring.
5415///
5416/// **Refusal is authored, not a field.** An offer whose own gate is closed is not
5417/// shown at all; an offer that should be *shown and refuse* leaves its own gate
5418/// open and gates its effects instead — the purchase behind `at-least <price>`,
5419/// the apology behind `at-most <price − 1>`. Both are the ordinary per-effect
5420/// gate every effect already carries, so the engine adds no `refused` field and
5421/// no second way to say one thing.
5422#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5423#[serde(deny_unknown_fields)]
5424pub struct ShopOffer {
5425    /// The button's caption. Player-visible, inventoried under
5426    /// `shop.<id>.offer.<i>.label`, and width-checked like any dialog button.
5427    pub label: String,
5428    /// The button's hover tooltip — the natural home for a price in words.
5429    /// Player-visible, inventoried under `shop.<id>.offer.<i>.tooltip`.
5430    #[serde(default, skip_serializing_if = "Option::is_none")]
5431    pub tooltip: Option<String>,
5432    /// Flags that must all be set for this offer to be shown (the one gate).
5433    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5434    pub requires_flags: Vec<FlagId>,
5435    /// Flags whose being set hides this offer (the one gate).
5436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5437    pub forbids_flags: Vec<FlagId>,
5438    /// Numeric gate terms (the one gate) — **this is where a price lives**.
5439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5440    pub requires_state: Vec<StateCompare>,
5441    /// What choosing this offer does. Effect root R8; runs with the choosing
5442    /// player as the acting player, so a `player`-scoped datum is writable here.
5443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5444    pub effects: Vec<QuestEffect>,
5445}
5446
5447impl ShopOffer {
5448    /// This offer's whole gate, as one value (DSL v0.10) — see
5449    /// [`crate::gate::Gate`].
5450    pub fn gate(&self) -> crate::gate::Gate<'_> {
5451        crate::gate::Gate::of(
5452            &self.requires_flags,
5453            &self.forbids_flags,
5454            &self.requires_state,
5455        )
5456    }
5457}
5458
5459/// How much of a datum a death forfeits into a [`Stake`] (DSL v0.10, spec-0032).
5460///
5461/// A creator who wants **no death cost at all** picks [`Forfeit::None`]; the
5462/// stake then still marks where they fell, which is the memorial case.
5463#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5464#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5465pub enum Forfeit {
5466    /// The whole balance.
5467    All,
5468    /// A percentage of the balance, rounded toward zero (integer arithmetic —
5469    /// ADR-0006 forbids anything a second implementation could round differently).
5470    Proportion {
5471        /// 0–100.
5472        percent: u32,
5473    },
5474    /// A fixed amount, capped at the balance so a purse can never go negative.
5475    Fixed {
5476        /// The amount.
5477        amount: i32,
5478    },
5479    /// Nothing is taken. The stake is a marker, not a wager.
5480    None,
5481}
5482
5483/// What a death does when the player already holds the maximum number of live
5484/// stakes (DSL v0.10, spec-0032).
5485#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5486#[serde(rename_all = "kebab-case")]
5487pub enum OnFull {
5488    /// Retire the player's oldest live stake — its contents are lost — and place a
5489    /// new one. With `max_live: 1` this is the souls behaviour.
5490    Replace,
5491    /// Leave the existing stakes alone: the new death places nothing and forfeits
5492    /// nothing. One wager at a time.
5493    Keep,
5494}
5495
5496impl OnFull {
5497    /// The wire token (`replace` / `keep`).
5498    pub fn token(self) -> &'static str {
5499        match self {
5500            OnFull::Replace => "replace",
5501            OnFull::Keep => "keep",
5502        }
5503    }
5504}
5505
5506/// Who may collect a stake that is not theirs (DSL v0.10, spec-0032).
5507#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5508#[serde(rename_all = "kebab-case")]
5509pub enum CollectBy {
5510    /// Only the player who left it. The default.
5511    Owner,
5512    /// Anyone standing at it. The whole of every live stake at that place is
5513    /// transferred to the collector.
5514    Anyone,
5515}
5516
5517impl CollectBy {
5518    /// The wire token (`owner` / `anyone`).
5519    pub fn token(self) -> &'static str {
5520        match self {
5521            CollectBy::Owner => "owner",
5522            CollectBy::Anyone => "anyone",
5523        }
5524    }
5525}
5526
5527/// A stage-5 **recovery stake** (DSL v0.10, spec-0032): what a death leaves
5528/// behind, and the one chance to get it back.
5529///
5530/// # A mechanism, not a genre
5531///
5532/// "Souls" is one setting of this declaration and nothing here knows the word.
5533/// The mechanism is *a death forfeits some of a declared datum into a collectable
5534/// marker at a computed place, and collecting it returns exactly what was taken*.
5535/// A campaign with no death cost, a campaign with a permanent memorial at every
5536/// death site, and a campaign with the souls loop are three configurations, not
5537/// three engines.
5538///
5539/// # Where it lands is a compile-time answer
5540///
5541/// The owner's rule: *the anchor is the point, on the walkable path
5542/// from the respawn point in force at the moment of death to the death point
5543/// under the quest state in force at that moment, that minimises distance to the
5544/// death point.* Every term already has an owner in the compiler — walkability is
5545/// the navigation world the completability proof runs on, quest-state passability
5546/// is the DAG-indexed sealing `close-gate` established, and the respawn point in
5547/// force is engine state. So it is a table computed at build time and a lookup at
5548/// run time: **no runtime search, no nondeterminism.**
5549///
5550/// # It is hardware, so it inherits the hardware rules
5551///
5552/// The marker is an invisible `minecraft:interaction` for the hitbox plus a
5553/// glowing `minecraft:item_display` for the rendering — the same pair every other
5554/// affordance in the engine uses, and therefore the same invisible-affordance
5555/// (`DW0420`) and hardware-erasure (`DW0421`) proofs. It is deliberately **not**
5556/// a dropped item entity: an item despawns after 6000 ticks, burns in lava, sinks
5557/// in the void, and can be picked up by anyone.
5558#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5559#[serde(deny_unknown_fields)]
5560pub struct Stake {
5561    /// Unique stake id (`stake/<kebab>`).
5562    pub id: StakeId,
5563    /// The datum a death forfeits into this stake, and a collection returns.
5564    /// Must be declared in the stage-5 `state` list, and must be `player`-scoped:
5565    /// a stake is a personal wager, and a party-shared purse would turn one
5566    /// player's death into everyone's penalty.
5567    pub state: StateId,
5568    /// How much of [`Self::state`] a death takes. Default: the whole balance.
5569    #[serde(default, skip_serializing_if = "Option::is_none")]
5570    pub forfeit: Option<Forfeit>,
5571    /// How many live stakes one player may hold at once. Default 1; `0` means a
5572    /// death never places one (and never forfeits anything), which is the
5573    /// "no death cost" configuration.
5574    #[serde(default, skip_serializing_if = "Option::is_none")]
5575    pub max_live: Option<u32>,
5576    /// What a death does when the player is already at [`Self::max_live`].
5577    /// Default [`OnFull::Replace`].
5578    #[serde(default, skip_serializing_if = "Option::is_none")]
5579    pub on_full: Option<OnFull>,
5580    /// Who may collect. Default [`CollectBy::Owner`].
5581    #[serde(default, skip_serializing_if = "Option::is_none")]
5582    pub collect_by: Option<CollectBy>,
5583    /// The line a collection says, on the action bar. Player-visible, inventoried
5584    /// under `stake.<id>.collected`.
5585    pub collected_message: String,
5586    /// The item the glowing marker renders as (default `minecraft:soul_lantern`).
5587    #[serde(default, skip_serializing_if = "Option::is_none")]
5588    pub marker_item: Option<String>,
5589}
5590
5591impl Stake {
5592    /// The forfeit rule, with the documented default applied.
5593    pub fn forfeit(&self) -> Forfeit {
5594        self.forfeit.unwrap_or(Forfeit::All)
5595    }
5596
5597    /// How many live stakes one player may hold, with the documented default.
5598    pub fn max_live(&self) -> u32 {
5599        self.max_live.unwrap_or(1)
5600    }
5601
5602    /// The at-capacity rule, with the documented default.
5603    pub fn on_full(&self) -> OnFull {
5604        self.on_full.unwrap_or(OnFull::Replace)
5605    }
5606
5607    /// The collection rule, with the documented default.
5608    pub fn collect_by(&self) -> CollectBy {
5609        self.collect_by.unwrap_or(CollectBy::Owner)
5610    }
5611
5612    /// The item the marker renders as, with the documented default.
5613    pub fn marker_item(&self) -> &str {
5614        self.marker_item
5615            .as_deref()
5616            .unwrap_or("minecraft:soul_lantern")
5617    }
5618}
5619
5620/// The presentation channel for a [`Verb::Narrate`] (DSL v0.4).
5621#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5622#[serde(rename_all = "kebab-case")]
5623pub enum NarrateStyle {
5624    /// A chat line (default).
5625    Chat,
5626    /// A large on-screen title.
5627    Title,
5628    /// An on-screen subtitle.
5629    Subtitle,
5630    /// An "art" title rendered through the delve's custom resource-pack pixel-banner
5631    /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
5632    /// spec-0014). Text is checked at compile time against the font's glyph inventory
5633    /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
5634    /// renders in the vanilla title slot, so it is width-checked like any title
5635    /// (`DW0330`) — roughly 15 glyphs fit on screen.
5636    Art,
5637    /// The **actionbar** — the one-line strip above the hotbar (DSL v0.11).
5638    ///
5639    /// This is the channel a *reply* uses: it does not interrupt, it does not
5640    /// stack, and it is overwritten by the next one. Every reply the compiler
5641    /// itself writes has always used it — a sealed gate's answer, a checkpoint
5642    /// return, the lobby's party count — but `narrate` could not reach it, which
5643    /// is the mechanical reason `close-gate.sealed_hint` could not have been an
5644    /// ordinary `narrate` even had someone tried (capability-ownership audit
5645    /// finding 3b). A channel is a property of the message, not of the verb that
5646    /// first wanted it.
5647    ///
5648    /// Unlike a title it is never width-checked: vanilla truncates nothing and
5649    /// draws it at GUI width, and a reply is a fragment rather than a banner.
5650    Actionbar,
5651}
5652
5653impl NarrateStyle {
5654    /// The kebab tag (`chat` / `title` / `subtitle` / `art` / `actionbar`).
5655    pub fn token(self) -> &'static str {
5656        match self {
5657            NarrateStyle::Chat => "chat",
5658            NarrateStyle::Title => "title",
5659            NarrateStyle::Subtitle => "subtitle",
5660            NarrateStyle::Art => "art",
5661            NarrateStyle::Actionbar => "actionbar",
5662        }
5663    }
5664}
5665
5666/// **A mark** (spec-0066): an anchor and an integer block offset from it, the
5667/// one declaration of a point the campaign places relative to a piece.
5668///
5669/// Its cell is the anchor's resolved cell plus `offset`, in world axes after
5670/// the piece's placement. It is what a body stands on ([`Npc::offset`],
5671/// [`Actor::offset`]), where a walk ends ([`Verb::MoveNpc`], [`Verb::MoveActor`],
5672/// [`Verb::Teleport`]), where the cast ledger says a body is
5673/// ([`CastPlace::Mark`]), where a sound plays ([`SoundAt::Anchor`]), and every
5674/// camera position in a shot: a dolly waypoint (`path`), an aim target
5675/// (`look_at`) and an anchor subject (`subject`). The roles are fields; the
5676/// type is one.
5677///
5678/// A mark's cell lies inside the placed piece its anchor belongs to (`DW0897`):
5679/// an offset says *where beside this place*, never *which place*.
5680#[derive(
5681    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
5682)]
5683#[serde(deny_unknown_fields)]
5684pub struct Mark {
5685    /// The anchor the mark is relative to.
5686    pub anchor: AnchorId,
5687    /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
5688    #[serde(default, skip_serializing_if = "is_zero3")]
5689    pub offset: [i32; 3],
5690}
5691
5692impl Mark {
5693    /// A mark at `anchor` with no offset.
5694    pub fn at(anchor: AnchorId) -> Self {
5695        Mark {
5696            anchor,
5697            offset: [0, 0, 0],
5698        }
5699    }
5700
5701    /// Whether the offset is non-zero.
5702    pub fn is_offset(&self) -> bool {
5703        !is_zero3(&self.offset)
5704    }
5705
5706    /// The mark's cell, given the cell its anchor resolved to.
5707    pub fn cell(&self, anchor_cell: [i32; 3]) -> [i32; 3] {
5708        offset_cell(anchor_cell, self.offset)
5709    }
5710
5711    /// The mark as a diagnostic spells it: the anchor alone at a zero offset,
5712    /// else `anchor + [x, y, z]`.
5713    pub fn display(&self) -> String {
5714        if self.is_offset() {
5715            format!(
5716                "{} + [{}, {}, {}]",
5717                self.anchor, self.offset[0], self.offset[1], self.offset[2]
5718            )
5719        } else {
5720            self.anchor.as_str().to_string()
5721        }
5722    }
5723}
5724
5725/// `cell + offset`, componentwise: the one arithmetic a [`Mark`] adds.
5726pub fn offset_cell(cell: [i32; 3], offset: [i32; 3]) -> [i32; 3] {
5727    [
5728        cell[0] + offset[0],
5729        cell[1] + offset[1],
5730        cell[2] + offset[2],
5731    ]
5732}
5733
5734/// One shot of a [`Verb::Cutscene`] (DSL v0.6): a camera dolly with its
5735/// own duration and optional subject. A cutscene plays its shots back-to-back —
5736/// a hard cut between them — inside a single gamemode/position save-restore
5737/// bracket, so a wide establishing move can be followed by an interior close-up
5738/// without the players ever leaving the cinematic.
5739#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5740#[serde(deny_unknown_fields)]
5741pub struct CameraShot {
5742    /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
5743    /// path is a static shot. Required without `shot_style`; with one, optional —
5744    /// an explicit `path` always overrides the style's expanded dolly.
5745    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5746    pub path: Vec<Mark>,
5747    /// This shot's duration in seconds. Required without `shot_style`; with one,
5748    /// optional — the style's default duration applies (see
5749    /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
5750    #[serde(default, skip_serializing_if = "Option::is_none")]
5751    pub seconds: Option<u32>,
5752    /// Optional subject the camera keeps framed for this shot. Absent = face
5753    /// along the direction of travel (or, under a `shot_style`, the style's own
5754    /// aim at its `subject`). An explicit `look_at` always overrides a style's
5755    /// aim.
5756    #[serde(default, skip_serializing_if = "Option::is_none")]
5757    pub look_at: Option<Mark>,
5758    /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
5759    /// compiler expands the style deterministically into a camera dolly +
5760    /// per-keyframe aim from the `subject`'s resolved geometry. Requires
5761    /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
5762    /// the corresponding expanded part (`DW0348` polices the combinations).
5763    #[serde(default, skip_serializing_if = "Option::is_none")]
5764    pub shot_style: Option<ShotStyle>,
5765    /// The styled shot's subject — what the shot is *about*. Required with
5766    /// `shot_style`, rejected without one (`DW0348`).
5767    #[serde(default, skip_serializing_if = "Option::is_none")]
5768    pub subject: Option<CameraSubject>,
5769    /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
5770    #[serde(default, skip_serializing_if = "Option::is_none")]
5771    pub subject_b: Option<CameraSubject>,
5772    /// Styled shots only: the style's characteristic camera distance in blocks
5773    /// (its *start* distance for the dolly styles). Default per style — see the
5774    /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
5775    /// (`DW0348`).
5776    #[serde(default, skip_serializing_if = "Option::is_none")]
5777    pub dist: Option<f64>,
5778    /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
5779    /// default 90 (`DW0348` elsewhere or out of range).
5780    #[serde(default, skip_serializing_if = "Option::is_none")]
5781    pub degrees: Option<f64>,
5782    /// Styled shots only: placement bearing in degrees — where the camera sits
5783    /// (or starts) relative to the subject, measured like a Minecraft yaw
5784    /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
5785    /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
5786    /// `side-track` the bearing picks which side of the subject's travel the
5787    /// camera runs abeam (`0` = right of travel, `180` = left).
5788    #[serde(default, skip_serializing_if = "Option::is_none")]
5789    pub bearing: Option<f64>,
5790}
5791
5792/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
5793/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
5794/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
5795/// is **distance only** — vanilla has no in-game FOV control.
5796#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5797#[serde(rename_all = "kebab-case")]
5798pub enum ShotStyle {
5799    /// Fully static close framing of a prop/detail. The beat that always looks
5800    /// right.
5801    Insert,
5802    /// Static position; only the aim turns as the subject (ideally moving)
5803    /// passes. Rockstar's roadside "ground view".
5804    LockedOff,
5805    /// Straight dolly toward the subject along the view axis; medium → close.
5806    PushIn,
5807    /// Reverse of `push-in`: close → wide, revealing context.
5808    PullBackReveal,
5809    /// High and far, descending and closing on the subject. First sight of an
5810    /// area.
5811    EstablishingCrane,
5812    /// Constant-radius, constant-height arc around the subject.
5813    OrbitArc,
5814    /// Parallel dolly abeam a **moving** subject at constant offset — needs a
5815    /// compiler-known subject path (`DW0349`).
5816    SideTrack,
5817    /// Static placement solved so **both** subjects land on opposite thirds
5818    /// (Toric-space construction). Needs `subject_b`.
5819    TwoShot,
5820    /// Low, close, trailing a **moving** subject near ground level — needs a
5821    /// compiler-known subject path (`DW0349`).
5822    LowFollow,
5823}
5824
5825impl ShotStyle {
5826    /// The kebab token (diagnostics, digests).
5827    pub fn token(self) -> &'static str {
5828        match self {
5829            ShotStyle::Insert => "insert",
5830            ShotStyle::LockedOff => "locked-off",
5831            ShotStyle::PushIn => "push-in",
5832            ShotStyle::PullBackReveal => "pull-back-reveal",
5833            ShotStyle::EstablishingCrane => "establishing-crane",
5834            ShotStyle::OrbitArc => "orbit-arc",
5835            ShotStyle::SideTrack => "side-track",
5836            ShotStyle::TwoShot => "two-shot",
5837            ShotStyle::LowFollow => "low-follow",
5838        }
5839    }
5840
5841    /// Default shot duration in seconds, from the dossier's per-style duration
5842    /// ranges (§2, anchored on the film-editing ASL literature) — the value an
5843    /// omitted `seconds` resolves to.
5844    pub fn default_seconds(self) -> u32 {
5845        match self {
5846            ShotStyle::Insert => 2,
5847            ShotStyle::LockedOff => 6,
5848            ShotStyle::PushIn => 4,
5849            ShotStyle::PullBackReveal => 6,
5850            ShotStyle::EstablishingCrane => 8,
5851            ShotStyle::OrbitArc => 8,
5852            ShotStyle::SideTrack => 8,
5853            ShotStyle::TwoShot => 5,
5854            ShotStyle::LowFollow => 5,
5855        }
5856    }
5857
5858    /// `true` for the styles whose subject must be *moving* on a compiler-known
5859    /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
5860    /// `side-track` and `low-follow` (`DW0349` otherwise).
5861    pub fn needs_moving_subject(self) -> bool {
5862        matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
5863    }
5864}
5865
5866/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
5867/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
5868/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
5869/// **plus one block up** (torso height, so a close shot does not frame feet)
5870/// before `offset` is applied; an `anchor` subject aims at the block centre
5871/// exactly like a [`CameraTarget`].
5872/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
5873/// — serde has no variant-level `deny_unknown_fields`, so an untagged
5874/// enum with inline struct variants silently *ignores* any key it does not
5875/// recognise: `{"npc": …, "ofset": [0,1,0]}` would deserialize happily with the
5876/// offset dropped, and `{"anchor": …, "npc": …}` would quietly match `Anchor`
5877/// and discard the NPC. Lifting each variant into its own type keeps the repo-wide
5878/// deny-unknown rule for both serde and the published JSON Schema
5879/// (`additionalProperties: false`): a mistyped shot subject fails the schema
5880/// instead of rendering a shot pointed somewhere the author never asked for.
5881#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5882#[serde(untagged)]
5883pub enum CameraSubject {
5884    /// A fixed world point: prefab anchor + offset.
5885    Anchor(Mark),
5886    /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
5887    /// group / sequence, else static at its declared (or spawn) anchor.
5888    Npc(NpcSubject),
5889    /// A stage-5 actor — moving if a `move-actor` for it runs in the same
5890    /// effect group / sequence, else static at its declared anchor.
5891    Actor(ActorSubject),
5892}
5893
5894/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
5895#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5896#[serde(deny_unknown_fields)]
5897pub struct NpcSubject {
5898    /// The NPC (stage-2 ref).
5899    pub npc: NpcId,
5900    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
5901    #[serde(default, skip_serializing_if = "is_zero3")]
5902    pub offset: [i32; 3],
5903}
5904
5905/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
5906#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5907#[serde(deny_unknown_fields)]
5908pub struct ActorSubject {
5909    /// The actor (stage-5 `actors` ref).
5910    pub actor: ActorId,
5911    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
5912    #[serde(default, skip_serializing_if = "is_zero3")]
5913    pub offset: [i32; 3],
5914}
5915
5916impl CameraSubject {
5917    /// The subject's integer offset, whichever variant.
5918    pub fn offset(&self) -> [i32; 3] {
5919        match self {
5920            CameraSubject::Anchor(s) => s.offset,
5921            CameraSubject::Npc(s) => s.offset,
5922            CameraSubject::Actor(s) => s.offset,
5923        }
5924    }
5925
5926    /// A short canonical rendering for digests/diagnostics.
5927    pub fn canon(&self) -> String {
5928        let (kind, id, o) = match self {
5929            CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
5930            CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
5931            CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
5932        };
5933        format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
5934    }
5935}
5936
5937/// `Debug` is hand-written because it is a **stable content-key rendering**:
5938/// the compiler's `payload_verb_key` (FNV over a verb's own `{:?}`) names the
5939/// generated `volley_`, `collapse_` and `teleport_` functions from it, so a shot
5940/// that uses none of the v0.6 style fields must render byte-identically to the
5941/// pre-style struct (`seconds` prints its inner value; absent style fields print
5942/// nothing) — otherwise a purely additive schema change would silently churn the
5943/// content key of every payload that carries a cutscene.
5944impl std::fmt::Debug for CameraShot {
5945    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5946        let mut d = f.debug_struct("CameraShot");
5947        d.field("path", &self.path);
5948        match self.seconds {
5949            Some(v) => d.field("seconds", &v),
5950            None => d.field("seconds", &self.seconds),
5951        };
5952        d.field("look_at", &self.look_at);
5953        if self.shot_style.is_some() {
5954            d.field("shot_style", &self.shot_style);
5955        }
5956        if self.subject.is_some() {
5957            d.field("subject", &self.subject);
5958        }
5959        if self.subject_b.is_some() {
5960            d.field("subject_b", &self.subject_b);
5961        }
5962        if self.dist.is_some() {
5963            d.field("dist", &self.dist);
5964        }
5965        if self.degrees.is_some() {
5966            d.field("degrees", &self.degrees);
5967        }
5968        if self.bearing.is_some() {
5969            d.field("bearing", &self.bearing);
5970        }
5971        d.finish()
5972    }
5973}
5974
5975impl CameraShot {
5976    /// The shot's resolved duration in seconds: explicit `seconds`, else the
5977    /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
5978    /// fallback only keeps downstream passes total).
5979    pub fn resolved_seconds(&self) -> u32 {
5980        self.seconds
5981            .or(self.shot_style.map(ShotStyle::default_seconds))
5982            .unwrap_or(1)
5983    }
5984}
5985
5986/// serde `skip_serializing_if` helper: skip a `[0, 0, 0]` offset.
5987fn is_zero3(v: &[i32; 3]) -> bool {
5988    *v == [0, 0, 0]
5989}
5990
5991impl Verb {
5992    /// The kebab-case `type` tag this verb serializes as — the verb a
5993    /// diagnostic should name so the author can find it in the JSON.
5994    pub fn tag(&self) -> &'static str {
5995        match self {
5996            Verb::OpenGate { .. } => "open-gate",
5997            Verb::CloseGate { .. } => "close-gate",
5998            Verb::CampaignComplete { .. } => "campaign-complete",
5999            Verb::GiveItem { .. } => "give-item",
6000            Verb::SetFlag { .. } => "set-flag",
6001            Verb::SetState { .. } => "set-state",
6002            Verb::AddState { .. } => "add-state",
6003            Verb::ClearState { .. } => "clear-state",
6004            Verb::DropStake { .. } => "drop-stake",
6005            Verb::SpawnWave { .. } => "spawn-wave",
6006            Verb::Narrate { .. } => "narrate",
6007            Verb::SetBlock { .. } => "set-block",
6008            Verb::FillRegion { .. } => "fill-region",
6009            Verb::ClearRegion { .. } => "clear-region",
6010            Verb::OpenWay { .. } => "open-way",
6011            Verb::DespawnNpc { .. } => "despawn-npc",
6012            Verb::MoveNpc { .. } => "move-npc",
6013            Verb::Cutscene { .. } => "cutscene",
6014            Verb::SetTime { .. } => "set-time",
6015            Verb::SetWeather { .. } => "set-weather",
6016            Verb::PlaySound { .. } => "play-sound",
6017            Verb::DamagePlayers { .. } => "damage-players",
6018            Verb::SetCheckpoint { .. } => "set-checkpoint",
6019            Verb::Bonfire { .. } => "bonfire",
6020            Verb::BeginStealth { .. } => "begin-stealth",
6021            Verb::EndStealth => "end-stealth",
6022            Verb::SpawnNpc { .. } => "spawn-npc",
6023            Verb::SpawnActor { .. } => "spawn-actor",
6024            Verb::DespawnActor { .. } => "despawn-actor",
6025            Verb::MoveActor { .. } => "move-actor",
6026            Verb::UnleashActor { .. } => "unleash-actor",
6027            Verb::Sequence { .. } => "sequence",
6028            Verb::Volley { .. } => "volley",
6029            Verb::Collapse { .. } => "collapse",
6030            Verb::GiveEffect { .. } => "give-effect",
6031            Verb::ClearEffect { .. } => "clear-effect",
6032            Verb::Teleport { .. } => "teleport",
6033            Verb::Firework { .. } => "firework",
6034        }
6035    }
6036}
6037
6038impl QuestEffect {
6039    /// The gate anchor if this is `open-gate`.
6040    pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
6041        match &self.verb {
6042            Verb::OpenGate { anchor, .. } => Some(anchor),
6043            _ => None,
6044        }
6045    }
6046
6047    /// The gate anchor if this is `close-gate` (DSL v0.6).
6048    pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
6049        match &self.verb {
6050            Verb::CloseGate { anchor, .. } => Some(anchor),
6051            _ => None,
6052        }
6053    }
6054
6055    /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
6056    /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
6057    /// the compiler's canonical English seal line.
6058    pub fn close_gate_sealed_hint(&self) -> Option<&str> {
6059        match &self.verb {
6060            Verb::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
6061            _ => None,
6062        }
6063    }
6064
6065    /// The wave id if this is `spawn-wave` (v0.3).
6066    pub fn spawn_wave(&self) -> Option<&WaveId> {
6067        match &self.verb {
6068            Verb::SpawnWave { wave, .. } => Some(wave),
6069            _ => None,
6070        }
6071    }
6072
6073    /// The flag id if this is `set-flag` (v0.3).
6074    pub fn set_flag(&self) -> Option<&FlagId> {
6075        match &self.verb {
6076            Verb::SetFlag { flag, .. } => Some(flag),
6077            _ => None,
6078        }
6079    }
6080
6081    /// The item id if this is `give-item` (v0.3).
6082    pub fn give_item(&self) -> Option<&str> {
6083        match &self.verb {
6084            Verb::GiveItem { item, .. } => Some(item),
6085            _ => None,
6086        }
6087    }
6088
6089    /// `true` if this is a `give-item` carrying a v0.4 display `name`.
6090    pub fn give_item_named(&self) -> bool {
6091        matches!(self.verb, Verb::GiveItem { name: Some(_), .. })
6092    }
6093
6094    /// The declared `carrier` if this is a `give-item` that states one (v0.6,
6095    /// spec-0018). `None` for any other effect **and** for a `give-item` that
6096    /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
6097    /// matters only for the pre-0.6 reserved-field gate.
6098    pub fn give_carrier(&self) -> Option<Carrier> {
6099        match &self.verb {
6100            Verb::GiveItem { carrier, .. } => *carrier,
6101            _ => None,
6102        }
6103    }
6104
6105    /// Does this `give-item` hand a single copy to the acting player (v0.6,
6106    /// spec-0018)? `false` for every other effect and for the party-wide default.
6107    pub fn gives_to_one(&self) -> bool {
6108        matches!(self.give_carrier(), Some(Carrier::One))
6109    }
6110
6111    /// The `set-block` block id if this is a v0.4 `set-block` effect.
6112    pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
6113        match &self.verb {
6114            Verb::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
6115            _ => None,
6116        }
6117    }
6118
6119    /// The NPC id if this is a v0.4 `despawn-npc` effect.
6120    pub fn despawn_npc(&self) -> Option<&NpcId> {
6121        match &self.verb {
6122            Verb::DespawnNpc { npc, .. } => Some(npc),
6123            _ => None,
6124        }
6125    }
6126
6127    /// `(npc, to)` if this is a v0.4 `move-npc` effect.
6128    pub fn move_npc(&self) -> Option<(&NpcId, &Mark)> {
6129        match &self.verb {
6130            Verb::MoveNpc { npc, to, .. } => Some((npc, to)),
6131            _ => None,
6132        }
6133    }
6134
6135    /// The v0.3 effect name if this effect is one introduced in DSL v0.3
6136    /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
6137    ///.
6138    pub fn v03_effect(&self) -> Option<&'static str> {
6139        match &self.verb {
6140            Verb::GiveItem { .. } => Some("give-item"),
6141            Verb::SetFlag { .. } => Some("set-flag"),
6142            Verb::SpawnWave { .. } => Some("spawn-wave"),
6143            Verb::OpenGate { .. }
6144            | Verb::CloseGate { .. }
6145            | Verb::CampaignComplete { .. } => None,
6146            // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
6147            // are not v0.3 verbs.
6148            Verb::Narrate { .. }
6149            | Verb::SetBlock { .. }
6150            | Verb::DespawnNpc { .. }
6151            | Verb::MoveNpc { .. }
6152            | Verb::Cutscene { .. }
6153            | Verb::SetTime { .. }
6154            | Verb::SetWeather { .. }
6155            | Verb::PlaySound { .. }
6156            | Verb::DamagePlayers { .. }
6157            | Verb::SetCheckpoint { .. }
6158            | Verb::Bonfire { .. }
6159            | Verb::BeginStealth { .. }
6160            | Verb::EndStealth
6161            | Verb::SpawnActor { .. }
6162            | Verb::DespawnActor { .. }
6163            | Verb::MoveActor { .. }
6164            | Verb::UnleashActor { .. }
6165            | Verb::SpawnNpc { .. }
6166            | Verb::Sequence { .. }
6167            // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
6168            | Verb::Volley { .. }
6169            | Verb::Collapse { .. }
6170            // spec-0031's state verbs, region writes, status effects and teleport,
6171            // and spec-0032's `drop-stake`, are all v0.10 — they report via
6172            // `v10_effect`.
6173            | Verb::SetState { .. }
6174            | Verb::AddState { .. }
6175            | Verb::ClearState { .. }
6176            | Verb::FillRegion { .. }
6177            | Verb::ClearRegion { .. }
6178            // spec-0042's `open-way` is v0.12 — it reports via `v12_effect`.
6179            | Verb::OpenWay { .. }
6180            | Verb::GiveEffect { .. }
6181            | Verb::ClearEffect { .. }
6182            | Verb::Teleport { .. }
6183            // spec-0068's `firework` is v0.29.
6184            | Verb::Firework { .. }
6185            | Verb::DropStake { .. } => None,
6186        }
6187    }
6188
6189    /// The v0.4 effect name if this effect is one introduced in DSL v0.4
6190    /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
6191    /// in v0.4 campaigns.
6192    pub fn v04_effect(&self) -> Option<&'static str> {
6193        match &self.verb {
6194            Verb::Narrate { .. } => Some("narrate"),
6195            Verb::SetBlock { .. } => Some("set-block"),
6196            Verb::DespawnNpc { .. } => Some("despawn-npc"),
6197            Verb::MoveNpc { .. } => Some("move-npc"),
6198            Verb::Cutscene { .. } => Some("cutscene"),
6199            _ => None,
6200        }
6201    }
6202
6203    /// The v0.5 effect name if this effect is one introduced in DSL v0.5
6204    /// (`set-time`/`set-weather`, spec-0010).
6205    pub fn v05_effect(&self) -> Option<&'static str> {
6206        match &self.verb {
6207            Verb::SetTime { .. } => Some("set-time"),
6208            Verb::SetWeather { .. } => Some("set-weather"),
6209            _ => None,
6210        }
6211    }
6212
6213    /// The v0.6 effect name if this effect is one introduced in DSL v0.6
6214    /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
6215    /// `play-sound`, spec-0014; the scripted-actor staging verbs
6216    /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
6217    /// spec-0014). These validate in v0.6 campaigns
6218    /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
6219    /// — see [`QuestEffect::narrate_art`] — not a new effect.)
6220    pub fn v06_effect(&self) -> Option<&'static str> {
6221        match &self.verb {
6222            Verb::CloseGate { .. } => Some("close-gate"),
6223            Verb::SetCheckpoint { .. } => Some("set-checkpoint"),
6224            Verb::Bonfire { .. } => Some("bonfire"),
6225            Verb::BeginStealth { .. } => Some("begin-stealth"),
6226            Verb::EndStealth => Some("end-stealth"),
6227            Verb::PlaySound { .. } => Some("play-sound"),
6228            Verb::DamagePlayers { .. } => Some("damage-players"),
6229            Verb::SpawnActor { .. } => Some("spawn-actor"),
6230            Verb::DespawnActor { .. } => Some("despawn-actor"),
6231            Verb::MoveActor { .. } => Some("move-actor"),
6232            Verb::UnleashActor { .. } => Some("unleash-actor"),
6233            Verb::Sequence { .. } => Some("sequence"),
6234            Verb::SpawnNpc { .. } => Some("spawn-npc"),
6235            // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
6236            Verb::Volley { .. } => Some("volley"),
6237            Verb::Collapse { .. } => Some("collapse"),
6238            _ => None,
6239        }
6240    }
6241
6242    /// The v0.10 effect name if this effect is one introduced in DSL v0.10
6243    /// (`set-state`/`add-state`/`clear-state` and the region writes
6244    /// `fill-region`/`clear-region`, spec-0031). These validate in v0.10
6245    /// campaigns.
6246    pub fn v10_effect(&self) -> Option<&'static str> {
6247        match &self.verb {
6248            Verb::SetState { .. } => Some("set-state"),
6249            Verb::AddState { .. } => Some("add-state"),
6250            Verb::ClearState { .. } => Some("clear-state"),
6251            Verb::FillRegion { .. } => Some("fill-region"),
6252            Verb::ClearRegion { .. } => Some("clear-region"),
6253            Verb::GiveEffect { .. } => Some("give-effect"),
6254            Verb::ClearEffect { .. } => Some("clear-effect"),
6255            Verb::Teleport { .. } => Some("teleport"),
6256            Verb::DropStake { .. } => Some("drop-stake"),
6257            _ => None,
6258        }
6259    }
6260
6261    /// The v0.12 effect name if this effect is one introduced in DSL v0.12
6262    /// (`open-way`, spec-0042).
6263    pub fn v12_effect(&self) -> Option<&'static str> {
6264        match &self.verb {
6265            Verb::OpenWay { .. } => Some("open-way"),
6266            _ => None,
6267        }
6268    }
6269
6270    /// **The way this effect opens** (DSL v0.12, spec-0042): the placed piece and
6271    /// the name of one way that piece's spatial contract exports.
6272    ///
6273    /// The third spelling of the one region write, beside
6274    /// [`QuestEffect::region_write`] (the author's own box) and
6275    /// [`QuestEffect::gate_region_write`] (a gate anchor's box). It answers with a
6276    /// *reference* and never with geometry, because the geometry is not the
6277    /// campaign's to state: the cells, the block and the direction all live in the
6278    /// piece's metadata, and the compiler resolves them there
6279    /// (`compiler::ways`). A region-shaped accessor here would be the second
6280    /// authority this surface exists to avoid.
6281    pub fn way_write(&self) -> Option<(&PrefabId, &str)> {
6282        match &self.verb {
6283            Verb::OpenWay { piece, way, .. } => Some((piece, way.as_str())),
6284            _ => None,
6285        }
6286    }
6287
6288    /// **The one region write**, whichever verb spelled it (DSL v0.10,
6289    /// spec-0031): the box to write and the block to write it with — `None` for
6290    /// the block meaning *clear to air*.
6291    ///
6292    /// This is the accessor the capability belongs to. It answers for
6293    /// `fill-region` / `clear-region`, which name their own box; `open-gate` /
6294    /// `close-gate` are the same operation over a box a prefab gate anchor
6295    /// declares, so they answer through
6296    /// [`QuestEffect::gate_region_write`](Self::gate_region_write) — the anchor
6297    /// is theirs, the *operation* is shared.
6298    pub fn region_write(&self) -> Option<(&StealthZone, Option<&str>)> {
6299        match &self.verb {
6300            Verb::FillRegion { region, block, .. } => Some((region, Some(block.as_str()))),
6301            Verb::ClearRegion { region, .. } => Some((region, None)),
6302            _ => None,
6303        }
6304    }
6305
6306    /// The gate anchor this effect writes, and whether the write **fills** it:
6307    /// `Some((anchor, true))` for `close-gate`, `Some((anchor, false))` for
6308    /// `open-gate`, `None` for everything else.
6309    ///
6310    /// The gate half of [`QuestEffect::region_write`]: same operation, but the box
6311    /// and the fill block come from the prefab's gate anchor rather than from the
6312    /// author. Everything that reasons about runtime region writes reads both
6313    /// accessors and nothing else.
6314    pub fn gate_region_write(&self) -> Option<(&AnchorId, bool)> {
6315        match &self.verb {
6316            Verb::CloseGate { anchor, .. } => Some((anchor, true)),
6317            Verb::OpenGate { anchor, .. } => Some((anchor, false)),
6318            _ => None,
6319        }
6320    }
6321
6322    /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
6323    /// `volley` (spec-0022), with the documented defaults already applied.
6324    pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
6325        match &self.verb {
6326            Verb::Volley {
6327                projectile,
6328                from_anchor,
6329                kill_zone,
6330                salvos,
6331                interval,
6332                ..
6333            } => Some((
6334                projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
6335                from_anchor,
6336                kill_zone,
6337                salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
6338                interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
6339            )),
6340            _ => None,
6341        }
6342    }
6343
6344    /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
6345    /// (spec-0022), with the documented default already applied.
6346    pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
6347        match &self.verb {
6348            Verb::Collapse {
6349                region_anchor,
6350                falling_block,
6351                then_floor,
6352                ..
6353            } => Some((
6354                region_anchor,
6355                falling_block
6356                    .as_deref()
6357                    .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
6358                then_floor.as_deref(),
6359            )),
6360            _ => None,
6361        }
6362    }
6363
6364    /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
6365    /// [`QuestEffect::despawn_npc`]).
6366    pub fn spawn_npc(&self) -> Option<&NpcId> {
6367        match &self.verb {
6368            Verb::SpawnNpc { npc, .. } => Some(npc),
6369            _ => None,
6370        }
6371    }
6372
6373    /// **The body this effect puts into the world**, by id, for every body class
6374    /// alike ([`BodyRef`]).
6375    ///
6376    /// `spawn-npc` and `spawn-actor` are the two, and they are answered in one
6377    /// place so a rule about a body's lifetime quantifies over bodies rather
6378    /// than over the verb that first needed it. A body's OTHER entry — standing
6379    /// on its mark from world init — is not an effect at all and is
6380    /// [`BodyRef::at_world_init`].
6381    ///
6382    /// `unleash-actor` is deliberately not an entry: it puts no new body on the
6383    /// mark, it replaces the one already standing there (see [`Self::body_exit`]).
6384    pub fn body_entry(&self) -> Option<&str> {
6385        match &self.verb {
6386            Verb::SpawnNpc { npc, .. } => Some(npc.as_str()),
6387            Verb::SpawnActor { actor, .. } => Some(actor.as_str()),
6388            _ => None,
6389        }
6390    }
6391
6392    /// **The body this effect takes out of the world**, by id, for every body
6393    /// class alike.
6394    ///
6395    /// `despawn-npc` and `despawn-actor` remove the body outright.
6396    /// `unleash-actor` is the third: it kills the staged puppet and stands a
6397    /// real-AI twin in its place, and from that moment the compiler makes no
6398    /// claim about where that body is or whether it is still alive — the twin
6399    /// walks, fights and dies under vanilla AI. Answering all three here is what
6400    /// keeps "can this body still be standing?" from being decided one verb at a
6401    /// time.
6402    ///
6403    /// Deliberately NOT an exit: `move-npc` / `move-actor`. A walked body is
6404    /// still in the world, and its declared mark is still the cell the engine
6405    /// summoned it onto — a mark two live bodies share is shared whether or not
6406    /// one of them has since walked off it.
6407    pub fn body_exit(&self) -> Option<&str> {
6408        match &self.verb {
6409            Verb::DespawnNpc { npc, .. } => Some(npc.as_str()),
6410            Verb::DespawnActor { actor, .. } | Verb::UnleashActor { actor, .. } => {
6411                Some(actor.as_str())
6412            }
6413            _ => None,
6414        }
6415    }
6416
6417    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
6418    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
6419        match &self.verb {
6420            Verb::SetCheckpoint { anchor, on_respawn } => Some((anchor, on_respawn.as_slice())),
6421            _ => None,
6422        }
6423    }
6424
6425    /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
6426    pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
6427        match &self.verb {
6428            Verb::Bonfire {
6429                anchor, on_rest, ..
6430            } => Some((anchor, on_rest.as_slice())),
6431            _ => None,
6432        }
6433    }
6434
6435    /// The bonfire's authored rest-dialog strings, each `None` when unauthored
6436    /// (the compiler then bakes its canonical English). `None` for every other
6437    /// effect (spec-0016 §1).
6438    pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
6439        match &self.verb {
6440            Verb::Bonfire {
6441                prompt,
6442                rest_label,
6443                save_label,
6444                ..
6445            } => Some(BonfireLabels {
6446                prompt: prompt.as_deref(),
6447                rest_label: rest_label.as_deref(),
6448                save_label: save_label.as_deref(),
6449            }),
6450            _ => None,
6451        }
6452    }
6453
6454    /// The `within` filter zone if this is a v0.6 `damage-players` effect that
6455    /// declares one (the `in` spatial scope). `None` for an unscoped
6456    /// `damage-players` and for every other effect.
6457    pub fn damage_within(&self) -> Option<&StealthZone> {
6458        match &self.verb {
6459            Verb::DamagePlayers { within, .. } => within.as_ref(),
6460            _ => None,
6461        }
6462    }
6463
6464    /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
6465    pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
6466        match &self.verb {
6467            Verb::BeginStealth {
6468                zones,
6469                on_caught,
6470                grace_ticks,
6471            } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
6472            _ => None,
6473        }
6474    }
6475
6476    /// The target time if this is a v0.5 `set-time` effect.
6477    pub fn set_time(&self) -> Option<WorldTime> {
6478        match &self.verb {
6479            Verb::SetTime { time, .. } => Some(*time),
6480            _ => None,
6481        }
6482    }
6483
6484    /// The target weather if this is a v0.5 `set-weather` effect.
6485    pub fn set_weather(&self) -> Option<WorldWeather> {
6486        match &self.verb {
6487            Verb::SetWeather { weather, .. } => Some(*weather),
6488            _ => None,
6489        }
6490    }
6491
6492    /// The effect lists nested one level inside this effect (DSL v0.6): a
6493    /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
6494    /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
6495    /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
6496    ///
6497    /// This is the **single authority** on effect nesting. Every deep traversal —
6498    /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
6499    /// string inventory, and emission — walks the tree through it (see
6500    /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
6501    /// once and no walker can silently miss a list (the class of bug where a
6502    /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
6503    pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
6504        match &self.verb {
6505            Verb::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
6506            Verb::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
6507            Verb::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
6508            Verb::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
6509            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
6510                vec![on_arrive.as_slice()]
6511            }
6512            _ => Vec::new(),
6513        }
6514    }
6515
6516    /// Visit `self` and every transitively nested effect (depth-first, pre-order),
6517    /// descending through [`Self::nested_effect_lists`].
6518    pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
6519        f(self);
6520        for list in self.nested_effect_lists() {
6521            for e in list {
6522                e.visit_deep(f);
6523            }
6524        }
6525    }
6526
6527    /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
6528    /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
6529    /// exposed mutably so the localization pass can rewrite nested player-visible
6530    /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
6531    /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
6532    /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
6533    /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
6534    /// derived segments make every derived key deterministic and stable across
6535    /// builds (ADR-0006 byte-identity).
6536    pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
6537        match &mut self.verb {
6538            Verb::Sequence { steps } => steps
6539                .iter_mut()
6540                .enumerate()
6541                .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
6542                .collect(),
6543            Verb::SetCheckpoint { on_respawn, .. } => {
6544                vec![("respawn".to_string(), on_respawn.as_mut_slice())]
6545            }
6546            Verb::Bonfire { on_rest, .. } => {
6547                vec![("rest".to_string(), on_rest.as_mut_slice())]
6548            }
6549            Verb::BeginStealth { on_caught, .. } => {
6550                vec![("caught".to_string(), on_caught.as_mut_slice())]
6551            }
6552            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
6553                vec![("arrive".to_string(), on_arrive.as_mut_slice())]
6554            }
6555            _ => Vec::new(),
6556        }
6557    }
6558
6559    /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
6560    /// exposes the **JSON-pointer path segment** for each nested list, so a deep
6561    /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
6562    /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
6563    /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
6564    /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
6565    /// (same lists, same order): the l10n key segments match
6566    /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
6567    /// `arrive`), and the path segments name the real fields
6568    /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
6569    pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
6570        match &self.verb {
6571            Verb::Sequence { steps } => steps
6572                .iter()
6573                .enumerate()
6574                .map(|(s, st)| {
6575                    (
6576                        format!("steps/{s}/effects"),
6577                        format!("seq.{s}"),
6578                        st.effects.as_slice(),
6579                    )
6580                })
6581                .collect(),
6582            Verb::SetCheckpoint { on_respawn, .. } => vec![(
6583                "on_respawn".to_string(),
6584                "respawn".to_string(),
6585                on_respawn.as_slice(),
6586            )],
6587            Verb::Bonfire { on_rest, .. } => vec![(
6588                "on_rest".to_string(),
6589                "rest".to_string(),
6590                on_rest.as_slice(),
6591            )],
6592            Verb::BeginStealth { on_caught, .. } => vec![(
6593                "on_caught".to_string(),
6594                "caught".to_string(),
6595                on_caught.as_slice(),
6596            )],
6597            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
6598                vec![(
6599                    "on_arrive".to_string(),
6600                    "arrive".to_string(),
6601                    on_arrive.as_slice(),
6602                )]
6603            }
6604            _ => Vec::new(),
6605        }
6606    }
6607
6608    /// Every world anchor this effect names **at this node** — the single
6609    /// authority on the anchor-bearing effect surface, the referential sibling of
6610    /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
6611    /// where the suffix is appended to the effect's own JSON pointer
6612    /// (`anchor`, `to/anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
6613    /// `shots/<i>/path/<j>/anchor`, …).
6614    ///
6615    /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
6616    /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
6617    /// reference scan, the compiler's build-time resolution seal (`DW0355`) —
6618    /// goes through here, so a new anchor-bearing variant (or a new anchor field
6619    /// on an existing one) is picked up by all of them at once. That closes the
6620    /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
6621    /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
6622    /// found no match) while every shallow validator looked straight past it.
6623    ///
6624    /// # The third element is the SHAPE the site demands
6625    ///
6626    /// A reference is not only a name: `close-gate` fills and clears a region and
6627    /// `bonfire` seats a body, and until spec-0052 those two sat in one match arm
6628    /// with nothing recording the difference. The demand rides with the reference
6629    /// so that a new anchor-bearing variant cannot be added without stating what
6630    /// it does with the anchor — the same reason this is one authority and not
6631    /// three walks.
6632    ///
6633    /// **A demand is stated only where the shape is structurally required**, and
6634    /// the line is drawn where this engine's own `DW0845` already draws it — *a
6635    /// region is not a place to stand*:
6636    ///
6637    /// * [`StationKind::Gate`] where the verb cannot function without a region
6638    ///   that seals and clears: the two gate verbs. A point is not a gate, and
6639    ///   `gate_region_block_any` finds nothing for one.
6640    /// * [`StationKind::Point`] where a **body is put**: a checkpoint seat, a
6641    ///   bonfire, a `move-npc`/`move-actor` destination, a `teleport`
6642    ///   destination. A body cannot stand inside bars.
6643    /// * `None` wherever a reference merely names a **location** — every
6644    ///   anchor-centred volume's centre, every camera field, a `set-block`, a
6645    ///   `play-sound`. A gate region's own corner is a perfectly good answer
6646    ///   there, and refusing it would refuse correct content.
6647    ///
6648    /// That last case is not a gap left for later. An earlier draft of this rule
6649    /// demanded a point at every non-gate site, and the gallery refused to
6650    /// compile: `trigger/east-door-wrong-side` is a `use` trigger sitting on the
6651    /// very `anchor/seam-…` of a barred door so that it can say "the east door
6652    /// does not open from this side". A check that resolves against a smaller
6653    /// world than the campaign has refuses CONTENT, which is the lesson `DW0343`
6654    /// carries three files away.
6655    pub fn anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
6656        // Every camera field is a point: a shot flies through cells and looks at
6657        // one.
6658        /// `(suffix, anchor, kind)` for a shot's own anchor-bearing fields, under `base`.
6659        fn shot_refs<'a>(
6660            base: &str,
6661            shot: &'a CameraShot,
6662        ) -> Vec<(String, &'a AnchorId, Option<StationKind>)> {
6663            let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shot
6664                .path
6665                .iter()
6666                .enumerate()
6667                .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor, None))
6668                .collect();
6669            if let Some(t) = &shot.look_at {
6670                out.push((format!("{base}look_at/anchor"), &t.anchor, None));
6671            }
6672            for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
6673                if let Some(CameraSubject::Anchor(s)) = subject {
6674                    out.push((format!("{base}{field}/anchor"), &s.anchor, None));
6675                }
6676            }
6677            out
6678        }
6679        match &self.verb {
6680            // The two gate verbs address a REGION that seals and clears; the
6681            // three below them seat or write at a cell. They shared an arm until
6682            // the demand had somewhere to be written down.
6683            Verb::OpenGate { anchor, .. } | Verb::CloseGate { anchor, .. } => {
6684                vec![("anchor".to_string(), anchor, Some(StationKind::Gate))]
6685            }
6686            // A checkpoint and a bonfire are **respawn seats** — a body is put
6687            // there, so a region is not one. `set-block` writes a block at a
6688            // cell, which names a location and seats nothing.
6689            Verb::SetCheckpoint { anchor, .. } | Verb::Bonfire { anchor, .. } => {
6690                vec![("anchor".to_string(), anchor, Some(StationKind::Point))]
6691            }
6692            Verb::SetBlock { anchor, .. } => {
6693                vec![("anchor".to_string(), anchor, None)]
6694            }
6695            Verb::MoveNpc { to, .. } | Verb::MoveActor { to, .. } => {
6696                vec![(
6697                    "to/anchor".to_string(),
6698                    &to.anchor,
6699                    Some(StationKind::Point),
6700                )]
6701            }
6702            // The `in` filter is one capability on three verbs, so it registers
6703            // once: `damage-players` (v0.6) and the v0.10 status-effect pair.
6704            Verb::DamagePlayers {
6705                within: Some(zone), ..
6706            }
6707            | Verb::GiveEffect {
6708                within: Some(zone), ..
6709            }
6710            | Verb::ClearEffect {
6711                within: Some(zone), ..
6712            } => vec![("in/anchor".to_string(), &zone.anchor, None)],
6713            // Both of a `teleport`'s anchors are load-bearing — the source volume
6714            // decides WHAT moves and the destination decides WHERE — so a typo in
6715            // either is a dangling reference (`DW0142`), never a silently
6716            // zero-cell volume or a dropped command.
6717            Verb::Teleport { from, to, .. } => vec![
6718                ("from/anchor".to_string(), &from.anchor, None),
6719                (
6720                    "to/anchor".to_string(),
6721                    &to.anchor,
6722                    Some(StationKind::Point),
6723                ),
6724            ],
6725            Verb::BeginStealth { zones, .. } => zones
6726                .iter()
6727                .enumerate()
6728                .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor, None))
6729                .collect(),
6730            Verb::PlaySound {
6731                at: Some(SoundAt::Anchor { anchor, .. }),
6732                ..
6733            } => vec![("at/anchor".to_string(), anchor, None)],
6734            // A firework is launched from a point and seats nothing, so it names
6735            // a location in the same shape `play-sound` does.
6736            Verb::Firework { at, .. } => vec![("at/anchor".to_string(), &at.anchor, None)],
6737            // spec-0022 trap-payload verbs. Both anchors of a `volley` are
6738            // load-bearing for the coverage proof, so both register here — a
6739            // typo'd `kill_zone` must be a dangling-reference error, never a
6740            // silently zero-cell (and therefore vacuously "covered") volley.
6741            Verb::Volley {
6742                from_anchor,
6743                kill_zone,
6744                ..
6745            } => vec![
6746                ("from_anchor".to_string(), from_anchor, None),
6747                ("kill_zone/anchor".to_string(), &kill_zone.anchor, None),
6748            ],
6749            Verb::Collapse { region_anchor, .. } => {
6750                vec![(
6751                    "region_anchor/anchor".to_string(),
6752                    &region_anchor.anchor,
6753                    None,
6754                )]
6755            }
6756            // The v0.10 region writes: the box's anchor is load-bearing for both
6757            // the emission and the completability model, so a typo'd one must be a
6758            // dangling-reference error (`DW0142`/`DW0355`), never a silently
6759            // unwritten — and therefore vacuously proven — region.
6760            Verb::FillRegion { region, .. } | Verb::ClearRegion { region, .. } => {
6761                vec![("region/anchor".to_string(), &region.anchor, None)]
6762            }
6763            // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
6764            // multi-shot list, or the v0.4 single-shot fields flattened at the
6765            // effect's own level.
6766            Verb::Cutscene {
6767                shots,
6768                path,
6769                look_at,
6770                ..
6771            } => {
6772                let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shots
6773                    .iter()
6774                    .enumerate()
6775                    .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
6776                    .collect();
6777                out.extend(
6778                    path.iter()
6779                        .enumerate()
6780                        .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor, None)),
6781                );
6782                if let Some(t) = look_at {
6783                    out.push(("look_at/anchor".to_string(), &t.anchor, None));
6784                }
6785                out
6786            }
6787            _ => Vec::new(),
6788        }
6789    }
6790
6791    /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
6792    /// the v0.6 `look_at` field.
6793    pub fn cutscene_look_at(&self) -> Option<&Mark> {
6794        match &self.verb {
6795            Verb::Cutscene { look_at, .. } => look_at.as_ref(),
6796            _ => None,
6797        }
6798    }
6799
6800    /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
6801    ///.
6802    pub fn cutscene_multi_shot(&self) -> bool {
6803        matches!(&self.verb, Verb::Cutscene { shots, .. } if !shots.is_empty())
6804    }
6805
6806    /// The normalized shot list of a `cutscene`, whichever spelling was used: the
6807    /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
6808    /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
6809    /// whose shape is invalid (`DW0199` reports that).
6810    pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
6811        match &self.verb {
6812            Verb::Cutscene {
6813                shots,
6814                path,
6815                seconds,
6816                look_at,
6817                ..
6818            } => {
6819                if !shots.is_empty() {
6820                    return Some(shots.clone());
6821                }
6822                match seconds {
6823                    Some(secs) => Some(vec![CameraShot {
6824                        path: path.clone(),
6825                        seconds: Some(*secs),
6826                        look_at: look_at.clone(),
6827                        shot_style: None,
6828                        subject: None,
6829                        subject_b: None,
6830                        dist: None,
6831                        degrees: None,
6832                        bearing: None,
6833                    }]),
6834                    None => Some(Vec::new()),
6835                }
6836            }
6837            _ => None,
6838        }
6839    }
6840
6841    /// `true` if this is a `narrate` carrying the `art` style (glyph-checked
6842    /// `DW0328`).
6843    pub fn narrate_art(&self) -> bool {
6844        matches!(
6845            &self.verb,
6846            Verb::Narrate {
6847                style: Some(NarrateStyle::Art),
6848                ..
6849            }
6850        )
6851    }
6852
6853    /// The `narrate` line's text if this is a `narrate` with the `art` style.
6854    pub fn narrate_art_text(&self) -> Option<&str> {
6855        match &self.verb {
6856            Verb::Narrate {
6857                text,
6858                style: Some(NarrateStyle::Art),
6859                ..
6860            } => Some(text.as_str()),
6861            _ => None,
6862        }
6863    }
6864
6865    /// The `narrate` line's style and text if this is a `narrate` rendered **on
6866    /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
6867    /// styles vanilla draws centred and unwrapped, so their rendered width is
6868    /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
6869    /// is exempt.
6870    pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
6871        match &self.verb {
6872            Verb::Narrate {
6873                text,
6874                style: Some(s),
6875                ..
6876            } if matches!(
6877                s,
6878                NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
6879            ) =>
6880            {
6881                Some((*s, text.as_str()))
6882            }
6883            _ => None,
6884        }
6885    }
6886
6887    /// Every vanilla sound-event id this effect references, for registry
6888    /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
6889    /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
6890    /// within the effect (e.g. `sound`).
6891    pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
6892        match &self.verb {
6893            Verb::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
6894            Verb::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
6895            _ => Vec::new(),
6896        }
6897    }
6898
6899    /// The `play-sound` `at: actor` id, if this effect is a `play-sound`
6900    /// targeting an actor (rejected `DW0335`).
6901    pub fn play_sound_actor(&self) -> Option<&str> {
6902        match &self.verb {
6903            Verb::PlaySound {
6904                at: Some(SoundAt::Actor { actor }),
6905                ..
6906            } => Some(actor.as_str()),
6907            _ => None,
6908        }
6909    }
6910
6911    /// The per-effect flag gate: flags that must ALL be set (per party) for this
6912    /// effect to fire. Empty for an ungated effect. Read off the one [`Guard`], so
6913    /// **every** verb answers it — the staging and souls vocabulary included.
6914    pub fn requires_flags(&self) -> &[FlagId] {
6915        self.when.as_ref().map_or(&[][..], |g| &g.requires_flags)
6916    }
6917
6918    /// The per-effect **negative** flag gate: flags whose being set suppresses this
6919    /// effect — the dual of [`QuestEffect::requires_flags`], on the same guard.
6920    pub fn forbids_flags(&self) -> &[FlagId] {
6921        self.when.as_ref().map_or(&[][..], |g| &g.forbids_flags)
6922    }
6923
6924    /// The numeric gate terms (spec-0031) — the third axis of the same guard, so
6925    /// "which verbs are gatable" has exactly one answer.
6926    pub fn requires_state(&self) -> &[StateCompare] {
6927        self.when.as_ref().map_or(&[][..], |g| &g.requires_state)
6928    }
6929
6930    /// The datum this effect writes and how, if it is one of the DSL v0.10 state
6931    /// verbs (`set-state` / `add-state` / `clear-state`).
6932    pub fn writes_state(&self) -> Option<(&StateId, StateWrite)> {
6933        match &self.verb {
6934            Verb::SetState { state, value, .. } => Some((state, StateWrite::Set(*value))),
6935            Verb::AddState { state, amount, .. } => Some((state, StateWrite::Add(*amount))),
6936            Verb::ClearState { state, .. } => Some((state, StateWrite::Clear)),
6937            _ => None,
6938        }
6939    }
6940
6941    /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
6942    /// parity with `move-actor`). `None` for a bare `move-npc` and every other
6943    /// effect.
6944    pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
6945        match &self.verb {
6946            Verb::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => Some(on_arrive.as_slice()),
6947            _ => None,
6948        }
6949    }
6950
6951    /// The actor id this effect targets, if it is one of the actor staging effects
6952    /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
6953    /// no single actor (its nested effects each carry their own).
6954    pub fn actor_ref(&self) -> Option<&ActorId> {
6955        match &self.verb {
6956            Verb::SpawnActor { actor, .. }
6957            | Verb::DespawnActor { actor, .. }
6958            | Verb::MoveActor { actor, .. }
6959            | Verb::UnleashActor { actor, .. } => Some(actor),
6960            _ => None,
6961        }
6962    }
6963
6964    /// Every vanilla **status-effect** id this effect names, for registry
6965    /// validation (`DW0192`) — the sibling of [`Self::sound_refs`] and the single
6966    /// authority on the status-effect-bearing verb surface. `(subpath, id)`
6967    /// pairs; empty for a `clear-effect` that names none (which clears all).
6968    pub fn status_effect_refs(&self) -> Vec<(&'static str, &str)> {
6969        match &self.verb {
6970            Verb::GiveEffect { effect, .. } => vec![("effect", effect.as_str())],
6971            Verb::ClearEffect {
6972                effect: Some(e), ..
6973            } => vec![("effect", e.as_str())],
6974            _ => Vec::new(),
6975        }
6976    }
6977
6978    /// `(effect, seconds, amplifier, hide_particles, in)` if this is a
6979    /// `give-effect` (DSL v0.10), with the documented defaults already applied.
6980    pub fn give_effect(&self) -> Option<(&str, u32, u32, bool, Option<&StealthZone>)> {
6981        match &self.verb {
6982            Verb::GiveEffect {
6983                effect,
6984                seconds,
6985                amplifier,
6986                hide_particles,
6987                within,
6988                ..
6989            } => Some((
6990                effect.as_str(),
6991                *seconds,
6992                amplifier.unwrap_or(0),
6993                hide_particles.unwrap_or(false),
6994                within.as_ref(),
6995            )),
6996            _ => None,
6997        }
6998    }
6999
7000    /// `(effect, in)` if this is a `clear-effect` (DSL v0.10). The effect is
7001    /// `None` for the clear-everything form, exactly as vanilla spells it.
7002    pub fn clear_effect(&self) -> Option<(Option<&str>, Option<&StealthZone>)> {
7003        match &self.verb {
7004            Verb::ClearEffect { effect, within, .. } => Some((effect.as_deref(), within.as_ref())),
7005            _ => None,
7006        }
7007    }
7008
7009    /// `(from, to)` if this is a `teleport` (DSL v0.10): the source volume and
7010    /// the destination mark.
7011    pub fn teleport(&self) -> Option<(&StealthZone, &Mark)> {
7012        match &self.verb {
7013            Verb::Teleport { from, to, .. } => Some((from, to)),
7014            _ => None,
7015        }
7016    }
7017}
7018
7019// ---------------------------------------------------------------------------
7020// Stage 7 — world-edits (the map editor edit script, DSL v0.6, spec-0017)
7021// ---------------------------------------------------------------------------
7022
7023/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
7024///
7025/// The artifact of record for L3 world detailing: an ordered list of edit
7026/// batches the compiler replays deterministically **after** world assembly.
7027/// The world files are never truth — same DSL + same edits + same seed →
7028/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
7029/// builds byte-identically to one from before this stage existed.
7030#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7031#[serde(deny_unknown_fields)]
7032pub struct WorldEditsContent {
7033    /// Ordered edit batches, replayed in order. After every batch the post-edit
7034    /// invariants re-prove (walkability, sealing + relight, boundary safety),
7035    /// so each batch is a valid, snapshot-reviewable world state.
7036    pub batches: Vec<EditBatch>,
7037}
7038
7039/// One edit batch: an ordered group of edit verbs applied to a single area,
7040/// checked and snapshot-rendered as a unit.
7041#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7042#[serde(deny_unknown_fields)]
7043pub struct EditBatch {
7044    /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
7045    /// snapshot name and seed-stream label — renaming a batch deliberately
7046    /// reseeds its noise.
7047    pub id: EditBatchId,
7048    /// The stage-1 area this batch edits. Frames and regions resolve against
7049    /// this area's placed pieces and anchors.
7050    pub area: AreaId,
7051    /// Authoring context (why this batch exists). Machine-ignored; **excluded**
7052    /// from l10n like `theme`/`premise`.
7053    #[serde(default, skip_serializing_if = "Option::is_none")]
7054    pub note: Option<String>,
7055    /// Ordered edit verbs. `select` defines named regions; later verbs in the
7056    /// same batch refer back to them (strictly backward, like every DSL ref).
7057    pub edits: Vec<WorldEdit>,
7058}
7059
7060/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
7061/// seeded verb derives its noise stream from the campaign seed + its script
7062/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
7063#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7064#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
7065pub enum WorldEdit {
7066    /// Define a named region (`region/<kebab>`) for later verbs in this batch.
7067    Select {
7068        /// The region name being defined (unique within the batch).
7069        name: RegionId,
7070        /// The region's shape (box, surface band, palette match, or a
7071        /// composition of earlier regions).
7072        shape: RegionShape,
7073    },
7074    /// Fill every cell of a region from a seeded palette recipe (value-noise
7075    /// keyed, so picks cluster into patches — never a uniform fill).
7076    Fill {
7077        /// The region (an earlier `select` in this batch) to fill.
7078        region: RegionId,
7079        /// The palette recipe to fill with.
7080        recipe: PaletteRecipe,
7081    },
7082    /// Like `fill`, but only rewrites cells whose current block matches one of
7083    /// `matching` (base ids; blockstate suffixes are ignored when matching).
7084    Replace {
7085        /// The region (an earlier `select` in this batch) to edit.
7086        region: RegionId,
7087        /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
7088        matching: Vec<String>,
7089        /// The palette recipe to rewrite them with.
7090        recipe: PaletteRecipe,
7091    },
7092    /// Clear a region to air. Sealing-aware: the carved region re-enters the
7093    /// sealing + relight passes and every walkability invariant re-proves.
7094    Carve {
7095        /// The region (an earlier `select` in this batch) to clear.
7096        region: RegionId,
7097    },
7098    /// Reshape terrain surface within a region (raise / lower / smooth).
7099    Morph {
7100        /// The region (an earlier `select` in this batch) whose columns to
7101        /// reshape. The region defines the footprint and where each column's
7102        /// surface is read (the highest occupied cell in the region's y-range);
7103        /// `raise`/`smooth` may add cells above the region's top — reshaping
7104        /// upward is the point — while removal only touches region cells.
7105        region: RegionId,
7106        /// The surface operation.
7107        op: MorphOp,
7108    },
7109    /// Seeded dressing scatter (spec-0017): drop weighted single-block
7110    /// dressing (flora, rocks, props) onto standable cells of a region —
7111    /// air cells with an occupied cell directly below — honoring keep-clear
7112    /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
7113    /// speckle, not the fill verbs' clustered patches), deterministic from the
7114    /// campaign seed + script position.
7115    Scatter {
7116        /// The region (an earlier `select` in this batch) to dress.
7117        region: RegionId,
7118        /// Weighted dressing blocks (blockstate suffixes allowed).
7119        items: Vec<PaletteBlock>,
7120        /// Per-candidate placement probability in `(0, 1]`.
7121        density: f64,
7122        /// Keep-clear envelopes: earlier regions whose cells (and columns —
7123        /// matched by `(x, z)`) never receive dressing.
7124        #[serde(default, skip_serializing_if = "Vec::is_empty")]
7125        avoid: Vec<RegionId>,
7126        /// Optional minimum spacing: when set, candidates are taken in
7127        /// descending noise order and one is rejected while another accepted
7128        /// candidate is closer than this on **both** horizontal axes (the
7129        /// generators' spread rule).
7130        #[serde(default, skip_serializing_if = "Option::is_none")]
7131        spacing: Option<u32>,
7132        /// Optional cap on how many items are placed (highest-noise first).
7133        #[serde(default, skip_serializing_if = "Option::is_none")]
7134        limit: Option<u32>,
7135    },
7136    /// Structural flora (spec-0017): plant hand-shaped trees via the
7137    /// lean-or-grow canopy rules — a canopy that would reach a
7138    /// keep-clear (`avoid`) column first leans one block away from it; if that
7139    /// still covers the corridor the tree grows tall instead, arching its
7140    /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
7141    Plant {
7142        /// The region (an earlier `select` in this batch) to plant in. Trunk
7143        /// cells are standable region cells (air over an occupied cell).
7144        region: RegionId,
7145        /// The tree species (canopy shape rules are per-species).
7146        tree: TreeKind,
7147        /// How many trees to plant (≥ 1; highest-noise candidates first).
7148        count: u32,
7149        /// Keep-clear envelopes: trunks never stand in these columns and
7150        /// canopies lean/grow to clear them.
7151        #[serde(default, skip_serializing_if = "Vec::is_empty")]
7152        avoid: Vec<RegionId>,
7153        /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
7154        #[serde(default, skip_serializing_if = "Option::is_none")]
7155        spacing: Option<u32>,
7156    },
7157    /// Stamp a prefab fragment (spec-0017): copy a library prefab's
7158    /// non-air cells into the world at a frame-resolved position. The fragment
7159    /// is a first-class library prefab — its provenance/license metadata is
7160    /// recorded and validated exactly like any placed prefab (ADR-0013);
7161    /// nothing outside the library can be stamped.
7162    Fragment {
7163        /// The library prefab to stamp.
7164        prefab: PrefabId,
7165        /// The frame `at` resolves in.
7166        frame: EditFrame,
7167        /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
7168        at: [i32; 3],
7169        /// Placement rotation (default `none`), the same quarter-turn set as
7170        /// `/place template`.
7171        #[serde(default, skip_serializing_if = "Option::is_none")]
7172        rotation: Option<FragmentRotation>,
7173    },
7174    /// Explicit region relight (spec-0017, spec-0010 machinery): run the
7175    /// deterministic fixture-placement pass over ONE region and bake the
7176    /// resulting fixtures into the edit script's writes — authorial control of
7177    /// where fixtures land, instead of the whole-area pass's greedy siting.
7178    /// (The whole-area relight still re-proves after every batch either way.)
7179    Relight {
7180        /// The region (an earlier `select` in this batch) to relight: its
7181        /// reachable walkable cells are brought to `min_light`.
7182        region: RegionId,
7183        /// Fixture override; default = the area's declared `lighting.fixture`.
7184        #[serde(default, skip_serializing_if = "Option::is_none")]
7185        fixture: Option<Fixture>,
7186        /// Target light override (1..=14); default = the area's declared
7187        /// `lighting.min_light`. Required (with `fixture`) when the area
7188        /// declares no `lighting`.
7189        #[serde(default, skip_serializing_if = "Option::is_none")]
7190        min_light: Option<u8>,
7191    },
7192    /// L2 massing (spec-0017): replace a placed piece with another
7193    /// library prefab that re-mates every currently-mated socket at its exact
7194    /// world pose (any rotation; overlap-checked). Applied at **plan** time —
7195    /// the whole downstream ladder (anchors, gate reachability, assembly,
7196    /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
7197    /// verbs live in massing-only batches, ordered before every detailing
7198    /// batch.
7199    SwapPiece {
7200        /// The piece's placement index in its area's solved layout (0-based,
7201        /// entry first).
7202        piece: u32,
7203        /// The prefab the indexed piece must currently be (drift guard).
7204        prefab: PrefabId,
7205        /// The library prefab to swap in.
7206        with: PrefabId,
7207    },
7208    /// L2 massing (spec-0017): attach a new piece at a specific **open**
7209    /// (unmated) socket of an existing piece — the targeted form of the
7210    /// solver's frontier attach. The socket opens (its seal becomes a
7211    /// passage); the new piece's other sockets seal.
7212    InsertPiece {
7213        /// The host piece's placement index.
7214        at_piece: u32,
7215        /// The prefab the host piece must currently be (drift guard).
7216        prefab: PrefabId,
7217        /// The host's connector index (prefab metadata `connectors` order).
7218        socket: u32,
7219        /// The library prefab to attach.
7220        insert: PrefabId,
7221    },
7222    /// L2 massing (spec-0017): remove a **leaf** piece (exactly one
7223    /// mated socket; never the entry piece). The neighbour's socket unmates
7224    /// and re-seals. Removal shifts later placement indices — order removals
7225    /// before other index-referencing massing verbs.
7226    RemovePiece {
7227        /// The piece's placement index.
7228        piece: u32,
7229        /// The prefab the indexed piece must currently be (drift guard).
7230        prefab: PrefabId,
7231    },
7232    /// L2 massing (spec-0017): override one socket's seal — `open`
7233    /// clears the opening to a passage, `sealed` walls it up — independent of
7234    /// its mated state (sealing a mated doorway makes a wall between joined
7235    /// pieces; opening an unmated exterior socket exposes the outside, which
7236    /// the boundary-safety proof then judges).
7237    RewireSocket {
7238        /// The piece's placement index.
7239        piece: u32,
7240        /// The prefab the indexed piece must currently be (drift guard).
7241        prefab: PrefabId,
7242        /// The connector index (prefab metadata `connectors` order).
7243        socket: u32,
7244        /// The socket's new state.
7245        state: SocketState,
7246    },
7247    /// L2 massing (spec-0017): re-pick this piece from its area pool's
7248    /// compatible members (weighted, seeded from the campaign seed + this
7249    /// verb's script position — moving the verb deliberately re-rolls). The
7250    /// current prefab is excluded, so a reseed always changes the piece or
7251    /// errors loudly.
7252    ReseedPiece {
7253        /// The piece's placement index.
7254        piece: u32,
7255        /// The prefab the indexed piece must currently be (drift guard).
7256        prefab: PrefabId,
7257    },
7258}
7259
7260/// A socket seal state for `rewire-socket` (spec-0017).
7261#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7262#[serde(rename_all = "kebab-case")]
7263pub enum SocketState {
7264    /// The opening is cleared to a passage.
7265    Open,
7266    /// The opening is walled up.
7267    Sealed,
7268}
7269
7270/// A tree species for the `plant` verb (spec-0017). One species per
7271/// canopy-rule implementation; the shipped rule set is the lean-or-grow oak.
7272#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7273#[serde(rename_all = "kebab-case")]
7274pub enum TreeKind {
7275    /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
7276    /// lean-or-grow corridor rules.
7277    Oak,
7278}
7279
7280/// A `fragment` stamp rotation (spec-0017) — the `/place template`
7281/// quarter-turn set.
7282#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7283#[serde(rename_all = "kebab-case")]
7284pub enum FragmentRotation {
7285    /// No rotation.
7286    None,
7287    /// 90° clockwise.
7288    Clockwise90,
7289    /// 180°.
7290    Clockwise180,
7291    /// 90° counterclockwise.
7292    Counterclockwise90,
7293}
7294
7295/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
7296/// frame; compositions combine earlier regions of the same batch.
7297#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7298#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7299pub enum RegionShape {
7300    /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
7301    Box {
7302        /// The coordinate frame `min`/`max` resolve in.
7303        frame: EditFrame,
7304        /// Inclusive minimum corner (frame coordinates).
7305        min: [i32; 3],
7306        /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
7307        max: [i32; 3],
7308    },
7309    /// The band of cells at `from..=to` blocks relative to each column's
7310    /// terrain surface (the highest non-air cell of the column) within an
7311    /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
7312    /// surface; `from: 0, to: 0` is the surface cells themselves; negative
7313    /// offsets reach below the surface.
7314    SurfaceBand {
7315        /// The earlier region whose columns are scanned.
7316        over: RegionId,
7317        /// Inclusive band start, relative to each column's surface y.
7318        from: i32,
7319        /// Inclusive band end (≥ `from`), relative to each column's surface y.
7320        to: i32,
7321    },
7322    /// The cells of an earlier region whose current block matches one of
7323    /// `blocks` (base ids; blockstate suffixes ignored when matching).
7324    PaletteMatch {
7325        /// The earlier region to filter.
7326        within: RegionId,
7327        /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
7328        blocks: Vec<String>,
7329    },
7330    /// The union of earlier regions.
7331    Union {
7332        /// The earlier regions to unite (≥ 2).
7333        of: Vec<RegionId>,
7334    },
7335    /// The intersection of earlier regions.
7336    Intersect {
7337        /// The earlier regions to intersect (≥ 2).
7338        of: Vec<RegionId>,
7339    },
7340    /// An earlier region minus other earlier regions.
7341    Subtract {
7342        /// The earlier region to start from.
7343        base: RegionId,
7344        /// The earlier regions to remove from it (≥ 1).
7345        remove: Vec<RegionId>,
7346    },
7347}
7348
7349/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
7350/// piece-local or anchor-relative — never raw world coordinates, so an edit
7351/// script survives a layout's world placement moving.
7352#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7353#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7354pub enum EditFrame {
7355    /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
7356    /// the piece's structure origin, axes as authored (the compiler applies
7357    /// the piece's placed rotation).
7358    PieceLocal {
7359        /// The piece's placement index in the area's solved layout (0-based,
7360        /// entry piece first — the order `delvec snapshot`'s manifest lists).
7361        piece: u32,
7362        /// The prefab the indexed piece must be (a drift guard: if a re-solve
7363        /// changed the layout, the mismatch is a loud compile error, never a
7364        /// silently misplaced edit).
7365        prefab: PrefabId,
7366    },
7367    /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
7368    /// anchor cell, axes world-aligned.
7369    AnchorRelative {
7370        /// The anchor (prefab metadata, resolved by the compiler).
7371        anchor: AnchorId,
7372    },
7373}
7374
7375/// A surface operation for the `morph` verb (spec-0017).
7376#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7377#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7378pub enum MorphOp {
7379    /// Raise each column's surface by up to `by` blocks, drawing the added
7380    /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
7381    /// as natural strata, not an extruded slab).
7382    Raise {
7383        /// How many blocks to raise each column (≥ 1).
7384        by: u32,
7385        /// The palette recipe for the added cells.
7386        recipe: PaletteRecipe,
7387    },
7388    /// Lower each column's surface by up to `by` blocks (carving the topmost
7389    /// solid cells to air).
7390    Lower {
7391        /// How many blocks to lower each column (≥ 1).
7392        by: u32,
7393    },
7394    /// Relax each column's surface toward the mean of its cardinal neighbours
7395    /// (one block per pass), turning steps into slopes. Added cells draw from
7396    /// `recipe`; removed cells carve to air. Deterministic double-buffered
7397    /// passes in fixed scan order.
7398    Smooth {
7399        /// Relaxation passes (≥ 1).
7400        passes: u32,
7401        /// The palette recipe for cells a pass adds.
7402        recipe: PaletteRecipe,
7403    },
7404}
7405
7406/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
7407/// smooth value-noise sample, the island/cave generators' proven primitive —
7408/// picks cluster into strata/patches instead of per-cell speckle, and a
7409/// single-entry recipe is the degenerate (discouraged) uniform case.
7410#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7411#[serde(deny_unknown_fields)]
7412pub struct PaletteRecipe {
7413    /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
7414    pub blocks: Vec<PaletteBlock>,
7415    /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
7416    /// across). Larger = smaller patches. Must be finite and > 0.
7417    #[serde(default, skip_serializing_if = "Option::is_none")]
7418    pub scale: Option<f64>,
7419}
7420
7421/// One weighted entry of a [`PaletteRecipe`].
7422#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7423#[serde(deny_unknown_fields)]
7424pub struct PaletteBlock {
7425    /// Vanilla block id (validated against the pinned 1.21.11 registry), with
7426    /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
7427    pub block: String,
7428    /// Relative weight (finite, > 0).
7429    pub weight: f64,
7430}
7431
7432/// Where in the campaign one effect sits — the attribution every per-branch
7433/// proof and the branch chronicle need (spec-0025).
7434#[derive(Clone, Debug, PartialEq, Eq)]
7435pub enum EffectSite {
7436    /// A quest's `on_objective_complete[<objective>]` bundle.
7437    Objective {
7438        /// The owning quest.
7439        quest: String,
7440        /// The objective whose completion fires the bundle.
7441        objective: String,
7442    },
7443    /// A quest's `on_complete` bundle.
7444    QuestComplete {
7445        /// The owning quest.
7446        quest: String,
7447    },
7448    /// An environment trigger's `effects` bundle — ambient, no DAG position.
7449    Trigger {
7450        /// The trigger id.
7451        trigger: String,
7452    },
7453    /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
7454    Trap {
7455        /// The trap id.
7456        trap: String,
7457    },
7458    /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
7459    /// DAG position, and the only site that does not live in the quests stage.
7460    ///
7461    /// This variant did not exist until the effect-root sweep, and its absence was
7462    /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
7463    /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
7464    /// (`combat::actor_beats`, `validate::difficulty_checks`,
7465    /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
7466    /// even if their authors had thought of it. Widening the type is what let the
7467    /// walk widen.
7468    DialogueRespawn {
7469        /// The NPC whose dialogue tree hosts the option.
7470        npc: String,
7471        /// The node the option sits under.
7472        node: String,
7473    },
7474    /// A `shortcuts[].on_unlock` bundle (spec-0016 §2) — ambient, no DAG
7475    /// position, and the sixth root: representable here only since spec-0031, for
7476    /// exactly the reason [`EffectSite::DialogueRespawn`] records above.
7477    ShortcutUnlock {
7478        /// The shortcut id.
7479        shortcut: String,
7480    },
7481    /// A `shops[].offers[].effects` bundle (DSL v0.10, spec-0032) — ambient, no
7482    /// DAG position: nobody is forced to buy anything.
7483    ShopOffer {
7484        /// The shop id.
7485        shop: String,
7486        /// The offer's index within that shop, which is also its button order.
7487        offer: usize,
7488    },
7489    /// The campaign's `on_death` bundle (spec-0031) — ambient, no DAG position,
7490    /// and no owning object: there is one per campaign.
7491    OnDeath,
7492}
7493
7494impl EffectSite {
7495    /// The quest this site belongs to, if it has a DAG position at all.
7496    ///
7497    /// **This `Option` is the capability, and it is on the enum rather than on the
7498    /// variants that happen to have a quest.** Only two of the eight sites name a
7499    /// quest, because only two of the eight roots HAVE a DAG position: an ambient
7500    /// root — a trigger, a trap payload, a dialogue `on_respawn`, a shortcut's
7501    /// `on_unlock`, the campaign's `on_death`, a shop offer — fires at a moment no
7502    /// static model can order, and inventing a quest for one would be exactly the
7503    /// over-attribution the completability model must not make. Asking the question
7504    /// of every variant and getting an honest `None` is the lift;
7505    /// `tools/ci/check-capability-ownership.py` check D asked for it while the field
7506    /// was still cross-cutting, and spec-0032's eighth site took it below that
7507    /// threshold, so the reasoning lives here now rather than in an exemption.
7508    pub fn quest(&self) -> Option<&str> {
7509        match self {
7510            EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
7511                Some(quest)
7512            }
7513            EffectSite::Trigger { .. }
7514            | EffectSite::Trap { .. }
7515            | EffectSite::DialogueRespawn { .. }
7516            | EffectSite::ShortcutUnlock { .. }
7517            | EffectSite::ShopOffer { .. }
7518            | EffectSite::OnDeath => None,
7519        }
7520    }
7521}
7522
7523/// Visit **every** effect the compiler can lower — at every one of the five
7524/// effect roots, top-level and transitively nested — in a fixed deterministic
7525/// order, invoking `f(json_pointer, site, effect)`.
7526///
7527/// The roots come from [`crate::effects::for_each_effect_root`], the single
7528/// enumeration; nesting is descended through the single
7529/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
7530/// enumerated here, which is the point: this walk used to hand-list four of the
7531/// five roots (it had no `EffectSite` variant for the fifth), so every proof
7532/// defined in terms of it inherited that blind spot.
7533pub fn for_each_campaign_effect<'a>(
7534    c: &'a crate::envelope::Campaign,
7535    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
7536) {
7537    crate::effects::for_each_effect_root(c, &mut |root, list| {
7538        let site = match root.owner {
7539            crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
7540                EffectSite::Objective {
7541                    quest: quest.id.as_str().to_string(),
7542                    objective: objective.to_string(),
7543                }
7544            }
7545            crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
7546                quest: quest.id.as_str().to_string(),
7547            },
7548            crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
7549                trigger: t.id.as_str().to_string(),
7550            },
7551            crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
7552                trap: t.id.as_str().to_string(),
7553            },
7554            crate::effects::EffectRootOwner::DialogueRespawn => {
7555                // The npc and node are in the root's path; parse them back rather
7556                // than widening the root walk's owner for one consumer.
7557                let seg = |n: usize| -> String {
7558                    root.path.split('/').nth(n).unwrap_or_default().to_string()
7559                };
7560                EffectSite::DialogueRespawn {
7561                    npc: seg(3),
7562                    node: seg(5),
7563                }
7564            }
7565            crate::effects::EffectRootOwner::ShortcutUnlock(s) => EffectSite::ShortcutUnlock {
7566                shortcut: s.id.as_str().to_string(),
7567            },
7568            crate::effects::EffectRootOwner::OnDeath => EffectSite::OnDeath,
7569            crate::effects::EffectRootOwner::ShopOffer(h) => EffectSite::ShopOffer {
7570                shop: h.id.as_str().to_string(),
7571                // The offer index is in the root's path (`…/offers/<i>/effects`),
7572                // parsed back rather than widening the owner for one consumer —
7573                // the same call the dialogue arm above makes.
7574                offer: root
7575                    .path
7576                    .split('/')
7577                    .nth(4)
7578                    .and_then(|n| n.parse().ok())
7579                    .unwrap_or(0),
7580            },
7581        };
7582        for (i, eff) in list.iter().enumerate() {
7583            campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
7584        }
7585    });
7586}
7587
7588fn campaign_effect_deep<'a>(
7589    eff: &'a QuestEffect,
7590    path: &str,
7591    site: &EffectSite,
7592    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
7593) {
7594    f(path, site, eff);
7595    for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
7596        for (j, inner) in list.iter().enumerate() {
7597            campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
7598        }
7599    }
7600}
7601
7602// ---------------------------------------------------------------------------
7603// The spine authority (`QuestPlanContent::spine`)
7604// ---------------------------------------------------------------------------
7605
7606#[cfg(test)]
7607mod spine_tests {
7608    use super::QuestPlanContent;
7609
7610    /// Build a plan from `(id, deps)` pairs plus a finale. JSON rather than a
7611    /// struct literal on purpose: a field added to `PlannedQuest` later must not
7612    /// red these tests for a reason that has nothing to do with the spine.
7613    fn plan(finale: &str, quests: &[(&str, &[&str])]) -> QuestPlanContent {
7614        let quests: Vec<serde_json::Value> = quests
7615            .iter()
7616            .map(|(id, deps)| {
7617                serde_json::json!({
7618                    "id": id,
7619                    "goal": "g",
7620                    "area": "area/keep",
7621                    "npcs": [],
7622                    "depends_on": deps,
7623                    "mandatory": true,
7624                    "act": 1,
7625                })
7626            })
7627            .collect();
7628        serde_json::from_value(serde_json::json!({
7629            "finale": finale,
7630            "quests": quests,
7631        }))
7632        .expect("plan fixture parses")
7633    }
7634
7635    fn sorted(p: &QuestPlanContent) -> Vec<String> {
7636        p.spine().into_iter().map(str::to_owned).collect()
7637    }
7638
7639    #[test]
7640    fn a_chain_is_wholly_spine() {
7641        let p = plan(
7642            "quest/c",
7643            &[
7644                ("quest/a", &[]),
7645                ("quest/b", &["quest/a"]),
7646                ("quest/c", &["quest/b"]),
7647            ],
7648        );
7649        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c"]);
7650    }
7651
7652    #[test]
7653    fn a_quest_the_finale_does_not_depend_on_is_off_the_spine() {
7654        // Exactly the `DW0132` shape: the plan does not converge, and the spine
7655        // is the half that does. The authority answers, it does not refuse — the
7656        // refusal is `validate`'s, built on this answer.
7657        let p = plan("quest/end", &[("quest/end", &[]), ("quest/side-trip", &[])]);
7658        assert_eq!(sorted(&p), ["quest/end"]);
7659    }
7660
7661    #[test]
7662    fn a_diamond_counts_the_join_once() {
7663        let p = plan(
7664            "quest/d",
7665            &[
7666                ("quest/a", &[]),
7667                ("quest/b", &["quest/a"]),
7668                ("quest/c", &["quest/a"]),
7669                ("quest/d", &["quest/b", "quest/c"]),
7670            ],
7671        );
7672        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c", "quest/d"]);
7673    }
7674
7675    #[test]
7676    fn a_cycle_terminates_and_yields_a_set() {
7677        // `DW0130` refuses this plan, but the authority is asked before that
7678        // verdict is known (the layout binding prints on an erroring campaign),
7679        // so it must terminate rather than hang.
7680        let p = plan(
7681            "quest/b",
7682            &[("quest/a", &["quest/b"]), ("quest/b", &["quest/a"])],
7683        );
7684        assert_eq!(sorted(&p), ["quest/a", "quest/b"]);
7685    }
7686
7687    #[test]
7688    fn a_dangling_dependency_is_reported_and_expands_no_further() {
7689        // The deliberate difference between the two derivations this function
7690        // replaced. `validate` pruned undeclared ids before walking; the
7691        // authority does not, because pruning them would make the set disagree
7692        // with the document, and naming an id the plan does not declare is
7693        // `DW0112`'s finding rather than the spine's.
7694        let p = plan("quest/end", &[("quest/end", &["quest/ghost"])]);
7695        assert_eq!(sorted(&p), ["quest/end", "quest/ghost"]);
7696    }
7697
7698    #[test]
7699    fn an_undeclared_finale_is_the_whole_spine() {
7700        // `DW0131`'s shape. The answer is honest about the document: nothing the
7701        // plan declares is on the spine of a finale it never declared.
7702        let p = plan(
7703            "quest/ghost",
7704            &[("quest/a", &[]), ("quest/b", &["quest/a"])],
7705        );
7706        assert_eq!(sorted(&p), ["quest/ghost"]);
7707    }
7708}