Skip to main content

delvewright_dsl/
stages.rs

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