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::celestial::{CelestialTime, Clock, MoonPhase};
13use crate::firework::FireworkExplosion;
14use crate::layout::StationKind;
15
16use crate::ids::{
17    ActorId, AmbushId, AnchorId, AreaId, AssemblyId, AtmosphereId, BranchId, BranchPointId,
18    ClassId, DialogueId, EditBatchId, EndingId, FlagId, LethalVolumeId, LoopId, LootId, NpcId,
19    ObjectiveId, PoolId, PrefabId, QuestId, RegionId, RigId, ShopId, ShortcutId, StakeId, StateId,
20    TimedGateId, TrapId, TriggerId, WaveId,
21};
22
23/// serde default helper: `true` (used by DSL v0.4 `trigger.once`).
24fn default_true() -> bool {
25    true
26}
27
28/// serde `skip_serializing_if` helper: skip a `false` bool (DSL v0.4
29/// `objective.stealth`), keeping older campaigns byte-identical.
30fn is_false(b: &bool) -> bool {
31    !*b
32}
33
34// ---------------------------------------------------------------------------
35// Stage 1 — world
36// ---------------------------------------------------------------------------
37
38/// Stage 1 payload: setting, seed and the areas that make up the delve.
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields)]
41pub struct WorldContent {
42    /// Player-facing delve title.
43    pub title: String,
44    /// One-line thematic description.
45    pub theme: String,
46    /// Short narrative premise.
47    pub premise: String,
48    /// The single downstream randomness source (ADR-0006).
49    pub seed: u64,
50    /// Informational pacing target in minutes (v0: not enforced).
51    pub target_minutes: u32,
52    /// The areas the delve is made of; each binds exactly one of `prefab` /
53    /// `prefab_pool`.
54    ///
55    /// **A campaign declares its placement in exactly one document, so this
56    /// list is empty on a site-plan campaign** (`DW0839`): where a
57    /// `site-plan.json` is present the plan is the placement authority, its one
58    /// place is `area/site`, and declaring `areas[]` as well gives every
59    /// question about where something is two answers. Empty is therefore a
60    /// legitimate and common value, not a campaign that forgot to place
61    /// anything.
62    pub areas: Vec<Area>,
63    /// Additional author-declared translation languages (BCP-47-style codes, e.g.
64    /// `["zh-cn"]`). English (`en`) is implicit, always canonical, and is **never**
65    /// listed here (spec-0001 i18n addendum). Absent or empty = English-only. Every
66    /// declared language must ship a fully-covering `l10n/<code>.json` sidecar
67    /// (`DW0180`/`DW0181`). Stage docs themselves stay pure English.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub languages: Vec<String>,
70    /// **The hour this delve is played at** (DSL v0.5, spec-0010; required since
71    /// spec-0061). Dimension-global; frozen by environment sealing
72    /// (`advance_time false`) so the set state persists. Affects sky attenuation
73    /// in the compiler's assembled-light model.
74    ///
75    /// **Required, and it has no default.** "This delve is played at noon" is a
76    /// design decision, and a mechanism that supplies one silently when the
77    /// author said nothing is exactly what `CLAUDE.md` forbids a primitive from
78    /// encoding — the same ruling spec-0060 §4.1 made for `walk_y`. It is also
79    /// the world half of the comparison `DW0890` makes against the approved
80    /// design's rows, so every campaign has to state it for the comparison to
81    /// have two sides. Emission is unchanged: `time set <kw>` was always
82    /// emitted, so a campaign that already declared this builds
83    /// byte-identically.
84    pub time: WorldTime,
85    /// **The weather this delve is played in** (DSL v0.5, spec-0010; required
86    /// since spec-0061). Dimension-global; frozen by environment sealing
87    /// (`advance_weather false`). Rain and thunder attenuate effective sky
88    /// brightness in the assembled-light model.
89    ///
90    /// Required, with no default, for the reason [`WorldContent::time`] gives.
91    /// Emission is unchanged: `weather <kw>` is emitted only for a declared
92    /// non-`clear` weather, because `clear` is vanilla's own state.
93    pub weather: WorldWeather,
94    /// Declared combat difficulty (DSL v0.6). Absent =
95    /// the compiler's historical derivation — `easy` when the campaign fields any
96    /// wave, `peaceful` when it fields none — which is what keeps every campaign
97    /// written before this field byte-identical. Declaring it overrides the
98    /// derivation for **both** the shipped `server.properties` and a `/difficulty`
99    /// in the sealing baseline, so the declaration also holds when the datapack is
100    /// dropped into somebody else's world.
101    ///
102    /// `peaceful` is rejected (`DW0468`). Raising difficulty changes the damage
103    /// players take — easy halves it — so combat arithmetic tuned under the old
104    /// implicit `easy` must be redone.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub difficulty: Option<WorldDifficulty>,
107    /// The scenic horizon: the ground and the sky the map stands in
108    /// (spec-0026). Absent or `void` is the void world. `ocean` swaps the world
109    /// generator for a deterministic superflat sea (bedrock/stone/water, sea
110    /// level y=62) and drops the area datum to y=60 so island pieces meet the
111    /// sea at their authored waterline. `valley` rings the map in a generated
112    /// mountain annulus — the one base that builds terrain rather than picking
113    /// a generator. Either a string shorthand or the object form
114    /// `{base, …params}`; see [`Horizon`].
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub horizon: Option<Horizon>,
117    /// **The skies a place can stand under** (spec-0080). Each one is declared
118    /// once, here, beside `time`, `weather` and `horizon` — the other
119    /// statements about the sky the party stands under — and ships as a
120    /// datapack biome (`<ns>:atmosphere/<kebab>`) built from the pinned game's
121    /// environment attributes. A place carries one from the first tick
122    /// ([`Area::atmosphere`], `boxes[].atmosphere`), and a beat repaints a
123    /// volume with another ([`Verb::SetAtmosphere`]). Absent or empty: every
124    /// cell stands in the horizon's biome. One no place carries and no beat
125    /// paints is refused (`DW0930`).
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub atmospheres: Vec<Atmosphere>,
128    /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
129    /// derives a region from the placed geometry and a per-second clock returns any
130    /// player who leaves it to the last checkpoint. Required when `horizon` is
131    /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub boundary: Option<Boundary>,
134    /// The closing line on the campaign-completion advancement — the last
135    /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
136    /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
137    /// finale quest's `goal`, which is already both campaign-derived and
138    /// inventoried; the description was previously the hardcoded English
139    /// "You left the keep." on *every* delve, whatever its theme or language.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub outro: Option<String>,
142    /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
143    /// party of one is always legal and every pre-0.6 campaign keeps that reading.
144    /// A design whose beats genuinely need `n` players — two rooms whose switches
145    /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
146    /// lobby then refuses to start below it: the class-selection dialog stays shut
147    /// and the waiting players get a party-count actionbar instead.
148    ///
149    /// Progression is party state either way (spec-0018), so this is a *declaration
150    /// of intent*, not a mechanism: it makes a mandatory-n design first-class
151    /// and turns on the analyzer's n-agent division
152    /// proof. Out of `1..=4` is `DW0370`.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub min_players: Option<u8>,
155    /// **The vanilla textures this delve replaces** (spec-0084). Each row names
156    /// one texture the pinned client ships and the campaign's own image for it,
157    /// at `textures/<id>.png` in the campaign directory; the build bakes it into
158    /// the resource pack at the vanilla path, so it is drawn wherever the client
159    /// draws that texture — every mob of that kind, the moon over every area —
160    /// for every player who accepted the pack. Absent or empty = vanilla's look.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub textures: Vec<TextureOverride>,
163    /// **Whether a player must accept this delve's resource pack to play it**
164    /// (spec-0084 §11). `true` is emitted as `require-resource-pack=true`: a
165    /// player who declines is disconnected by the server. Absent or `false` =
166    /// the pack is offered and may be declined, in which case the player reads
167    /// English and sees vanilla's textures. A host may still set the server's own
168    /// flip (itzg's `RESOURCE_PACK_ENFORCE`), which is obeyed as given.
169    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
170    pub require_resource_pack: bool,
171    /// **A fallen player waits before rejoining** (spec-0077). After clicking
172    /// *Respawn*, a player whose party still has somebody in play watches a
173    /// teammate as a spectator for `seconds`, and counts as down for the party
174    /// wipe while they wait. Absent = no wait, and emission is byte-identical to
175    /// a campaign that never had the field. Needs a checkpoint or bonfire to come
176    /// back to; `seconds` outside `1..=120`, or no checkpoint, is `DW0925`.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub respawn_wait: Option<RespawnWait>,
179}
180
181/// One vanilla texture a campaign replaces (spec-0084 §3.1). The row is a
182/// judgement and nothing more: width, height and frame count are read off the
183/// file and the pinned client's census, and the namespace is fixed.
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
185#[serde(deny_unknown_fields)]
186pub struct TextureOverride {
187    /// A bare kebab token, unique among the campaign's textures (`DW0190`). The
188    /// image is `textures/<id>.png` in the campaign directory (`DW0309`), and a
189    /// `textures/<id>.png.mcmeta` beside it ships with it as the animation.
190    pub id: String,
191    /// The texture replaced, as a resource location in the `minecraft`
192    /// namespace without `textures/` and without `.png` — the path vanilla's own
193    /// models and atlases use (e.g. `minecraft:entity/zombie/drowned`,
194    /// `minecraft:environment/celestial/moon/full_moon`). It must name a texture
195    /// the pinned client ships (`DW0939`).
196    pub replaces: String,
197    /// Where the image came from and under what licence (ADR-0013): original
198    /// work (`spdx` and `source` both `original`), or an allowlisted third-party
199    /// image with its `url`, and its `attribution` for CC BY (`DW0741`).
200    pub license: crate::license::LicenseEvidence,
201}
202
203/// One declared sky (spec-0080 §3.1): what the party sees and hears while it
204/// stands in a cell painted with this atmosphere's biome.
205///
206/// The biome is vanilla's one channel for sky colour, fog, clouds, sky-light
207/// tint, stars, ambient particles, music, ambience, grass, foliage and water
208/// tint, and precipitation. In the overworld the day cycle stacks over it: it
209/// multiplies the colours (a biome's value survives, darkened at night), takes
210/// the maximum of `star_brightness`, and replaces the sun, moon and star
211/// angles, the sunrise colour and the moon phase outright — which is why those
212/// five ids are refused (`DW0928`) and the sun cannot be moved from a place.
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
214#[serde(deny_unknown_fields)]
215pub struct Atmosphere {
216    /// `atmosphere/<kebab>`, unique.
217    pub id: AtmosphereId,
218    /// Environment attributes, keyed by id (`visual/sky_color`; the
219    /// `minecraft:` prefix is optional). Which ids a campaign may set, the
220    /// shape of each value and the range the pinned codec accepts are vendored
221    /// data (`crates/delvec/data/environment-attributes-1.21.11.json`), not
222    /// DSL surface: a value outside them is `DW0928`. A float attribute may
223    /// also be written in vanilla's modifier form, `{"argument": 0.85,
224    /// "modifier": "multiply"}`.
225    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
226    pub attributes: BTreeMap<String, serde_json::Value>,
227    /// Grass, foliage, dry-foliage and water tint (`#rrggbb`), each optional.
228    /// Absent, vanilla derives grass and foliage from the climate and water is
229    /// the void biome's `#3f76e4`.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub tint: Option<AtmosphereTint>,
232    /// What falls here when the world's weather is rain or thunder. The
233    /// compiler derives the three vanilla fields that must agree from it
234    /// (`has_precipitation`, `temperature`, `downfall`), and it is the fact
235    /// `DW0496` reads at a cell: `none` under a rainy world means the undead
236    /// burn here.
237    pub precipitation: Precipitation,
238    /// Overrides the derived `temperature` / `downfall`, for vanilla's own
239    /// grass colormap at a named point. Must agree with `precipitation`
240    /// (`DW0930`): snow below 0.15, rain at or above it.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub climate: Option<Climate>,
243}
244
245/// An atmosphere's tint (spec-0080 §3.1.3): the biome `effects` colours the
246/// pinned data writes, each `#rrggbb`.
247#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
248#[serde(deny_unknown_fields)]
249pub struct AtmosphereTint {
250    /// `grass_color`.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub grass: Option<String>,
253    /// `foliage_color`.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub foliage: Option<String>,
256    /// `dry_foliage_color`.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub dry_foliage: Option<String>,
259    /// `water_color`.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub water: Option<String>,
262}
263
264/// What an atmosphere's biome lets fall (spec-0080 §3.1.4).
265#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
266#[serde(rename_all = "kebab-case")]
267pub enum Precipitation {
268    /// Nothing falls: `has_precipitation: false`.
269    None,
270    /// Rain falls: `has_precipitation: true`, temperature 0.5.
271    Rain,
272    /// Snow falls: `has_precipitation: true`, temperature 0.0.
273    Snow,
274}
275
276/// A biome's climate pair (spec-0080 §3.1.4).
277#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
278#[serde(deny_unknown_fields)]
279pub struct Climate {
280    /// `temperature`: vanilla snows below 0.15.
281    pub temperature: f64,
282    /// `downfall`.
283    pub downfall: f64,
284}
285
286/// How long a fallen player waits before rejoining the party (spec-0077 §3).
287#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
288#[serde(deny_unknown_fields)]
289pub struct RespawnWait {
290    /// Seconds a fallen player waits after clicking *Respawn*, `1..=120`
291    /// (`DW0925`). Counted on the server only while the player is online.
292    pub seconds: u16,
293    /// Whether a player who comes back with nobody else present also waits.
294    /// Default `false`: a party of one never waits.
295    #[serde(default)]
296    pub alone: bool,
297}
298
299/// Who a granted item goes to (DSL v0.6, spec-0018).
300///
301/// Progression is a fact about the party, so the default for every `give-item` and
302/// every class-kit entry is **all**: a quest beat that arms the party arms all of
303/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
304/// the stake): exactly one copy enters the party, handed to the player whose action
305/// earned it, and the party passes it around physically.
306#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
307#[serde(rename_all = "kebab-case")]
308pub enum Carrier {
309    /// Every party member receives the item (the default).
310    #[default]
311    All,
312    /// Exactly one copy, to the player whose action fired the effect.
313    One,
314}
315
316/// A declared world time state (DSL v0.5, spec-0010; the celestial spelling
317/// spec-0081). The sole difference from vanilla is that the daylight cycle is
318/// frozen (`advance_time false`), so a set state persists for the whole delve
319/// until a `set-time` effect cuts to another.
320///
321/// **Two spellings of one clock.** A keyword — `day`, `noon`, `dusk`, `night`,
322/// `midnight`, `dawn` — states vanilla's word with vanilla's meaning: the hour,
323/// on day 0, so a keyword night shows a full moon. A celestial statement
324/// ([`CelestialTime`], `{"moon": "just-risen", "phase": "new-moon"}`) names one
325/// body, where it stands and, where the moon shows, its phase, and the engine
326/// computes the tick count, day included. Both resolve to one [`Clock`]
327/// ([`WorldTime::clock`]); two values are equal when their clocks are.
328///
329/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
330/// tick count, and the tick form is the general one. `dusk` and `dawn` are the
331/// tick form exposed first-class, per the no-hack rule; a celestial statement is
332/// the same primitive reached from a designer's sentence. A keyword on day 0
333/// still emits its keyword verbatim ([`WorldTime::token`]), so existing
334/// campaigns are byte-identical.
335///
336/// **There is no `Default`** (spec-0061 §4). A default hour is a design decision
337/// wearing a mechanism's clothes, and `#[default] Noon` is what let a delve whose
338/// whole approved look was night build, light-check and render under a blue noon
339/// sky. Removing the impl is what makes that unwritable rather than merely
340/// discouraged: `WorldContent::time` is required, and nothing can supply an hour
341/// the author did not.
342#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
343pub enum WorldTime {
344    /// Morning daylight (`/time set day`, 1000 ticks).
345    Day,
346    /// Midday, brightest (`/time set noon`, 6000 ticks).
347    Noon,
348    /// Sunset onset (`/time set 12000`).
349    Dusk,
350    /// Night, sun fully down (`/time set night`, 13000 ticks).
351    Night,
352    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
353    Midnight,
354    /// First light, just before sunrise (`/time set 23000`).
355    Dawn,
356    /// A sky stated in a designer's words (spec-0081).
357    Celestial(CelestialTime),
358}
359
360/// The six keyword spellings of a [`WorldTime`] — the wire and schema form of
361/// its keyword half.
362#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
363#[serde(rename_all = "kebab-case")]
364pub enum TimeKeyword {
365    /// Morning daylight (`/time set day`, 1000 ticks).
366    Day,
367    /// Midday, brightest (`/time set noon`, 6000 ticks).
368    Noon,
369    /// Sunset — the sky visibly going orange and the day ending
370    /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
371    /// has finished setting, which is what the `night` keyword already sets, so
372    /// 13000 would make `dusk` a synonym rather than its own beat.
373    Dusk,
374    /// Night, sun fully down (`/time set night`, 13000 ticks).
375    Night,
376    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
377    Midnight,
378    /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
379    /// `sunrise` is accepted as a synonym on input.
380    #[serde(alias = "sunrise")]
381    Dawn,
382}
383
384impl From<TimeKeyword> for WorldTime {
385    fn from(k: TimeKeyword) -> WorldTime {
386        match k {
387            TimeKeyword::Day => WorldTime::Day,
388            TimeKeyword::Noon => WorldTime::Noon,
389            TimeKeyword::Dusk => WorldTime::Dusk,
390            TimeKeyword::Night => WorldTime::Night,
391            TimeKeyword::Midnight => WorldTime::Midnight,
392            TimeKeyword::Dawn => WorldTime::Dawn,
393        }
394    }
395}
396
397impl Serialize for WorldTime {
398    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
399        match self {
400            WorldTime::Celestial(c) => c.serialize(s),
401            kw => s.serialize_str(kw.keyword_str().expect("a keyword")),
402        }
403    }
404}
405
406impl<'de> Deserialize<'de> for WorldTime {
407    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<WorldTime, D::Error> {
408        struct V;
409        impl<'de> serde::de::Visitor<'de> for V {
410            type Value = WorldTime;
411            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
412                f.write_str(
413                    "a time: a keyword (day, noon, dusk, night, midnight, dawn) or an object \
414                     naming one body, where it stands and its phase \
415                     ({\"moon\": \"just-risen\", \"phase\": \"new-moon\"})",
416                )
417            }
418            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WorldTime, E> {
419                use serde::de::IntoDeserializer;
420                TimeKeyword::deserialize(v.into_deserializer()).map(WorldTime::from)
421            }
422            fn visit_map<A: serde::de::MapAccess<'de>>(
423                self,
424                map: A,
425            ) -> Result<WorldTime, A::Error> {
426                CelestialTime::deserialize(serde::de::value::MapAccessDeserializer::new(map))
427                    .map(WorldTime::Celestial)
428            }
429        }
430        de.deserialize_any(V)
431    }
432}
433
434impl JsonSchema for WorldTime {
435    fn schema_name() -> std::borrow::Cow<'static, str> {
436        "WorldTime".into()
437    }
438
439    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
440        schemars::json_schema!({
441            "description": "A time: a keyword, vanilla's hour on day 0 (a keyword night is a full \
442                            moon), or a celestial statement naming one body, where it stands and, \
443                            where the moon shows, its phase (spec-0081).",
444            "anyOf": [g.subschema_for::<TimeKeyword>(), g.subschema_for::<CelestialTime>()],
445        })
446    }
447}
448
449impl WorldTime {
450    /// The keyword table: `(the /time set argument, daytime ticks)`, or `None`
451    /// for a celestial statement.
452    ///
453    /// A state vanilla names keeps its keyword — the argument the compiler has
454    /// always emitted — so no shipped campaign's bytes move. A state vanilla does
455    /// not name emits the equivalent tick count, which is the same primitive.
456    const fn spec(self) -> Option<(&'static str, i64)> {
457        match self {
458            WorldTime::Day => Some(("day", 1000)),
459            WorldTime::Noon => Some(("noon", 6000)),
460            WorldTime::Dusk => Some(("12000", 12000)),
461            WorldTime::Night => Some(("night", 13000)),
462            WorldTime::Midnight => Some(("midnight", 18000)),
463            WorldTime::Dawn => Some(("23000", 23000)),
464            WorldTime::Celestial(_) => None,
465        }
466    }
467
468    /// Whether this is one of the six keywords.
469    pub fn is_keyword(self) -> bool {
470        !matches!(self, WorldTime::Celestial(_))
471    }
472
473    /// The celestial statement, if this is one.
474    pub fn celestial(self) -> Option<CelestialTime> {
475        match self {
476            WorldTime::Celestial(c) => Some(c),
477            _ => None,
478        }
479    }
480
481    /// The phase this value states, if any.
482    pub fn stated_phase(self) -> Option<MoonPhase> {
483        self.celestial().and_then(|c| c.phase)
484    }
485
486    /// The `daytime` tick value this state sets (the `time query daytime`
487    /// read-back). Keywords: day=1000, noon=6000, dusk=12000 (sunset onset),
488    /// night=13000, midnight=18000, dawn=23000; a celestial statement its
489    /// position's tick ([`crate::celestial::position_tick`]).
490    pub fn daytime_ticks(self) -> i64 {
491        match self.spec() {
492            Some((_, t)) => t,
493            None => self.celestial().expect("celestial").daytime(),
494        }
495    }
496
497    /// The day this value names as **the world's own time**: a keyword is day 0;
498    /// a celestial statement the day its `phase` names, or day 0 where it names
499    /// none (the moon is below the horizon, so no phase is stated).
500    pub fn world_day(self) -> i64 {
501        self.stated_phase().map(MoonPhase::index).unwrap_or(0)
502    }
503
504    /// **The clock this value sets**, at a site whose world declares `world`
505    /// (spec-0081 §3.3).
506    ///
507    /// - A celestial statement is its position's tick on the day its `phase`
508    ///   names, or the world's day where it states none — a cut changes the
509    ///   hour, and the moon keeps the phase the world declared.
510    /// - A keyword is its table row on day 0 where it states a sky — the
511    ///   world's own time, a design row, a camera ([`TimeSite::Sky`]) — and on
512    ///   the world's day where it is a `set-time` cut ([`TimeSite::Cut`]), which
513    ///   changes the hour and keeps the moon.
514    pub fn clock(self, site: TimeSite, world: WorldTime) -> Clock {
515        let daytime = self.daytime_ticks();
516        let day = match (self, site) {
517            (WorldTime::Celestial(c), _) => c
518                .phase
519                .map(MoonPhase::index)
520                .unwrap_or_else(|| world.world_day()),
521            (_, TimeSite::Cut) => world.world_day(),
522            (_, TimeSite::Sky) => 0,
523        };
524        Clock { day, daytime }
525    }
526
527    /// The world's own clock: [`WorldTime::clock`] at the world's site.
528    pub fn world_clock(self) -> Clock {
529        self.clock(TimeSite::Sky, self)
530    }
531
532    /// **The vanilla `/time set` argument for `clock`**, the one token every
533    /// `time set` the engine emits goes through: a keyword on day 0 emits its
534    /// table argument verbatim (`night`, `12000`), so no keyword campaign's bytes
535    /// move; any other clock emits the integer `day × 24000 + daytime`.
536    pub fn token(self, clock: Clock) -> String {
537        match self.spec() {
538            Some((tok, t)) if clock.day == 0 && clock.daytime == t => tok.to_string(),
539            _ => clock.absolute().to_string(),
540        }
541    }
542
543    /// The keyword spelling, if this is a keyword.
544    fn keyword_str(self) -> Option<&'static str> {
545        match self {
546            WorldTime::Day => Some("day"),
547            WorldTime::Noon => Some("noon"),
548            WorldTime::Dusk => Some("dusk"),
549            WorldTime::Night => Some("night"),
550            WorldTime::Midnight => Some("midnight"),
551            WorldTime::Dawn => Some("dawn"),
552            WorldTime::Celestial(_) => None,
553        }
554    }
555
556    /// **What an author writes** — this state's spelling in a document: the
557    /// keyword, or the canonical JSON of a celestial statement
558    /// (`{"moon":"high","phase":"new-moon"}`).
559    ///
560    /// Not [`WorldTime::token`], which is the `/time set` argument and is a raw
561    /// tick count for every state vanilla does not name. A diagnostic that asks
562    /// an author to declare an hour has to say `dusk`, not `12000`.
563    pub fn keyword(self) -> String {
564        match self {
565            WorldTime::Celestial(c) => c.spelling(),
566            kw => kw.keyword_str().expect("a keyword").to_string(),
567        }
568    }
569}
570
571/// Where a time value is written, which decides the day a keyword names
572/// ([`WorldTime::clock`]).
573#[derive(Clone, Copy, Debug, PartialEq, Eq)]
574pub enum TimeSite {
575    /// A statement of a sky: the world's own time, a design row, a camera.
576    Sky,
577    /// A `set-time` effect, quest or dialogue.
578    Cut,
579}
580
581/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
582/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
583///
584/// **No `Default`**, for the reason [`WorldTime`] gives.
585#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
586#[serde(rename_all = "kebab-case")]
587pub enum WorldWeather {
588    /// Clear sky (`/weather clear`). Vanilla's own state, so emission writes no
589    /// `/weather` command for it.
590    Clear,
591    /// Rain (`/weather rain`).
592    Rain,
593    /// Thunderstorm (`/weather thunder`).
594    Thunder,
595}
596
597impl WorldWeather {
598    /// The word an author writes — identical to [`WorldWeather::token`] for
599    /// every state, and stated separately so a message that names a document's
600    /// vocabulary reads the document's vocabulary. See [`WorldTime::keyword`],
601    /// where the two differ.
602    pub fn keyword(self) -> &'static str {
603        self.token()
604    }
605
606    /// The vanilla `/weather` keyword.
607    pub fn token(self) -> &'static str {
608        match self {
609            WorldWeather::Clear => "clear",
610            WorldWeather::Rain => "rain",
611            WorldWeather::Thunder => "thunder",
612        }
613    }
614}
615
616/// The declared combat difficulty of the delve (DSL v0.6). Values are the
617/// vanilla `/difficulty` keywords.
618///
619/// Difficulty is the single largest lever on how hard a delve *feels*, so the
620/// campaign declares it rather than letting the compiler choose. Easy **halves
621/// incoming player damage** — `min(dmg / 2 + 1, dmg)` — so a campaign tuned
622/// under `easy` is tuned against a halved world. A campaign that
623/// raises this must redo that arithmetic.
624///
625/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
626/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
627/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
628/// campaign would silently vanish. It is a variant only so the compiler can say
629/// that in a diagnostic instead of a serde "unknown variant" parse error.
630#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
631#[serde(rename_all = "kebab-case")]
632pub enum WorldDifficulty {
633    /// `/difficulty easy` — the compiler's historical choice for a wave
634    /// campaign, and the default reading of an absent field. Incoming player
635    /// damage is halved (`min(dmg / 2 + 1, dmg)`).
636    #[default]
637    Easy,
638    /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
639    Normal,
640    /// `/difficulty hard` — amplified damage, and zombies reinforce.
641    Hard,
642    /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
643    /// the rejection can be a diagnostic with a rationale.
644    Peaceful,
645}
646
647impl WorldDifficulty {
648    /// The vanilla `/difficulty` keyword.
649    pub fn token(self) -> &'static str {
650        match self {
651            WorldDifficulty::Peaceful => "peaceful",
652            WorldDifficulty::Easy => "easy",
653            WorldDifficulty::Normal => "normal",
654            WorldDifficulty::Hard => "hard",
655        }
656    }
657
658    /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
659    /// `/difficulty` query command returns — the only vanilla read-back path for
660    /// the setting, and so what the generated PackTest asserts on.
661    pub fn id(self) -> i32 {
662        match self {
663            WorldDifficulty::Peaceful => 0,
664            WorldDifficulty::Easy => 1,
665            WorldDifficulty::Normal => 2,
666            WorldDifficulty::Hard => 3,
667        }
668    }
669}
670
671/// A scenic horizon (spec-0026). A horizon is a **composition of orthogonal
672/// axes**, not an enum of monoliths: a **base** — what surrounds the map — and
673/// that base's params. The field accepts a plain string shorthand
674/// ([`HorizonName`]) or the object form [`HorizonSpec`] `{base, …params}`.
675///
676/// Consumers never match this wire enum. [`Horizon::resolved`] desugars both
677/// forms into one [`ResolvedHorizon`] with the pinned defaults applied, and
678/// [`horizon_base`] answers the one question most callers have.
679#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
680#[serde(untagged)]
681pub enum Horizon {
682    /// A bare base name — `"ocean"` is exactly `{base: "ocean"}`. The two
683    /// bases that predate the horizon library were spelled this way and still
684    /// are, byte-identically; a base added since is spellable this way too,
685    /// because a base with every param at its default has nothing else to say.
686    Name(HorizonBase),
687    /// The object form `{base, …params}`.
688    Spec(HorizonSpec),
689}
690
691/// What surrounds the map.
692///
693/// **One enumeration of bases, reachable two ways.** The shorthand
694/// `horizon: "ocean"` and the object form `horizon: {base: "ocean"}` name this
695/// same variant, which is what stops a base from existing in one spelling and
696/// not the other. A separate list of "names" beside this one would be two
697/// enumerations of the same thing, and the second base added would land in
698/// whichever of them its author was looking at.
699///
700/// What is deliberately NOT here is a name that stands for a base plus a set of
701/// params. Such a name reads as a thing, and the whole claim of this design is
702/// that it is not one — it is a base with params set. A spelling that hides
703/// which params it sets makes that claim unverifiable by looking at the
704/// document, and buys a few saved keystrokes for it. Each base carries its own params on
705/// [`HorizonSpec`], and a param foreign to the declared base is refused rather
706/// than ignored.
707#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
708#[serde(rename_all = "kebab-case")]
709pub enum HorizonBase {
710    /// Void superflat; no surround. The default.
711    #[default]
712    Void,
713    /// Pinned water superflat, sea level 62; no surround.
714    Ocean,
715    /// A mountain annulus around a flat gap floor, with void ambient below the
716    /// tile skirt. The one base that generates terrain.
717    Valley,
718}
719
720impl HorizonBase {
721    /// The kebab wire name.
722    pub fn token(self) -> &'static str {
723        match self {
724            HorizonBase::Void => "void",
725            HorizonBase::Ocean => "ocean",
726            HorizonBase::Valley => "valley",
727        }
728    }
729
730    /// Whether this base generates a surround — compiler-built terrain outside
731    /// the map's own extent. `void` and `ocean` are pure ambient and build
732    /// nothing.
733    pub fn has_surround(self) -> bool {
734        matches!(self, HorizonBase::Valley)
735    }
736}
737
738/// The `horizon` object form: a `base` plus that base's params, all optional
739/// with pinned defaults ([`horizon_defaults`]).
740///
741/// The `valley` surround generator carries a second flora and a second surface
742/// palette (a cherry grove over `minecraft:cherry_grove`) and **this struct
743/// does not expose them.** Every engine surface owes a gallery element in the
744/// change that lands it; the element a second flora needs is a valley overlay,
745/// and one is writable now that a one-area campaign's single prefab states an
746/// extent ([`crate::placement::Extent`], `DW0855`) — the reason recorded here
747/// was that no two-file overlay could ring a map, and that reason is spent.
748/// What is left is that nothing has written the element, and a surface lands
749/// with its element or it does not land. The shape is flat rather than
750/// per-base tagged, and a param foreign to the declared base is refused
751/// (`DW0853`) — so an `ocean` cannot quietly carry a `rim_height` that nothing
752/// reads.
753#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
754#[serde(deny_unknown_fields)]
755pub struct HorizonSpec {
756    /// The base — what surrounds the map.
757    pub base: HorizonBase,
758    /// `valley`: the surround's total footprint as a multiple of the map's, on
759    /// each axis (`2.0..=3.0`, default 2.5).
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub ratio: Option<f64>,
762    /// `valley`: crest height of the rim over the gap floor (`16..=128`,
763    /// default 48).
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub rim_height: Option<i32>,
766}
767
768/// Pinned horizon param defaults. One table, so the doc comments, the resolver
769/// and the diagnostics cannot drift.
770pub mod horizon_defaults {
771    /// `valley.ratio`.
772    pub const RATIO: f64 = 2.5;
773    /// `valley.ratio` lower bound — below 2.0 the annulus has no room for a
774    /// gap floor and a slope run both.
775    pub const RATIO_MIN: f64 = 2.0;
776    /// `valley.ratio` upper bound — above 3.0 the surround is mostly terrain a
777    /// body never reaches, at a cost that is all shipped bytes.
778    pub const RATIO_MAX: f64 = 3.0;
779    /// `valley.rim_height`.
780    pub const RIM_HEIGHT: i32 = 48;
781    /// `valley.rim_height` lower bound — a rim under 16 does not close the
782    /// horizon from a body standing on the gap floor.
783    pub const RIM_HEIGHT_MIN: i32 = 16;
784    /// `valley.rim_height` upper bound — the build range is 384 blocks tall and
785    /// the surround has to fit under whatever the map puts above it.
786    pub const RIM_HEIGHT_MAX: i32 = 128;
787}
788
789/// A horizon with both wire forms desugared and every default applied — the
790/// only view downstream code reads.
791#[derive(Clone, Copy, Debug, PartialEq)]
792pub struct ResolvedHorizon {
793    /// The base.
794    pub base: HorizonBase,
795    /// `valley.ratio`.
796    pub ratio: f64,
797    /// `valley.rim_height`.
798    pub rim_height: i32,
799}
800
801impl Default for ResolvedHorizon {
802    fn default() -> Self {
803        ResolvedHorizon {
804            base: HorizonBase::Void,
805            ratio: horizon_defaults::RATIO,
806            rim_height: horizon_defaults::RIM_HEIGHT,
807        }
808    }
809}
810
811impl ResolvedHorizon {
812    /// The resolved horizon of `base` with every param at its pinned default.
813    pub fn of_base(base: HorizonBase) -> Self {
814        ResolvedHorizon {
815            base,
816            ..Default::default()
817        }
818    }
819}
820
821impl Horizon {
822    /// Desugar either wire form to the one resolved view, defaults applied.
823    pub fn resolved(&self) -> ResolvedHorizon {
824        match self {
825            Horizon::Name(base) => ResolvedHorizon::of_base(*base),
826            Horizon::Spec(s) => ResolvedHorizon {
827                base: s.base,
828                ratio: s.ratio.unwrap_or(horizon_defaults::RATIO),
829                rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT),
830            },
831        }
832    }
833
834    /// The resolved base.
835    pub fn base(&self) -> HorizonBase {
836        self.resolved().base
837    }
838
839    /// True when this declaration needs the horizon-library surface: the object
840    /// form, or a bare name for a base that did not exist before it.
841    ///
842    /// The two bases that predate the library stay writable as bare names at
843    /// the version that introduced them, and their emission does not move —
844    /// which is what makes this a widening rather than a break. What is fenced
845    /// is saying something the old surface had no spelling for.
846    pub fn needs_horizon_library(&self) -> bool {
847        match self {
848            Horizon::Spec(_) => true,
849            Horizon::Name(base) => match base {
850                HorizonBase::Void | HorizonBase::Ocean => false,
851                HorizonBase::Valley => true,
852            },
853        }
854    }
855}
856
857/// The resolved base of an optional stage-1 `horizon` field — `Void` when
858/// absent. The one helper every downstream consumer (placement, the ambient
859/// model, emission) goes through, so that a new base cannot be forgotten at one
860/// of them.
861pub fn horizon_base(horizon: &Option<Horizon>) -> HorizonBase {
862    horizon.as_ref().map(|h| h.base()).unwrap_or_default()
863}
864
865/// The resolved view of an optional stage-1 `horizon` field, with defaults
866/// applied for an absent one.
867pub fn resolved_horizon(horizon: &Option<Horizon>) -> ResolvedHorizon {
868    horizon.as_ref().map(|h| h.resolved()).unwrap_or_default()
869}
870
871/// The default boundary `margin` (blocks of horizontal breathing room added
872/// around the derived region). Separate function so `serde(default = …)` and the
873/// documented literal cannot drift.
874fn default_margin() -> u16 {
875    16
876}
877
878/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
879/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
880/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
881/// placed block − 8) — never authored — so "every anchor is inside" is structural.
882/// Enforcement is a per-second clock that returns any player outside the region to
883/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
884/// damage, no items lost.
885#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
886#[serde(deny_unknown_fields)]
887pub struct Boundary {
888    /// Horizontal breathing room in blocks added around the derived region on
889    /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
890    #[serde(default = "default_margin")]
891    pub margin: u16,
892    /// Actionbar message shown on return. Absent = the compiler's English default.
893    /// When set, it is inventoried under l10n key `world.boundary.message` and is
894    /// translated like every other player-facing string.
895    #[serde(default, skip_serializing_if = "Option::is_none")]
896    pub message: Option<String>,
897}
898
899/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
900/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
901/// compiler owns the placement rule and block-light emission.
902#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
903#[serde(rename_all = "kebab-case")]
904pub enum Fixture {
905    /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
906    Torch,
907    /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
908    Lantern,
909    /// Floor campfire (block light 15); never on or adjacent to a required path
910    /// cell (it is a damage source).
911    Campfire,
912    /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
913    Shroomlight,
914}
915
916impl Fixture {
917    /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
918    pub fn token(self) -> &'static str {
919        match self {
920            Fixture::Torch => "torch",
921            Fixture::Lantern => "lantern",
922            Fixture::Campfire => "campfire",
923            Fixture::Shroomlight => "shroomlight",
924        }
925    }
926}
927
928/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
929/// presence puts the area on the relight path: the compiler guarantees every
930/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
931/// with `DW0211` if it cannot.
932#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
933#[serde(deny_unknown_fields)]
934pub struct AreaLighting {
935    /// The fixture the relight pass places.
936    pub fixture: Fixture,
937    /// The minimum block+sky light guaranteed on reachable walkable cells
938    /// (1..=14, default 7). Range-checked (`DW0196`).
939    #[serde(default = "default_min_light")]
940    pub min_light: u8,
941}
942
943/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
944fn default_min_light() -> u8 {
945    7
946}
947
948/// A per-area **darkness mitigation** declaration (DSL v0.6).
949///
950/// The first-class answer to "this area is meant to be dark, and the players are
951/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
952/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
953/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
954/// gap between the check and the feature.
955///
956/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
957/// for `night vision`: that accepted a renamed water bottle, so the gate passed
958/// while nothing in the world granted night vision (owner, island QA).
959#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
960#[serde(rename_all = "kebab-case")]
961pub enum AreaMitigation {
962    /// Every player inside the area's placed bounds is kept under
963    /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
964    NightVision,
965}
966
967/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
968///
969/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
970/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
971/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
972/// full jigsaw layout semantics are spec-0002's.
973#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
974#[serde(deny_unknown_fields)]
975pub struct Area {
976    /// Unique area id.
977    pub id: AreaId,
978    /// Player-facing area name.
979    pub name: String,
980    /// The single prefab bound to this area (mutually exclusive with
981    /// `prefab_pool`).
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub prefab: Option<PrefabId>,
984    /// The jigsaw prefab pool bound to this area (mutually exclusive with
985    /// `prefab`); requires `pieces`.
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub prefab_pool: Option<PoolId>,
988    /// Jigsaw piece-count bounds (only with `prefab_pool`).
989    #[serde(default, skip_serializing_if = "Option::is_none")]
990    pub pieces: Option<Pieces>,
991    /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
992    /// present, the compiler's relight pass guarantees `min_light` on every
993    /// reachable walkable cell of this area by placing the declared fixture, or
994    /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
995    /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
996    #[serde(default, skip_serializing_if = "Option::is_none")]
997    pub lighting: Option<AreaLighting>,
998    /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
999    /// the compiler emit a clocked `effect give` over this area's placed bounds and
1000    /// is the (only) declaration that satisfies `DW0210` without `lighting`.
1001    /// Independent of `lighting`: an area may declare both (fixtures *and* the
1002    /// effect), either, or neither.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub mitigation: Option<AreaMitigation>,
1005    /// **The sky this place stands under from the first tick** (spec-0080
1006    /// §3.2): one of `world.atmospheres[]`, painted at world setup over the
1007    /// area's placed bounds grown up and down as far as the client's biome
1008    /// blend reads. The volume is the placement's, never typed.
1009    /// Absent: the horizon's biome.
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub atmosphere: Option<AtmosphereId>,
1012}
1013
1014/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
1015#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1016#[serde(deny_unknown_fields)]
1017pub struct Pieces {
1018    /// Minimum number of pieces to assemble.
1019    pub min: u32,
1020    /// Maximum number of pieces to assemble.
1021    pub max: u32,
1022}
1023
1024// ---------------------------------------------------------------------------
1025// Body traversal — the declaration every body that moves carries (DSL v0.11,
1026// spec-0034)
1027// ---------------------------------------------------------------------------
1028
1029/// How a body gets around.
1030///
1031/// The compiler DERIVES this from the entity id for every body (spiders climb,
1032/// ghasts fly, `#minecraft:aquatic` swims, and everything else — including every
1033/// id the table has never heard of — is [`Locomotion::Ground`], the checked
1034/// class). [`BodyTraversal`] is the author's side of the same vocabulary: one
1035/// enum, so a declaration and a derivation can never mean different things.
1036///
1037/// The vocabulary lives in this crate rather than in the compiler because it is
1038/// now DSL surface; the compiler re-exports it and owns the derivation table
1039/// (`compiler::traversal`).
1040#[derive(
1041    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1042)]
1043#[serde(rename_all = "kebab-case")]
1044pub enum Locomotion {
1045    /// Walks, steps and jumps — the default and the CHECKED class.
1046    Ground,
1047    /// Climbs sheer vertical surfaces (vanilla's `Spider` class).
1048    Climber,
1049    /// Leaves the ground under its own power.
1050    Flier,
1051    /// A member of vanilla's `#minecraft:aquatic` tag. A ledger classification
1052    /// that exempts nothing, which is why it may not be **declared**
1053    /// (`DW0455`) — see [`BodyTraversal`].
1054    Aquatic,
1055}
1056
1057impl Locomotion {
1058    /// The stable kebab token this class is written and reported under.
1059    pub fn token(self) -> &'static str {
1060        match self {
1061            Locomotion::Ground => "ground",
1062            Locomotion::Climber => "climber",
1063            Locomotion::Flier => "flier",
1064            Locomotion::Aquatic => "aquatic",
1065        }
1066    }
1067
1068    /// Every class, in ledger order — so a report can never silently drop a row
1069    /// when a class is added.
1070    pub const ALL: [Locomotion; 4] = [
1071        Locomotion::Ground,
1072        Locomotion::Climber,
1073        Locomotion::Flier,
1074        Locomotion::Aquatic,
1075    ];
1076}
1077
1078/// What a body can do when it moves, **declared by the author** (DSL v0.11,
1079/// spec-0034).
1080///
1081/// Carried by every object class in the DSL that has a body and a position and
1082/// is walked by a compiler-emitted route — the stage-2 [`Npc`] and the stage-5
1083/// [`Actor`]. It is deliberately one shared type on both rather than a field
1084/// per consumer: traversal is a property of a body that moves, not of the verb
1085/// that first needed it (CLAUDE.md), and a second bespoke field would be the
1086/// defect rather than the fix.
1087///
1088/// **A declaration is a claim the build holds you to, never an opt-out.** The
1089/// compiler compares the verdicts this body earns under the declared class
1090/// against the ones it earns under its species' derived class; a declaration
1091/// that changes no verdict is inert and is `DW0454`. So declaring `climber` on
1092/// a sheep is only accepted where that sheep's route really does go over a
1093/// barrier line — the exception is authored and proven, instead of happening by
1094/// accident and merely rendering.
1095///
1096/// **What is deliberately NOT here: `opens_gates`.** Passing a closed fence gate
1097/// is a right-click, a scripted walk is a compiler-emitted `tp` polyline whose
1098/// puppet performs no interaction at all, and no runtime verb changes a fence
1099/// gate's block state. Declaring it would not make it true, so the error tier
1100/// (`DW0452`) has no authorable exemption and a declaration can never reach it.
1101#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1102#[serde(deny_unknown_fields)]
1103pub struct BodyTraversal {
1104    /// How this body gets around, overriding what its entity id implies.
1105    pub locomotion: Locomotion,
1106}
1107
1108/// One object class that has a **body the compiler stages**: a declared
1109/// position, a declared species, and a compiler-emitted route.
1110///
1111/// A sum type rather than a flattened tuple, so **adding a body class is a
1112/// compile error at every consumer** until each one says what it does with it.
1113/// The alternative — each rule walking the classes it happens to remember — is
1114/// the defect class CLAUDE.md names: a hand-rolled walk that
1115/// enumerated three of five effect roots.
1116///
1117/// **Keyed to the object, not to the verb that first needed it.** This type was
1118/// introduced for [`BodyTraversal`] and read, for a while, as "one consumer of
1119/// `BodyTraversal`" — which is why its only enumeration
1120/// ([`body_traversal_sites`]) was filtered to bodies declaring a traversal, and
1121/// why the second property a body carries, its [`NpcSkin`], had no enumeration
1122/// at all: the bake walked the stage-2 npc list by hand, and an actor's skin was
1123/// emitted into the summon, never baked into the pack, and never refused.
1124/// A body's properties belong to the body. [`body_sites`] is the unfiltered
1125/// walk; a per-property enumeration is a *filter* over it, never a second walk.
1126///
1127/// Deliberately NOT a member: [`WaveMob`]. A wave mob has a body and a position,
1128/// but it is driven by **native vanilla AI**, never by a compiler-emitted route,
1129/// so the compiler makes no claim about the moves it makes and a locomotion
1130/// declaration on it could change no verdict. It declares no `skin` either — the
1131/// exclusion holds for both properties, and the schema says so
1132/// (`crates/dsl/tests/body_skin_sites.rs`). It becomes a member the day the lane
1133/// proof reasons about how its bodies move — and it joins here, through this
1134/// same type, rather than through a field of its own.
1135#[derive(Clone, Copy, Debug)]
1136pub enum BodyRef<'a> {
1137    /// A stage-2 NPC, walked by `move-npc`.
1138    Npc(&'a Npc),
1139    /// A stage-5 scripted actor, walked by `move-actor`.
1140    Actor(&'a Actor),
1141}
1142
1143impl<'a> BodyRef<'a> {
1144    /// The declaring stage's wire name (`npcs` / `quests`) — also the stage
1145    /// whose `dsl_version` fences this body's declaration.
1146    pub fn stage(self) -> &'static str {
1147        match self {
1148            BodyRef::Npc(_) => "npcs",
1149            BodyRef::Actor(_) => "quests",
1150        }
1151    }
1152
1153    /// The body's declared id.
1154    pub fn id(self) -> &'a str {
1155        match self {
1156            BodyRef::Npc(n) => n.id.as_str(),
1157            BodyRef::Actor(a) => a.id.as_str(),
1158        }
1159    }
1160
1161    /// The entity id written on the body. **Not necessarily the body that
1162    /// ships**: a `skin` re-dresses it as a `minecraft:mannequin` — see
1163    /// [`Self::worn_entity`].
1164    pub fn declared_entity(self) -> &'a str {
1165        match self {
1166            BodyRef::Npc(n) => n.base_entity.as_str(),
1167            BodyRef::Actor(a) => a.entity.as_str(),
1168        }
1169    }
1170
1171    /// **The entity id the body ships as**: `minecraft:mannequin` when it
1172    /// declares a `skin`, else the declared entity. The one authority for that
1173    /// rule — the compiler's geometric proofs (`nav::npc_body_entity`,
1174    /// `nav::actor_body_entity`) and the equipment fit rule (`DW0898`) all read
1175    /// it.
1176    pub fn worn_entity(self) -> &'a str {
1177        match self.skin() {
1178            Some(_) => "minecraft:mannequin",
1179            None => self.declared_entity(),
1180        }
1181    }
1182
1183    /// **The mark this body is placed on** — the anchor and offset the engine
1184    /// summons it at, for every class alike (spec-0066).
1185    ///
1186    /// A body's placement is a property of the body, not of the stage list that
1187    /// happens to declare it: a mark is a cell, and a cell holds one body. The
1188    /// rule that reads this ([`crate::compiler`]'s `DW0896`, via
1189    /// [`body_sites`]) therefore quantifies over npcs and actors in one pass
1190    /// rather than over `actors[]`, which is where the seven-men-one-anchor
1191    /// muster came from.
1192    pub fn mark(self) -> Mark {
1193        let (anchor, offset) = match self {
1194            BodyRef::Npc(n) => (&n.anchor, n.offset),
1195            BodyRef::Actor(a) => (&a.anchor, a.offset),
1196        };
1197        Mark {
1198            anchor: anchor.clone(),
1199            offset,
1200        }
1201    }
1202
1203    /// The area whose anchor table resolves [`Self::mark`]'s anchor first, when this
1204    /// class declares one.
1205    ///
1206    /// A stage-2 npc names its area and is resolved inside it; a stage-5 actor
1207    /// names none and is resolved across every placed piece, exactly as an
1208    /// `open-gate` / `move-actor` destination is. Stated here so the resolution
1209    /// rule is one rule over both classes and not a per-call-site habit.
1210    pub fn area(self) -> Option<&'a AreaId> {
1211        match self {
1212            BodyRef::Npc(n) => Some(&n.area),
1213            BodyRef::Actor(_) => None,
1214        }
1215    }
1216
1217    /// Whether this body stands on its mark from **world init**, with no effect
1218    /// having to fire.
1219    ///
1220    /// A stage-2 npc does unless it is `deferred`; a stage-5 actor never does —
1221    /// a puppet exists only from the `spawn-actor` that summons it, which is why
1222    /// an actor no `spawn-actor` names never exists at all.
1223    pub fn at_world_init(self) -> bool {
1224        match self {
1225            BodyRef::Npc(n) => !n.deferred,
1226            BodyRef::Actor(_) => false,
1227        }
1228    }
1229
1230    /// Whether a **player** can end this body's life.
1231    ///
1232    /// An npc body is emitted `Invulnerable:1b` unconditionally, so nothing a
1233    /// player does removes it; an actor's puppet is `Invulnerable` unless it
1234    /// declares [`Actor::vulnerable`]. A body a player can kill is one whose
1235    /// lifetime the compiler cannot bound, which is the whole of what this
1236    /// answers.
1237    pub fn killable_by_players(self) -> bool {
1238        match self {
1239            BodyRef::Npc(_) => false,
1240            BodyRef::Actor(a) => a.vulnerable,
1241        }
1242    }
1243
1244    /// This body's traversal declaration, if it carries one.
1245    pub fn traversal(self) -> Option<&'a BodyTraversal> {
1246        match self {
1247            BodyRef::Npc(n) => n.traversal.as_ref(),
1248            BodyRef::Actor(a) => a.traversal.as_ref(),
1249        }
1250    }
1251
1252    /// This body's skin declaration, if it carries one.
1253    ///
1254    /// A skinned body of **either** class ships as a `minecraft:mannequin`
1255    /// whose `profile.texture` resolves to `delvewright:npc/<campaign_id>/<texture_id>`,
1256    /// so either one owes the same `skins/<texture_id>.png` under the same refusal
1257    /// (`DW0309`). Answering it here is what stops the bake from being a
1258    /// property of one class.
1259    pub fn skin(self) -> Option<&'a NpcSkin> {
1260        match self {
1261            BodyRef::Npc(n) => n.skin.as_ref(),
1262            BodyRef::Actor(a) => a.skin.as_ref(),
1263        }
1264    }
1265
1266    /// This class's name in the JSON Schema export (`delvec schema --stage all`).
1267    ///
1268    /// The join between the closed Rust set and the schema, which is the only
1269    /// authority on *which object classes declare what*. `body_skin_sites.rs`
1270    /// compares the two.
1271    pub fn class(self) -> &'static str {
1272        match self {
1273            BodyRef::Npc(_) => "Npc",
1274            BodyRef::Actor(_) => "Actor",
1275        }
1276    }
1277
1278    /// Every body class, by schema name. The closed set, stated once.
1279    pub const ALL_CLASSES: [&'static str; 2] = ["Npc", "Actor"];
1280}
1281
1282/// A staged body declaration, with the JSON pointer at the declaration itself.
1283///
1284/// The pointer is at the OBJECT (`/content/npcs/3`), not at any one of its
1285/// fields: a per-property site appends its own field name. A pointer built per
1286/// property is how two walks of one population start disagreeing about where a
1287/// thing was declared.
1288#[derive(Clone, Debug)]
1289pub struct BodySite<'a> {
1290    /// Which object class declared it, and the object itself.
1291    pub body: BodyRef<'a>,
1292    /// JSON pointer at the declaration, for a diagnostic path.
1293    pub path: String,
1294}
1295
1296/// **Every** body the campaign declares, in stage order: stage-2 npcs in
1297/// declaration order, then stage-5 actors in declaration order.
1298///
1299/// The one walk of the campaign's bodies. A rule about a property a body carries
1300/// is a *filter* over this ([`body_traversal_sites`], [`body_skin_sites`]) — never
1301/// a second traversal, and never a hand-written loop over one stage's list,
1302/// which is exactly how an actor's skin came to be emitted but never baked.
1303pub fn body_sites(c: &crate::envelope::Campaign) -> Vec<BodySite<'_>> {
1304    let mut out: Vec<BodySite<'_>> = Vec::new();
1305    for (i, n) in c.npcs.content.npcs.iter().enumerate() {
1306        out.push(BodySite {
1307            body: BodyRef::Npc(n),
1308            path: format!("/content/npcs/{i}"),
1309        });
1310    }
1311    for (i, a) in c.quests.content.actors.iter().enumerate() {
1312        out.push(BodySite {
1313            body: BodyRef::Actor(a),
1314            path: format!("/content/actors/{i}"),
1315        });
1316    }
1317    out
1318}
1319
1320/// A body that carries a [`BodyTraversal`] declaration, with the JSON pointer at
1321/// it.
1322#[derive(Clone, Debug)]
1323pub struct BodyTraversalSite<'a> {
1324    /// Which object class declared it, and the object itself.
1325    pub body: BodyRef<'a>,
1326    /// JSON pointer at the `traversal` field, for a diagnostic path.
1327    pub path: String,
1328    /// The declaration.
1329    pub traversal: &'a BodyTraversal,
1330}
1331
1332/// Every body in the campaign that DECLARES a traversal, in stage order.
1333///
1334/// The one enumeration of the declaration's consumers, shared by the DSL's value
1335/// check (`DW0455`) and by the compiler's proof (`DW0454`), so "which object
1336/// classes carry this" is answered in exactly one place.
1337pub fn body_traversal_sites(c: &crate::envelope::Campaign) -> Vec<BodyTraversalSite<'_>> {
1338    body_sites(c)
1339        .into_iter()
1340        .filter_map(|s| {
1341            s.body.traversal().map(|t| BodyTraversalSite {
1342                body: s.body,
1343                path: format!("{}/traversal", s.path),
1344                traversal: t,
1345            })
1346        })
1347        .collect()
1348}
1349
1350/// A body that carries an [`NpcSkin`] declaration, with the JSON pointer at it.
1351#[derive(Clone, Debug)]
1352pub struct BodySkinSite<'a> {
1353    /// Which object class declared it, and the object itself.
1354    pub body: BodyRef<'a>,
1355    /// JSON pointer at the `skin` field, for a diagnostic path.
1356    pub path: String,
1357    /// The declaration.
1358    pub skin: &'a NpcSkin,
1359}
1360
1361/// Every body in the campaign that DECLARES a skin, in stage order.
1362///
1363/// The one enumeration of what the resource-pack bake must serve and what
1364/// `DW0309` must refuse, for every class alike — a skinned npc and a skinned
1365/// actor are the same fact about two bodies.
1366///
1367/// **Declarations, not textures.** Two bodies may name one `texture_id` (an npc
1368/// and the puppet that plays it), and the caller decides what that means; the
1369/// bake reads each file once.
1370pub fn body_skin_sites(c: &crate::envelope::Campaign) -> Vec<BodySkinSite<'_>> {
1371    body_sites(c)
1372        .into_iter()
1373        .filter_map(|s| {
1374            s.body.skin().map(|k| BodySkinSite {
1375                body: s.body,
1376                path: format!("{}/skin", s.path),
1377                skin: k,
1378            })
1379        })
1380        .collect()
1381}
1382
1383/// The **mutable mirror** of [`body_skin_sites`]: every skin declaration in the
1384/// campaign, in the identical order, exposed mutably so one pass can rewrite what
1385/// every emitter will read ([`crate::l10n::namespace_skin_textures`]).
1386///
1387/// It carries no [`BodyRef`] and no pointer, because a rewrite needs neither and a
1388/// borrow of the whole body would forbid the field it is there to change. What it
1389/// does owe is the **same population**: a body class that declares a skin and is
1390/// missing here would keep an un-namespaced texture and collide with every other
1391/// delve, silently. `body_skin_sites_mut_is_the_same_walk`
1392/// (`crates/dsl/tests/body_skin_sites.rs`) pins that over a campaign carrying one
1393/// body of every class in [`BodyRef::ALL_CLASSES`] — the closed set the schema
1394/// export is compared against in the same file, so a new body class turns that
1395/// coverage red and both walks are visited together.
1396pub fn body_skins_mut(c: &mut crate::envelope::Campaign) -> Vec<&mut NpcSkin> {
1397    let mut out: Vec<&mut NpcSkin> = Vec::new();
1398    out.extend(
1399        c.npcs
1400            .content
1401            .npcs
1402            .iter_mut()
1403            .filter_map(|n| n.skin.as_mut()),
1404    );
1405    out.extend(
1406        c.quests
1407            .content
1408            .actors
1409            .iter_mut()
1410            .filter_map(|a| a.skin.as_mut()),
1411    );
1412    out
1413}
1414
1415// ---------------------------------------------------------------------------
1416// Stage 2 — npcs
1417// ---------------------------------------------------------------------------
1418
1419/// Stage 2 payload: the campaign's NPCs (casting sheets).
1420#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1421#[serde(deny_unknown_fields)]
1422pub struct NpcsContent {
1423    /// All NPCs in the campaign.
1424    pub npcs: Vec<Npc>,
1425}
1426
1427/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
1428///
1429/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
1430/// contract the stage-6 `dialogue` tree must honor.
1431#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1432#[serde(deny_unknown_fields)]
1433pub struct Npc {
1434    /// Unique NPC id.
1435    pub id: NpcId,
1436    /// Player-facing name.
1437    pub name: String,
1438    /// NPC role.
1439    pub role: Role,
1440    /// The area this NPC stands in (stage-1 ref).
1441    pub area: AreaId,
1442    /// The prefab anchor this NPC stands on.
1443    pub anchor: AnchorId,
1444    /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
1445    /// `[0, 0, 0]`): the NPC stands at the [`Mark`] the two fields spell.
1446    #[serde(default, skip_serializing_if = "is_zero3")]
1447    pub offset: [i32; 3],
1448    /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
1449    pub base_entity: String,
1450    /// The structured persona (character contract for stage 6).
1451    pub persona: Persona,
1452    /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
1453    /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
1454    /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
1455    /// Non-skinned NPCs are byte-identical to v0.3.
1456    #[serde(default, skip_serializing_if = "Option::is_none")]
1457    pub skin: Option<NpcSkin>,
1458    /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
1459    /// world init — its body and interaction hitbox only appear when a
1460    /// [`Verb::SpawnNpc`] fires, at this same `anchor`. The dual of
1461    /// `despawn-npc`: a character with a scripted entrance must not stand at its
1462    /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
1463    /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
1464    /// init, byte-identical to pre-0.6.
1465    #[serde(default, skip_serializing_if = "is_false")]
1466    pub deferred: bool,
1467    /// What this body can do when it moves (DSL v0.11, spec-0034). Absent = the
1468    /// class the compiler derives from `base_entity` (or from `minecraft:mannequin`
1469    /// when `skin` is set — the body that actually ships). See [`BodyTraversal`]:
1470    /// the declaration must change a verdict or it is `DW0454`, and it can never
1471    /// reach the error tier.
1472    #[serde(default, skip_serializing_if = "Option::is_none")]
1473    pub traversal: Option<BodyTraversal>,
1474}
1475
1476/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG is sourced from
1477/// the campaign dir's `skins/<texture_id>.png` and ships in the per-delve resource
1478/// pack at `assets/delvewright/textures/npc/<campaign_id>/<texture_id>.png`, which
1479/// is what the mannequin's `profile.texture` resolves to. The delve's own
1480/// directory is stamped on at emission ([`crate::l10n::namespace_skin_textures`])
1481/// — a client merges every applied pack's textures into ONE space, so two delves
1482/// that both cast a `keeper` would otherwise wear each other's faces. Nothing a
1483/// creator writes or names on disk carries it.
1484#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1485#[serde(deny_unknown_fields)]
1486pub struct NpcSkin {
1487    /// Skin id: the PNG basename under `skins/`, and the last segment of the
1488    /// resource-pack texture path (a bare kebab token; validated by `DW0190`).
1489    pub texture_id: String,
1490    /// Player model. **Required** (spec-0009): an omitted model renders slim, so
1491    /// a wide skin on a slim model is distorted — the compiler always emits it.
1492    pub model: SkinModel,
1493}
1494
1495/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
1496/// Alex). Emitted verbatim into the mannequin `profile.model`.
1497#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1498#[serde(rename_all = "kebab-case")]
1499pub enum SkinModel {
1500    /// Classic 4-pixel arms (Steve).
1501    Wide,
1502    /// Slim 3-pixel arms (Alex).
1503    Slim,
1504}
1505
1506impl SkinModel {
1507    /// The vanilla `profile.model` token.
1508    pub fn token(self) -> &'static str {
1509        match self {
1510            SkinModel::Wide => "wide",
1511            SkinModel::Slim => "slim",
1512        }
1513    }
1514}
1515
1516/// A structured casting sheet. Structure lives in the
1517/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
1518/// are required; the rest are optional.
1519#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1520#[serde(deny_unknown_fields)]
1521pub struct Persona {
1522    /// One-line character archetype (required).
1523    pub archetype: String,
1524    /// How the NPC speaks — register, tics, formality (required).
1525    pub speech_style: String,
1526    /// Emotional bearing toward the player (optional).
1527    #[serde(default, skip_serializing_if = "Option::is_none")]
1528    pub demeanor: Option<String>,
1529    /// What the NPC wants (required).
1530    pub motivation: String,
1531    /// Something the NPC hides (optional).
1532    #[serde(default, skip_serializing_if = "Option::is_none")]
1533    pub secret: Option<String>,
1534    /// Backstory colour (optional).
1535    #[serde(default, skip_serializing_if = "Option::is_none")]
1536    pub backstory: Option<String>,
1537    /// Attitudes toward other same-stage NPCs (optional; refs validated).
1538    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1539    pub relationships: Vec<Relationship>,
1540}
1541
1542/// One persona relationship: an attitude toward another same-stage NPC.
1543#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1544#[serde(deny_unknown_fields)]
1545pub struct Relationship {
1546    /// The other NPC (stage-2 ref, validated within stage 2).
1547    pub npc: NpcId,
1548    /// Free-text attitude toward that NPC.
1549    pub attitude: String,
1550}
1551
1552/// What a speaking part does. A schema enum offers what the engine accepts,
1553/// so there are two of them: how hard a fight is billed is [`EncounterTier`] on
1554/// the body that fights (a `waves[]` entry or a stage-5 actor), not a role here.
1555#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1556#[serde(rename_all = "kebab-case")]
1557pub enum Role {
1558    /// Gives and advances quests.
1559    QuestGiver,
1560    /// Flavor only.
1561    Flavor,
1562}
1563
1564// ---------------------------------------------------------------------------
1565// Stage 6 — dialogue
1566// ---------------------------------------------------------------------------
1567
1568/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
1569#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1570#[serde(deny_unknown_fields)]
1571pub struct DialogueContent {
1572    /// One tree per NPC (1:1 with stage 2, both directions).
1573    pub dialogues: Vec<NpcDialogue>,
1574}
1575
1576impl DialogueContent {
1577    /// The dialogue tree for an NPC id, if present.
1578    pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
1579        self.dialogues.iter().find(|t| t.npc.as_str() == npc)
1580    }
1581}
1582
1583/// One NPC's dialogue tree: a root node plus a set of nodes.
1584#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1585#[serde(deny_unknown_fields)]
1586pub struct NpcDialogue {
1587    /// The NPC this tree belongs to (stage-2 ref).
1588    pub npc: NpcId,
1589    /// The entry node id; every node must be reachable from it.
1590    pub root: DialogueId,
1591    /// The dialogue nodes.
1592    pub nodes: Vec<DialogueNode>,
1593}
1594
1595impl NpcDialogue {
1596    /// The ids of the nodes reachable from `roots` by following option `next`
1597    /// edges, ignoring every option gate.
1598    ///
1599    /// **The one authority for "what can this tree show, entered here".** A
1600    /// dialogue tree has more than one entry point — the stage-6 `root`, and
1601    /// every node a quest's `cast` ledger names as a scene — so "reachable" is
1602    /// always relative to a root SET, and each consumer supplies the set its own
1603    /// question is about. `DW0120`/`DW0123` ask about every entry point at once;
1604    /// the cast ledger's `DW0858` asks about the scenes live during one
1605    /// objective. Gates are ignored on purpose: an option's flag gate is
1606    /// `DW0191`'s question, not this one's.
1607    ///
1608    /// Root ids that name no node of this tree contribute nothing (a dangling
1609    /// scene root is `DW0464`, and a dangling stage-6 `root` is `DW0121`).
1610    pub fn reachable_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1611        let by_id: BTreeMap<&'a str, &'a DialogueNode> =
1612            self.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
1613        let mut seen: BTreeSet<&'a str> = BTreeSet::new();
1614        let mut stack: Vec<&'a str> = roots
1615            .iter()
1616            .filter_map(|r| by_id.get_key_value(*r).map(|(k, _)| *k))
1617            .collect();
1618        while let Some(cur) = stack.pop() {
1619            if !seen.insert(cur) {
1620                continue;
1621            }
1622            let Some(node) = by_id.get(cur) else { continue };
1623            for opt in &node.options {
1624                if let Some(next) = &opt.next
1625                    && let Some((k, _)) = by_id.get_key_value(next.as_str())
1626                {
1627                    stack.push(k);
1628                }
1629            }
1630        }
1631        seen
1632    }
1633
1634    /// The objective ids some option reachable from `roots` completes — what a
1635    /// player entering this tree at those scenes can actually finish.
1636    pub fn completes_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1637        let seen = self.reachable_from(roots);
1638        let mut out: BTreeSet<&'a str> = BTreeSet::new();
1639        for node in &self.nodes {
1640            if !seen.contains(node.id.as_str()) {
1641                continue;
1642            }
1643            for opt in &node.options {
1644                for eff in &opt.effects {
1645                    if let DialogueEffect::CompleteObjective { objective } = eff {
1646                        out.insert(objective.as_str());
1647                    }
1648                }
1649            }
1650        }
1651        out
1652    }
1653}
1654
1655/// One dialogue node: text plus branching options.
1656#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1657#[serde(deny_unknown_fields)]
1658pub struct DialogueNode {
1659    /// Node id (unique within this NPC's dialogue).
1660    pub id: DialogueId,
1661    /// The line the NPC speaks.
1662    pub text: String,
1663    /// Branching options; empty closes the dialog.
1664    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1665    pub options: Vec<DialogueOption>,
1666}
1667
1668/// One selectable dialogue option.
1669#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1670#[serde(deny_unknown_fields)]
1671pub struct DialogueOption {
1672    /// Button label.
1673    pub label: String,
1674    /// The full line this button is the caption of (DSL v0.8). Vanilla's dialog button codec
1675    /// (`CommonButtonData`) carries an optional `tooltip` component beside
1676    /// `label`, and the client hangs it on the button as a real hover tooltip —
1677    /// so a caption on the button and the sentence the character actually says
1678    /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
1679    /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
1680    /// scrolls. Player-visible, so it translates like the label
1681    /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
1682    #[serde(default, skip_serializing_if = "Option::is_none")]
1683    pub tooltip: Option<String>,
1684    /// Next node; omitted closes the dialog.
1685    #[serde(default, skip_serializing_if = "Option::is_none")]
1686    pub next: Option<DialogueId>,
1687    /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
1688    /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
1689    /// gated option is hidden until every referenced flag has been set by a
1690    /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
1691    /// option cannot make a critical-path node unreachable (`DW0191`).
1692    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1693    pub requires_flags: Vec<FlagId>,
1694    /// Negative flag gate (DSL v0.6): the option is
1695    /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
1696    /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
1697    /// option counts as *gated* for the `DW0191` deadlock guard: it can be
1698    /// suppressed at any point, so it cannot be the only completing path.
1699    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1700    pub forbids_flags: Vec<FlagId>,
1701    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
1702    /// hold for this gate to be open. The third field of the one gate, carried by
1703    /// every gate consumer — never by the verb that first wanted it. Default
1704    /// empty, so a pre-0.10 campaign is byte-identical.
1705    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1706    pub requires_state: Vec<StateCompare>,
1707    /// Effects fired when this option is chosen.
1708    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1709    pub effects: Vec<DialogueEffect>,
1710    /// What choosing this option does to the story (spec-0025). Required for a
1711    /// **story-weight** beat
1712    /// — an option carrying a `set-flag` effect, which is how a player's choice
1713    /// forks the world (`DW0481`). An option that only walks the tree or
1714    /// completes an objective needs none: the objective already declares one.
1715    #[serde(default, skip_serializing_if = "Option::is_none")]
1716    pub happening: Option<Happening>,
1717}
1718
1719/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
1720/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
1721/// dialogue choice, enabling flag-gated options/objectives/triggers).
1722#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1723#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
1724pub enum DialogueEffect {
1725    /// Marks a stage-5 objective complete.
1726    CompleteObjective {
1727        /// The objective to complete (resolved at the stage-5 boundary).
1728        objective: ObjectiveId,
1729    },
1730    /// Sets a campaign flag (DSL v0.4), mirroring [`Verb::SetFlag`].
1731    SetFlag {
1732        /// The flag to set.
1733        flag: FlagId,
1734    },
1735    /// Cuts the world time (DSL v0.5), mirroring [`Verb::SetTime`].
1736    SetTime {
1737        /// The time state to cut to.
1738        time: WorldTime,
1739    },
1740    /// Cuts the weather (DSL v0.5), mirroring [`Verb::SetWeather`].
1741    SetWeather {
1742        /// The weather state to cut to.
1743        weather: WorldWeather,
1744    },
1745    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
1746    /// [`Verb::SetCheckpoint`] — usable from a dialogue outcome.
1747    SetCheckpoint {
1748        /// The prefab checkpoint anchor the party respawns at.
1749        anchor: AnchorId,
1750        /// Per-player effects re-run on respawn while this checkpoint is active
1751        /// (scene reset). Empty = no hook.
1752        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1753        on_respawn: Vec<QuestEffect>,
1754    },
1755    /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
1756    /// [`Verb::SpawnNpc`] — a character who walks in mid-conversation.
1757    SpawnNpc {
1758        /// The NPC (stage-2 ref) to summon.
1759        npc: NpcId,
1760    },
1761}
1762
1763impl DialogueEffect {
1764    /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
1765    /// (`set-time`/`set-weather`, spec-0010).
1766    pub fn v05_effect(&self) -> Option<&'static str> {
1767        match self {
1768            DialogueEffect::SetTime { .. } => Some("set-time"),
1769            DialogueEffect::SetWeather { .. } => Some("set-weather"),
1770            _ => None,
1771        }
1772    }
1773
1774    /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
1775    /// (`set-checkpoint`, spec-0012; `spawn-npc`).
1776    pub fn v06_effect(&self) -> Option<&'static str> {
1777        match self {
1778            DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
1779            DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
1780            _ => None,
1781        }
1782    }
1783
1784    /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
1785    pub fn spawn_npc(&self) -> Option<&NpcId> {
1786        match self {
1787            DialogueEffect::SpawnNpc { npc } => Some(npc),
1788            _ => None,
1789        }
1790    }
1791
1792    /// **The body this dialogue effect puts into the world**, by id — the
1793    /// dialogue half of [`QuestEffect::body_entry`].
1794    ///
1795    /// A body can enter the world from a conversation as well as from a quest
1796    /// bundle, and a rule about what is standing where has to enumerate **every**
1797    /// entry point or it is a gate with a door beside it. This enum carries no
1798    /// exit at all: nothing a dialogue option does removes a body.
1799    pub fn body_entry(&self) -> Option<&str> {
1800        match self {
1801            DialogueEffect::SpawnNpc { npc } => Some(npc.as_str()),
1802            _ => None,
1803        }
1804    }
1805
1806    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
1807    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
1808        match self {
1809            DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
1810                Some((anchor, on_respawn.as_slice()))
1811            }
1812            _ => None,
1813        }
1814    }
1815
1816    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
1817    /// **effect root 5** ([`crate::effects`]). Named separately from
1818    /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
1819    /// same accessor modulo mutability, which is what lets one macro body generate
1820    /// both.
1821    pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
1822        match self {
1823            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
1824            _ => None,
1825        }
1826    }
1827
1828    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
1829    /// mutably so the localization pass can rewrite the player-visible strings
1830    /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
1831    /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
1832    /// that reaches it read-only needs a way to reach it writable too.
1833    pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
1834        match self {
1835            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
1836            _ => None,
1837        }
1838    }
1839}
1840
1841impl DialogueEffect {
1842    /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
1843    pub fn set_flag(&self) -> Option<&FlagId> {
1844        match self {
1845            DialogueEffect::SetFlag { flag } => Some(flag),
1846            _ => None,
1847        }
1848    }
1849
1850    /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
1851    /// (`set-flag`).
1852    pub fn v04_effect(&self) -> Option<&'static str> {
1853        match self {
1854            DialogueEffect::SetFlag { .. } => Some("set-flag"),
1855            _ => None,
1856        }
1857    }
1858
1859    /// The target time if this is a v0.5 `set-time` dialogue effect.
1860    pub fn set_time(&self) -> Option<WorldTime> {
1861        match self {
1862            DialogueEffect::SetTime { time } => Some(*time),
1863            _ => None,
1864        }
1865    }
1866
1867    /// The target weather if this is a v0.5 `set-weather` dialogue effect.
1868    pub fn set_weather(&self) -> Option<WorldWeather> {
1869        match self {
1870            DialogueEffect::SetWeather { weather } => Some(*weather),
1871            _ => None,
1872        }
1873    }
1874}
1875
1876// ---------------------------------------------------------------------------
1877// Stage 3 — classes
1878// ---------------------------------------------------------------------------
1879
1880/// Stage 3 payload: 1..4 selectable classes.
1881#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1882#[serde(deny_unknown_fields)]
1883pub struct ClassesContent {
1884    /// The selectable classes.
1885    pub classes: Vec<Class>,
1886}
1887
1888/// A player class with a starting kit.
1889#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1890#[serde(deny_unknown_fields)]
1891pub struct Class {
1892    /// Unique class id.
1893    pub id: ClassId,
1894    /// Player-facing name.
1895    pub name: String,
1896    /// Selection-screen blurb.
1897    pub blurb: String,
1898    /// Granted items.
1899    pub kit: Vec<KitItem>,
1900}
1901
1902/// One item in a class kit.
1903///
1904/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
1905/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
1906/// document using them is rejected as an unknown field (`DW0100`).
1907#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1908#[serde(deny_unknown_fields)]
1909pub struct KitItem {
1910    /// Vanilla item id, validated against the pinned 1.21.11 registry.
1911    pub item: String,
1912    /// Stack count.
1913    pub count: u32,
1914    /// Optional display name.
1915    #[serde(default, skip_serializing_if = "Option::is_none")]
1916    pub name: Option<String>,
1917    /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
1918    /// per-player gear by construction — every player who picks the class gets the
1919    /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
1920    /// enters the party, given to the first player to pick this class.
1921    #[serde(default, skip_serializing_if = "Option::is_none")]
1922    pub carrier: Option<Carrier>,
1923    /// **The flask** (DSL v0.8, spec-0016 §1): this kit
1924    /// entry is the class's recovery item, and resting at a bonfire replenishes
1925    /// it to exactly `count`. A campaign that places a `bonfire` and declares no
1926    /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
1927    /// dying an investment, so a souls campaign without one is a build error, not
1928    /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
1929    #[serde(default, skip_serializing_if = "is_false")]
1930    pub flask: bool,
1931    /// **What is in the bottle** (DSL v0.8, spec-0016 §1): the vanilla
1932    /// `minecraft:potion_contents` component of a
1933    /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
1934    /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
1935    /// nothing — which is exactly the placeholder flask this field exists to
1936    /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
1937    /// no `contents` is `DW0487`.
1938    #[serde(default, skip_serializing_if = "Option::is_none")]
1939    pub contents: Option<PotionContents>,
1940}
1941
1942/// The items whose vanilla item definition carries a `minecraft:potion_contents`
1943/// component — the only items a kit `contents` may be declared on (`DW0486`).
1944///
1945/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
1946/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
1947/// misode/mcmeta ref `crates/delvec/data/PROVENANCE.md` pins): exactly these
1948/// four items declare the component, and on any other item the game drops the
1949/// data on the floor.
1950pub const POTION_BEARING_ITEMS: &[&str] = &[
1951    "minecraft:lingering_potion",
1952    "minecraft:potion",
1953    "minecraft:splash_potion",
1954    "minecraft:tipped_arrow",
1955];
1956
1957/// True if `item_id` (optionally un-namespaced) is one of the four
1958/// [`POTION_BEARING_ITEMS`].
1959pub fn is_potion_bearing_item(item_id: &str) -> bool {
1960    let norm = if item_id.contains(':') {
1961        item_id.to_string()
1962    } else {
1963        format!("minecraft:{item_id}")
1964    };
1965    POTION_BEARING_ITEMS.contains(&norm.as_str())
1966}
1967
1968/// The two **instantaneous** status effects: they are applied once, on the tick
1969/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
1970/// `isInstantenous` before any effect instance is ever added), so a `duration` on
1971/// one is a statement the game never reads — `DW0486` says so rather than letting
1972/// an author believe they wrote a thirty-second heal.
1973pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
1974
1975/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
1976/// modelled field for field on vanilla rather than invented: a **named** potion,
1977/// a list of **custom effects**, or both, plus the bottle-colour override.
1978///
1979/// Vanilla resolves a drink as the named potion's effects followed by the custom
1980/// ones, and derives the bottle colour from those effects unless `color`
1981/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
1982/// Potion of Healing II a player would brew, not an approximation of one.
1983#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1984#[serde(deny_unknown_fields)]
1985pub struct PotionContents {
1986    /// A named vanilla potion id (`minecraft:strong_healing`,
1987    /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
1988    /// `potion` registry (`DW0486`).
1989    #[serde(default, skip_serializing_if = "Option::is_none")]
1990    pub potion: Option<String>,
1991    /// Custom effects applied on top of (or instead of) the named potion — the
1992    /// escape hatch for a recovery item vanilla has no brew for.
1993    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1994    pub effects: Vec<PotionEffect>,
1995    /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
1996    /// colour vanilla derives from the effects themselves.
1997    #[serde(default, skip_serializing_if = "Option::is_none")]
1998    pub color: Option<String>,
1999}
2000
2001/// One entry of a potion's `custom_effects` list.
2002#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2003#[serde(deny_unknown_fields)]
2004pub struct PotionEffect {
2005    /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
2006    /// against the pinned registry (`DW0486`).
2007    pub effect: String,
2008    /// How long it lasts, in **ticks** (20 = one second). Required for every
2009    /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
2010    #[serde(default, skip_serializing_if = "Option::is_none")]
2011    pub duration: Option<u32>,
2012    /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
2013    #[serde(default, skip_serializing_if = "Option::is_none")]
2014    pub amplifier: Option<u32>,
2015}
2016
2017impl PotionEffect {
2018    /// True if this effect is applied once on drinking rather than over time.
2019    pub fn is_instant(&self) -> bool {
2020        let norm = crate::registry::namespaced_effect_id(&self.effect);
2021        INSTANT_EFFECTS.contains(&norm.as_str())
2022    }
2023}
2024
2025/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
2026/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
2027/// need is refused — while a duration typed in *milliseconds*, or one that would
2028/// overflow vanilla's int, is caught (`DW0486`).
2029pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
2030
2031/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
2032/// unsigned byte, so 255 is not a policy but the end of the field.
2033pub const MAX_POTION_AMPLIFIER: u32 = 255;
2034
2035/// The largest `seconds` a [`Verb::GiveEffect`] may declare, derived from
2036/// [`MAX_POTION_DURATION_TICKS`] rather than chosen again: the two are the same
2037/// quantity in different units, and a second independently-picked ceiling is how
2038/// two limits for one fact drift apart. ≈13.9 hours, past the 10-hour delve
2039/// ceiling, so nothing a delve can legally need is refused — while a duration
2040/// typed in *ticks* or in milliseconds is caught (`DW0541`).
2041pub const MAX_EFFECT_SECONDS: u32 = MAX_POTION_DURATION_TICKS / 20;
2042
2043/// The canonical English title of the bonfire rest dialog. Baked at emit time
2044/// when the campaign authors no `prompt`, in the
2045/// `world.boundary.message` tradition: a compiler default is not inventoried, an
2046/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
2047pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
2048/// The canonical English label of the **rest and save** option.
2049pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
2050/// The canonical English label of the **save only** option.
2051pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
2052
2053/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
2054/// leaves the compiler's canonical English in place
2055/// ([`QuestEffect::bonfire_labels`]).
2056#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2057pub struct BonfireLabels<'a> {
2058    /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
2059    pub prompt: Option<&'a str>,
2060    /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
2061    pub rest_label: Option<&'a str>,
2062    /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
2063    pub save_label: Option<&'a str>,
2064    /// **Rest and save** button hover tooltip (spec-0078); `None` → none emitted.
2065    pub rest_tooltip: Option<&'a str>,
2066    /// **Save only** button hover tooltip (spec-0078); `None` → none emitted.
2067    pub save_tooltip: Option<&'a str>,
2068}
2069
2070impl BonfireLabels<'_> {
2071    /// The dialog title actually emitted.
2072    pub fn prompt_or_default(&self) -> &str {
2073        self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
2074    }
2075    /// The **rest and save** label actually emitted.
2076    pub fn rest_or_default(&self) -> &str {
2077        self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
2078    }
2079    /// The **save only** label actually emitted.
2080    pub fn save_or_default(&self) -> &str {
2081        self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
2082    }
2083}
2084
2085// ---------------------------------------------------------------------------
2086// Stage 4 — quest-plan
2087// ---------------------------------------------------------------------------
2088
2089/// Stage 4 payload: the quest dependency plan.
2090#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2091#[serde(deny_unknown_fields)]
2092pub struct QuestPlanContent {
2093    /// Planned quests (expanded in stage 5).
2094    pub quests: Vec<PlannedQuest>,
2095    /// The quest whose completion ends the campaign.
2096    pub finale: QuestId,
2097    /// The campaign's declared **story forks** (DSL v0.8, spec-0025). Empty/absent = a campaign that claims to have no
2098    /// branch — which the compiler then *verifies* rather than assumes: any flag
2099    /// that gates casts, staging or structure and is set on some playthroughs and
2100    /// not others belongs to no declared point and is `DW0480`.
2101    ///
2102    /// Enumerated branches are the **product of the declared points**, so the
2103    /// branch set is authored and small — never a combinatorial sweep of every
2104    /// flag in the campaign.
2105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2106    pub branch_points: Vec<BranchPoint>,
2107}
2108
2109impl QuestPlanContent {
2110    /// **The ONE authority on which quests are the spine**: the finale and every
2111    /// quest its `depends_on` chain transitively demands — the quests a body
2112    /// cannot reach the finale without.
2113    ///
2114    /// The capability belongs here, on the stage-4 document, because the spine is
2115    /// a fact about the quest plan and about nothing else. It had grown two
2116    /// derivations of the same closure in two files — one inline in
2117    /// [`crate::validate`]'s `DW0132` convergence check, one a private
2118    /// `mandatory_quests` in [`crate::layout`] read by the layout binding and by
2119    /// the critical-path spine obligation. Both were correct and neither said it
2120    /// was the authority, which is exactly the shape a later clean merge turns
2121    /// into two rules that disagree.
2122    ///
2123    /// **Why the closure is taken over the raw `depends_on` edges, unfiltered.**
2124    /// The `validate` copy first dropped every dep naming a quest the plan does
2125    /// not declare. That filtering is not this function's question: a dangling
2126    /// `depends_on` is `DW0112`'s finding, and silently pruning it here would
2127    /// make the set disagree with the document it is derived from. So an id the
2128    /// plan does not declare is reported in the spine and expands no further —
2129    /// and the one reader that could care, `DW0132`, only ever asks whether a
2130    /// **declared** quest is a member, so an undeclared member cannot change its
2131    /// verdict.
2132    ///
2133    /// Cycle-safe by construction (a quest already in the set is not expanded
2134    /// again), so a plan `DW0130` will refuse still yields a set rather than
2135    /// hanging.
2136    ///
2137    /// **Not the same question as the `mandatory` field**, and the name says so
2138    /// deliberately. Today the two sets always coincide, because `DW0132` demands
2139    /// every declared quest be a transitive dependency of the finale and `DW0866`
2140    /// demands every quest set `mandatory: true`. If `mandatory: false` ever
2141    /// becomes legal those coincide no longer, and this function keeps answering
2142    /// the graph question it has always answered.
2143    #[must_use]
2144    pub fn spine(&self) -> BTreeSet<&str> {
2145        let deps: BTreeMap<&str, &[QuestId]> = self
2146            .quests
2147            .iter()
2148            .map(|q| (q.id.as_str(), q.depends_on.as_slice()))
2149            .collect();
2150        let mut spine: BTreeSet<&str> = BTreeSet::new();
2151        let mut stack = vec![self.finale.as_str()];
2152        while let Some(q) = stack.pop() {
2153            if !spine.insert(q) {
2154                continue;
2155            }
2156            for dep in deps.get(q).copied().unwrap_or(&[]) {
2157                stack.push(dep.as_str());
2158            }
2159        }
2160        spine
2161    }
2162
2163    /// **The ONE authority on which quests are elective** (spec-0051): the
2164    /// quests declaring `mandatory: false`.
2165    ///
2166    /// The counterpart to [`Self::spine`], and deliberately a *different*
2167    /// question. `spine` asks what the graph demands; this asks what the author
2168    /// claims. `DW0866`/`DW0867` are exactly the rules that keep the two
2169    /// answers honest about each other, and they can only do that while each
2170    /// side has one derivation — which is the defect the spine function was
2171    /// created to end, and which a second private `!q.mandatory` filter in the
2172    /// compiler would re-introduce on the other half.
2173    #[must_use]
2174    pub fn optional(&self) -> BTreeSet<&str> {
2175        self.quests
2176            .iter()
2177            .filter(|q| !q.mandatory)
2178            .map(|q| q.id.as_str())
2179            .collect()
2180    }
2181}
2182
2183/// One declared story fork (DSL v0.8, spec-0025).
2184///
2185/// A branch point names the flag set the story forks on, the quest at which the
2186/// fork opens, and every branch it offers. Each branch pins the point's whole
2187/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
2188/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
2189/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
2190#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2191#[serde(deny_unknown_fields)]
2192pub struct BranchPoint {
2193    /// Unique branch-point id.
2194    pub id: BranchPointId,
2195    /// The quest at which the fork opens — every branch's divergent content is at
2196    /// or after it in the stage-4 DAG.
2197    pub opens_at: QuestId,
2198    /// The flag set the story forks on. Every branch's `flags` is a subset of
2199    /// this, and the flags it does not list are pinned **unset** on that branch.
2200    pub forks_on: Vec<FlagId>,
2201    /// The alternatives (≥ 2).
2202    pub branches: Vec<BranchDecl>,
2203}
2204
2205/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
2206#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2207#[serde(deny_unknown_fields)]
2208pub struct BranchDecl {
2209    /// Unique branch id (campaign-wide — it names the emitted chronicle file).
2210    pub id: BranchId,
2211    /// The subset of the point's `forks_on` that is SET on this branch. The rest
2212    /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
2213    /// option" branch — as long as it is genuinely reachable.
2214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2215    pub flags: Vec<FlagId>,
2216    /// Where this branch goes: either the `quest/<kebab>` the branches converge
2217    /// at, or the `ending/<kebab>` this branch runs to.
2218    ///
2219    /// One field, not two mutually exclusive ones, because the **id prefix
2220    /// already says which it is** — the same convention every cross-stage
2221    /// reference in the DSL uses. That is what makes "exactly one of them" an
2222    /// unrepresentable state rather than a rule some diagnostic has to police: a
2223    /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
2224    /// the ordinary malformed-id `DW0110`, and one that names nothing is the
2225    /// ordinary dangling-reference `DW0112`.
2226    pub leads_to: String,
2227}
2228
2229impl BranchDecl {
2230    /// The convergence quest, if [`Self::leads_to`] names one.
2231    pub fn converges_at(&self) -> Option<QuestId> {
2232        let q = QuestId(self.leads_to.clone());
2233        q.is_valid_syntax().then_some(q)
2234    }
2235
2236    /// The ending, if [`Self::leads_to`] names one.
2237    pub fn ending(&self) -> Option<EndingId> {
2238        let e = EndingId(self.leads_to.clone());
2239        e.is_valid_syntax().then_some(e)
2240    }
2241}
2242
2243/// What a story node does to the story (DSL v0.8, spec-0025).
2244///
2245/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
2246/// design that never got written down node by node cannot compile. It is
2247/// **node-local on purpose** — there is no parallel per-branch script document
2248/// that could itself drift from the graph.
2249///
2250/// `text` is authoring/validation metadata, never shown to a player, so it is
2251/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
2252/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
2253/// chronicle is assembled from.
2254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2255#[serde(deny_unknown_fields)]
2256pub struct Happening {
2257    /// The structured event verb — the machine-decidable part.
2258    pub verb: HappeningVerb,
2259    /// One line of prose stating what this node does to the story.
2260    pub text: String,
2261    /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
2262    /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
2263    /// story token the campaign tracks by hand. Optional, because not every beat
2264    /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
2265    /// over the beats that name one.
2266    #[serde(default, skip_serializing_if = "Option::is_none")]
2267    pub subject: Option<String>,
2268}
2269
2270/// The subject a beat is about, and whether the document said so (spec-0071 §3).
2271///
2272/// Answered by [`QuestEffect::happening_subject`], the one derivation. `derived`
2273/// is carried rather than dropped because the two readers want different things
2274/// from it: the namespace check reports on what an author **wrote**, and the
2275/// chronicle reasons over what the beat **is about**.
2276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2277pub struct HappeningSubject<'a> {
2278    /// The subject id (`npc/`, `actor/`, `wave/`, `anchor/` or an `item/` label).
2279    pub id: &'a str,
2280    /// `true` when the effect's own single object supplied it, `false` when the
2281    /// `happening` states it.
2282    pub derived: bool,
2283}
2284
2285/// The structured event vocabulary (DSL v0.8, spec-0025).
2286///
2287/// Deliberately small and closed. These ten verbs are what make a subset of
2288/// narrative errors machine-decidable per branch (`DW0485`); everything else a
2289/// beat means lives in [`Happening::text`], which the compiler never interprets.
2290/// Extend only when a real campaign cannot state its beat with what is here.
2291#[derive(
2292    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2293)]
2294#[serde(rename_all = "kebab-case")]
2295pub enum HappeningVerb {
2296    /// The subject is killed / destroyed. Terminal: nothing the subject does may
2297    /// follow it on the same branch.
2298    Dies,
2299    /// The subject comes through alive — the explicit counterpart of `dies`,
2300    /// which is what lets a branch state that somebody *did not* die.
2301    Survives,
2302    /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
2303    Departs,
2304    /// The subject enters the stage.
2305    Arrives,
2306    /// Somebody learns a fact — the true-information beat.
2307    Learns,
2308    /// Somebody comes to believe something (whether or not it is true) — the beat
2309    /// that carries a wrong belief forward, which is where branch drift shows.
2310    Believes,
2311    /// The party (or the subject) gains a thing.
2312    Gains,
2313    /// The party (or the subject) loses a thing. A second `loses` with no
2314    /// intervening `gains` is spending what is already spent (`DW0485`).
2315    Loses,
2316    /// A way is opened.
2317    Opens,
2318    /// A way is sealed. Cleared by `opens`.
2319    Seals,
2320}
2321
2322/// One planned quest (dependency-graph node).
2323#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2324#[serde(deny_unknown_fields)]
2325pub struct PlannedQuest {
2326    /// Unique quest id.
2327    pub id: QuestId,
2328    /// Human-readable goal.
2329    pub goal: String,
2330    /// Area this quest takes place in (stage-1 ref).
2331    pub area: AreaId,
2332    /// NPCs involved (stage-2 refs).
2333    pub npcs: Vec<NpcId>,
2334    /// Prerequisite quests; edges must form a DAG.
2335    pub depends_on: Vec<QuestId>,
2336    /// `false` declares an optional quest (spec-0051).
2337    pub mandatory: bool,
2338    /// Act number (informational).
2339    pub act: u32,
2340}
2341
2342// ---------------------------------------------------------------------------
2343// Stage 5 — quests
2344// ---------------------------------------------------------------------------
2345
2346/// Stage 5 payload: quest expansions.
2347#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2348#[serde(deny_unknown_fields)]
2349pub struct QuestsContent {
2350    /// The expanded quests (1:1 with stage 4).
2351    pub quests: Vec<Quest>,
2352    /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
2353    /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
2354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2355    pub waves: Vec<Wave>,
2356    /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
2357    /// watches an anchor for a strike / use / approach event and fires a bundle
2358    /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
2359    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2360    pub triggers: Vec<EnvTrigger>,
2361    /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
2362    /// compiler-emitted per-tick teleport. Distinct from stage-2 NPCs
2363    /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
2364    /// staging effects. Empty/absent before v0.6.
2365    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2366    pub actors: Vec<Actor>,
2367    /// Traps (DSL v0.6, spec-0011; command payloads spec-0022): environmental
2368    /// hazards, each at one point anchor an area's prefab provides. Each says
2369    /// what springs the trap, what it then does (a command `payload`, a legacy
2370    /// dispenser `effect`, or both), how dangerous it is, how it is disarmed and
2371    /// whether it re-arms. Empty/absent in pre-0.6 campaigns, so a v0.5-or-earlier campaign that declares none stays
2372    /// byte-identical.
2373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2374    pub traps: Vec<Trap>,
2375    /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
2376    /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
2377    /// campaigns, so a campaign that declares none stays
2378    /// byte-identical.
2379    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2380    pub shortcuts: Vec<Shortcut>,
2381    /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
2382    /// springs them". Empty/absent in pre-0.6 campaigns.
2383    ///
2384    /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
2385    /// each ambush into `triggers`, so the canonical form of a campaign is its
2386    /// **desugared** form. That is what keeps the canonical round-trip idempotent
2387    /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
2388    /// second time and duplicate the trigger ids), and it means the sugar exists
2389    /// at exactly one layer boundary — the authored `.json` — with nothing
2390    /// downstream needing to know it was ever there. The list itself is kept in
2391    /// memory so diagnostics can name the ambush the author wrote.
2392    /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
2393    /// Empty/absent in pre-0.6 campaigns.
2394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2395    pub timed_gates: Vec<TimedGate>,
2396    /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
2397    /// given contents at world init. Empty/absent in pre-0.6 campaigns
2398    ///.
2399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2400    pub loot: Vec<Loot>,
2401    /// Runtime state data (DSL v0.10, spec-0031): named, scoped, integer-valued
2402    /// counters the campaign sets, adds to and clears at runtime, and compares
2403    /// against in any gate. A campaign that declares none emits none of it.
2404    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2405    pub state: Vec<StateDecl>,
2406    /// **The campaign's death beat** (DSL v0.10, spec-0031): effects run at the
2407    /// moment a player dies, for that player. Effect root **R7**
2408    /// ([`crate::EffectRootKind::OnDeath`]); a campaign that declares none emits
2409    /// none of it.
2410    ///
2411    /// **Why this is campaign-wide and not a field on a checkpoint.** The engine
2412    /// already has `on_respawn`, and it hangs off a `set-checkpoint` because
2413    /// *where you come back* is a property of the checkpoint. *That you died* is
2414    /// not: it is true at every point of the delve, under every checkpoint, and a
2415    /// bundle repeated on each checkpoint would be the same content written N
2416    /// times with N chances to forget one. So death is a moment in the campaign,
2417    /// and this is the one place it is named. Anything that should only happen in
2418    /// some phase of the delve is expressed by the ordinary per-effect
2419    /// `requires_flags` / `forbids_flags` gate every other root already carries —
2420    /// no second gating surface.
2421    ///
2422    /// **Audience is the dying player** (`Audience::Solo`, the audience
2423    /// `on_respawn` and `on_caught` already use): a death is one player's, and
2424    /// re-broadcasting it to the party would duplicate their narration and their
2425    /// kit. A beat the whole party should see is a `narrate` addressed by the
2426    /// author to the party through the effect's own vocabulary, not a different
2427    /// default here.
2428    ///
2429    /// **Timing.** It fires on the death edge while the player is still a corpse
2430    /// (`Health: 0.0f`, on the death screen) — see
2431    /// `emit::emit_checkpoint_functions`. That is the difference between this and
2432    /// `on_respawn`, which deliberately waits for the player to come back.
2433    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2434    pub on_death: Vec<QuestEffect>,
2435    /// Lethal volumes (DSL v0.10, spec-0031): declared boxes that kill whatever
2436    /// enters them. Empty/absent in pre-0.10 campaigns, so a
2437    /// campaign that declares none stays byte-identical.
2438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2439    pub lethal_volumes: Vec<LethalVolume>,
2440    /// Shops (DSL v0.10, spec-0032): interaction points that open a list of
2441    /// gated offers. Empty/absent in pre-0.10 campaigns, so a
2442    /// campaign that declares none stays byte-identical.
2443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2444    pub shops: Vec<Shop>,
2445    /// Recovery stakes (DSL v0.10, spec-0032): what a death forfeits, where the
2446    /// marker lands, and how it comes back. Empty/absent in pre-0.10 campaigns
2447    ///, so a campaign that declares none stays byte-identical.
2448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2449    pub stakes: Vec<Stake>,
2450    /// Assemblies (spec-0082): fixed things built of display entities that
2451    /// play clips from a library rig, can be struck in melee, and strike back
2452    /// at a player who stands where they reach. Appear on `spawn-assembly`,
2453    /// leave on `despawn-assembly`. Empty/absent = nothing emitted.
2454    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2455    pub assemblies: Vec<Assembly>,
2456    /// Loops (spec-0086): slabs whose crossing returns a body by a whole-block
2457    /// offset to an identical earlier section, held while a party gate is open.
2458    /// Empty/absent for every campaign that declares none, so such a campaign
2459    /// stays byte-identical.
2460    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2461    pub loops: Vec<Loop>,
2462    #[serde(default, skip_serializing)]
2463    pub ambushes: Vec<Ambush>,
2464    /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
2465    /// authored `ambushes` are deliberately KEPT after expansion so validation
2466    /// and the counterplay proof can attribute diagnostics to the ambush the
2467    /// author actually wrote; this flag is what makes a second expansion a no-op.
2468    #[serde(skip)]
2469    pub ambushes_expanded: bool,
2470}
2471
2472impl QuestsContent {
2473    /// Every environment trigger this stage produces: the authored `triggers`
2474    /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
2475    /// declared order.
2476    ///
2477    /// **This is the single trigger authority.** Validation, the l10n
2478    /// inventory, the flag/wave producer scans, the nav proofs and emission all
2479    /// read triggers through it, so an ambush behaves exactly like the trigger
2480    /// an author would otherwise hand-write — there is no second code path for
2481    /// the sugar to drift down. Deterministic (declaration order, no hashing).
2482    pub fn all_triggers(&self) -> Vec<EnvTrigger> {
2483        let mut out = self.triggers.clone();
2484        out.extend(self.ambushes.iter().map(Ambush::to_trigger));
2485        out
2486    }
2487
2488    /// **Does the campaign itself answer a right-click at `anchor`?**
2489    ///
2490    /// One predicate, read by both consumers of the press-answer rule, so they can
2491    /// never disagree about what "the campaign answered it" means: the compiler's
2492    /// synthesis (`plan::collect_press_answers`, which stands down where this is
2493    /// true) and the obligation on a shortcut door (`DW0429`, which fires where it
2494    /// is false). Split across the two crates they would drift, and the drift
2495    /// would read as "the compiler refused a door I answered".
2496    ///
2497    /// Deliberately the widest reading — *any* `use` trigger anchored there.
2498    /// Pressing it already does something the author chose, and the engine does
2499    /// not adjudicate whether what they chose counts as an answer.
2500    pub fn answers_press_at(&self, anchor: &str) -> bool {
2501        self.all_triggers()
2502            .iter()
2503            .any(|t| matches!(t.on, TriggerOn::Use) && t.at_anchor() == Some(anchor))
2504    }
2505
2506    /// Desugar every `ambush` into a real environment trigger and clear the
2507    /// ambush list (spec-0016 §3). Called once, by
2508    /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
2509    /// (a second call sees no ambushes left to expand).
2510    pub fn expand_ambushes(&mut self) {
2511        if self.ambushes_expanded || self.ambushes.is_empty() {
2512            return;
2513        }
2514        self.triggers = self.all_triggers();
2515        self.ambushes_expanded = true;
2516    }
2517
2518    /// The declared datum with this id, if any (DSL v0.10).
2519    pub fn state_decl(&self, id: &str) -> Option<&StateDecl> {
2520        self.state.iter().find(|s| s.id.as_str() == id)
2521    }
2522
2523    /// The declared stake with this id, if any (DSL v0.10, spec-0032).
2524    pub fn stake_decl(&self, id: &str) -> Option<&Stake> {
2525        self.stakes.iter().find(|s| s.id.as_str() == id)
2526    }
2527
2528    /// The declared assembly with this id, if any (spec-0082).
2529    pub fn assembly_decl(&self, id: &str) -> Option<&Assembly> {
2530        self.assemblies.iter().find(|a| a.id.as_str() == id)
2531    }
2532}
2533
2534// ---------------------------------------------------------------------------
2535// Stage 5 — runtime state (DSL v0.10, spec-0031)
2536// ---------------------------------------------------------------------------
2537
2538/// Who holds a datum's value (DSL v0.10, spec-0031).
2539///
2540/// **Declared, never inferred.** A datum's multiplayer semantics is the one
2541/// thing about it that cannot be recovered from its uses: a purse read on a
2542/// `talk-to` looks identical whether every player has their own or the party
2543/// shares one, and the difference decides the whole design. spec-0031 states it
2544/// as a rule, and the type makes it un-omittable — there is no default.
2545#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2546#[serde(rename_all = "kebab-case")]
2547pub enum StateScope {
2548    /// Each player holds their own value. Read and written against the acting
2549    /// player, so a bundle with no acting player (the scheduler) cannot touch one
2550    /// (`DW0503`).
2551    Player,
2552    /// One value the whole party shares — the same holder story flags use
2553    /// (spec-0018). Readable and writable from every audience, including the
2554    /// scheduler.
2555    Party,
2556}
2557
2558impl StateScope {
2559    /// The wire token (`player` / `party`).
2560    pub fn token(self) -> &'static str {
2561        match self {
2562            StateScope::Player => "player",
2563            StateScope::Party => "party",
2564        }
2565    }
2566}
2567
2568/// One declared runtime datum (DSL v0.10, spec-0031): a named, scoped,
2569/// integer-valued counter.
2570///
2571/// This is what [`FlagId`] is not. A flag is boolean, party-wide and
2572/// **monotonic** — no verb clears one — which is exactly right for "this has
2573/// happened" and useless for a balance, a floor number, or "a ride is in
2574/// progress". A datum clears, counts down as well as up, and states its scope.
2575#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2576#[serde(deny_unknown_fields)]
2577pub struct StateDecl {
2578    /// Unique datum id (`state/<kebab>`).
2579    pub id: StateId,
2580    /// Who holds the value. Required — see [`StateScope`].
2581    pub scope: StateScope,
2582    /// The value the datum starts at, and the value `clear-state` returns it to.
2583    /// Defaults to `0`.
2584    ///
2585    /// One field rather than a separate `initial` and `cleared`: "the value this
2586    /// datum has when nothing has happened to it yet" is one fact, and two fields
2587    /// would let a campaign declare a datum that can never be returned to its own
2588    /// starting state.
2589    #[serde(default, skip_serializing_if = "is_zero_i32")]
2590    pub initial: i32,
2591    /// Free prose: what this datum means. Never machine-checked, never shown to a
2592    /// player — the forcing function that makes an author say what the number is,
2593    /// the same role `cast[].doing` plays.
2594    #[serde(default, skip_serializing_if = "Option::is_none")]
2595    pub note: Option<String>,
2596    /// The player-visible name of this datum (DSL v0.10, spec-0032).
2597    ///
2598    /// **A named datum is a currency.** There is no separate `currencies` section
2599    /// and there deliberately is not: a purse is a runtime datum that the player
2600    /// can see, and "the player can see it" is a property of the datum, not a
2601    /// different object class. A second struct carrying `id` + `scope` +
2602    /// `initial` + `name` would be a private copy of this one, which is the defect
2603    /// CLAUDE.md names second.
2604    ///
2605    /// Present ⇒ every `set-state` / `add-state` / `clear-state` on this datum
2606    /// also states the new balance to whoever holds it, on the action bar, as
2607    /// `<name>: <value>` with the value carried by vanilla's own `score`
2608    /// component. Absent ⇒ the datum is silent bookkeeping and emission is exactly
2609    /// what it was.
2610    ///
2611    /// Player-visible, so it is inventoried under `state.<id>.name` and
2612    /// translated like any other authored line.
2613    #[serde(default, skip_serializing_if = "Option::is_none")]
2614    pub name: Option<String>,
2615    /// Where this datum **stands** on screen between changes (spec-0076).
2616    ///
2617    /// The announcement `name` buys fades with the action bar; a datum that
2618    /// declares `display` also occupies a vanilla display slot, so its balance is
2619    /// on screen at every moment for every player. Declared, never automatic: a
2620    /// creator may want a named tally that is spoken only when it moves, and the
2621    /// engine does not decide which of two named datums is the purse.
2622    ///
2623    /// Present ⇒ `setup` heads the datum's objective with its translated `name`,
2624    /// paints the value gold, and puts the objective in the slot. Requires `name`
2625    /// (the slot's heading is the display name, and without one it would show the
2626    /// objective's id) and a `player` scope (the sidebar hides `#`-prefixed
2627    /// holders, which is what a `party` datum's value lives on); one datum per
2628    /// campaign may stand, because the slot holds one objective — all three are
2629    /// `DW0919`. Absent ⇒ the datum announces itself and stands nowhere.
2630    #[serde(default, skip_serializing_if = "Option::is_none")]
2631    pub display: Option<StateDisplay>,
2632}
2633
2634/// The vanilla display slot a named datum stands in (spec-0076).
2635///
2636/// One value, because vanilla has one slot that stands for the viewer: `list`
2637/// shows only while the tab key is held and `below_name` draws under *other*
2638/// players' name tags, never over the viewer's own body. A second variant is
2639/// added when the pinned game offers a second standing surface, not before.
2640#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2641#[serde(rename_all = "kebab-case")]
2642pub enum StateDisplay {
2643    /// The right-hand sidebar: a heading (the datum's `name`) and one line per
2644    /// player, each showing that player's own balance.
2645    Sidebar,
2646}
2647
2648impl StateDisplay {
2649    /// The `minecraft:scoreboard_slot` token the slot is addressed by.
2650    pub fn slot(self) -> &'static str {
2651        match self {
2652            StateDisplay::Sidebar => "sidebar",
2653        }
2654    }
2655}
2656
2657/// serde `skip_serializing_if` helper: skip a zero `i32` (`StateDecl.initial`).
2658fn is_zero_i32(v: &i32) -> bool {
2659    *v == 0
2660}
2661
2662/// How a [`StateCompare`] relates a datum to its operand (DSL v0.10).
2663///
2664/// Four operators, not six: over integers `less-than n` is `at-most n-1` and
2665/// `greater-than n` is `at-least n+1`, so the extra spellings would add a second
2666/// way to say one thing and a second emission path to keep honest.
2667#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2668#[serde(rename_all = "kebab-case")]
2669pub enum CompareOp {
2670    /// The datum is exactly `value`.
2671    Equals,
2672    /// The datum is anything but `value`.
2673    NotEquals,
2674    /// The datum is `value` or more.
2675    AtLeast,
2676    /// The datum is `value` or less.
2677    AtMost,
2678}
2679
2680impl CompareOp {
2681    /// The wire token (`equals` / `not-equals` / `at-least` / `at-most`).
2682    pub fn token(self) -> &'static str {
2683        match self {
2684            CompareOp::Equals => "equals",
2685            CompareOp::NotEquals => "not-equals",
2686            CompareOp::AtLeast => "at-least",
2687            CompareOp::AtMost => "at-most",
2688        }
2689    }
2690}
2691
2692/// What one of the DSL v0.10 state verbs does to a datum — the value half of
2693/// [`QuestEffect::writes_state`].
2694#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2695pub enum StateWrite {
2696    /// `set-state`: the datum becomes this value.
2697    Set(i32),
2698    /// `add-state`: the datum moves by this signed amount.
2699    Add(i32),
2700    /// `clear-state`: the datum returns to its declared `initial`.
2701    Clear,
2702}
2703
2704/// One numeric term of a gate (DSL v0.10, spec-0031): *this datum compares thus
2705/// to this value*.
2706///
2707/// It rides [`Gate`](crate::gate::Gate) — the shared gate, carried by every
2708/// consumer of `requires_flags`/`forbids_flags` — and not any one verb. The
2709/// comparison's consumers are exactly the gate's consumers ("this door opens at
2710/// 500", "this line is withheld below 200", "this lever does nothing while the
2711/// car is moving"), so hanging it off the first verb that asked would leave the
2712/// second with no surface and make a second bespoke field look like the fix.
2713#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2714#[serde(deny_unknown_fields)]
2715pub struct StateCompare {
2716    /// The datum to read. Must be declared in the stage-5 `state` list
2717    /// (`DW0502`).
2718    pub state: StateId,
2719    /// How to compare it.
2720    pub op: CompareOp,
2721    /// What to compare it against.
2722    pub value: i32,
2723}
2724
2725/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
2726/// barrel the prefab already placed.
2727///
2728/// The container is **hardware the prefab authored**, exactly like a trap's
2729/// dispenser: this declaration gives an already-placed, already-lit, already
2730/// composed piece of furniture its contents. The compiler never places the
2731/// container itself — if the anchor's cell does not already hold one, that is a
2732/// content defect and a build error (`DW0431`), not something to paper over by
2733/// setblock-ing a chest into a wall.
2734#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2735#[serde(deny_unknown_fields)]
2736pub struct Loot {
2737    /// Unique loot id (`loot/<kebab>`).
2738    pub id: LootId,
2739    /// The anchor whose cell holds the container to fill.
2740    pub anchor: AnchorId,
2741    /// Contents, in declaration order. Slot assignment is positional and
2742    /// deterministic — the first entry lands in `container.0`, the second in
2743    /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
2744    pub items: Vec<LootItem>,
2745}
2746
2747/// One stack inside a [`Loot`] container.
2748#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2749#[serde(deny_unknown_fields)]
2750pub struct LootItem {
2751    /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
2752    /// 1.21.11 item registry (`DW0143`).
2753    pub item: String,
2754    /// Stack size. Defaults to 1.
2755    #[serde(default = "one_u32")]
2756    pub count: u32,
2757    /// Optional custom item name. Enters the l10n string inventory exactly like
2758    /// a class kit item's name.
2759    #[serde(default, skip_serializing_if = "Option::is_none")]
2760    pub name: Option<String>,
2761    /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
2762    /// 1.21 `minecraft:enchantments` item component — or, on a
2763    /// `minecraft:enchanted_book`, `minecraft:stored_enchantments`
2764    /// ([`enchantment_component`]).
2765    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2766    pub enchantments: BTreeMap<String, u32>,
2767}
2768
2769fn one_u32() -> u32 {
2770    1
2771}
2772
2773/// A stage-5 trap (DSL v0.6, spec-0011; command payloads spec-0022): an
2774/// environmental hazard at one cell of a placed piece.
2775///
2776/// **What the prefab has to provide is one point anchor with the trigger block
2777/// in its cell, and for most traps that is all.** [`Trap::at`] names the
2778/// trigger/hazard cell; the piece places the plate, tripwire or trapped chest
2779/// there (`DW0917`); the compiler models it as a hazard for the completability
2780/// proofs (`DW0342`) and, for a disarmable trap, emits the disarm affordance. A
2781/// [`payload`](Trap::payload) trap needs nothing else: **the compiler owns the
2782/// detection**, emitting a per-tick, edge-latched `execute … if entity @a[<cell>]` and running the authored effect
2783/// bundle from it.
2784///
2785/// Two things a piece must pre-wire, each for one case and neither for the
2786/// common one:
2787///
2788/// * the legacy [`effect`](Trap::effect) — a `dispense` payload the prefab's own
2789///   redstone fires — needs the anchor's `dispenser` socket cell, which the
2790///   compiler fills. That is the case "harm is redstone-native" (spec-0011) was
2791///   written about, and the only one in which no detection is emitted.
2792/// * a **flag-gated** trap ([`requires_flags`](Trap::requires_flags) /
2793///   [`forbids_flags`](Trap::forbids_flags)) needs the anchor's `trigger_block`,
2794///   because gating removes the trigger block from the world while the gate is
2795///   shut and puts it back verbatim (`DW0363`).
2796///
2797/// Player-vs-mob distinguishing matters in a sealed box-garden with controlled
2798/// mobs, so `trapped-chest` (opened by a player) is called out as the only
2799/// player-distinct trigger.
2800#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2801#[serde(deny_unknown_fields)]
2802pub struct Trap {
2803    /// Unique trap id (`trap/<kebab>`).
2804    pub id: TrapId,
2805    /// **The point anchor this trap sits on** — any anchor an area's prefab
2806    /// provides, whatever it is called. Its cell is the trigger/hazard cell the
2807    /// compiler models, and for a `payload` trap that cell, holding the block
2808    /// its [`trigger`](Trap::trigger) names, is the whole of what the piece has
2809    /// to provide: detection is the compiler's, the block is the piece's
2810    /// (`DW0917`).
2811    ///
2812    /// The anchor additionally needs a `dispenser` socket for a legacy
2813    /// [`effect`](Trap::effect) trap, and a `trigger_block` for a flag-gated one
2814    /// (`DW0363`). `anchor/trap` is the name the shipped pieces use, and a name
2815    /// is all it is.
2816    pub at: AnchorId,
2817    /// What springs the trap (all redstone-native).
2818    pub trigger: TrapTrigger,
2819    /// The **legacy** redstone consequence (spec-0011): a static dispenser
2820    /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
2821    /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
2822    /// so existing campaigns build unchanged. Optional since spec-0022; a trap
2823    /// must declare `effect`, `payload`, or both (`DW0440`).
2824    #[serde(default, skip_serializing_if = "Option::is_none")]
2825    pub effect: Option<TrapEffect>,
2826    /// The **command payload** (spec-0022): an ordered effect list in the same
2827    /// vocabulary quests use, run when the trigger fires. This is where a trap's
2828    /// consequence lives now — the compiler owns the detection tick and the
2829    /// effect vocabulary, so a trap's payload is authored like any other effect
2830    /// bundle rather than built out of dust and repeaters. Expressiveness moves
2831    /// from "what dust can carry" to "what the effect vocabulary can say":
2832    /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
2833    /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
2834    ///
2835    /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
2836    /// emitted before (byte-identical).
2837    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2838    pub payload: Vec<QuestEffect>,
2839    /// How dangerous the trap is. A `lethal` trap on the forced critical path
2840    /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
2841    /// carry none. Defaults to `harmful`.
2842    #[serde(default)]
2843    pub lethality: Lethality,
2844    /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
2845    /// turn the trap off — setting a flag and emptying the dispenser — before the
2846    /// trap cell is forced.
2847    #[serde(default, skip_serializing_if = "Option::is_none")]
2848    pub disarm: Option<TrapDisarm>,
2849    /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
2850    /// spent — the survivability path); `rearm` = re-fires each trigger (default).
2851    #[serde(default)]
2852    pub reset: TrapReset,
2853    /// Flags that must be set before the trap is considered active (mirrors
2854    /// [`EnvTrigger::requires_flags`]). Default empty.
2855    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2856    pub requires_flags: Vec<FlagId>,
2857    /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
2858    /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
2859    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2860    pub forbids_flags: Vec<FlagId>,
2861    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2862    /// hold for this gate to be open. The third field of the one gate, carried by
2863    /// every gate consumer — never by the verb that first wanted it. Default
2864    /// empty, so a pre-0.10 campaign is byte-identical.
2865    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2866    pub requires_state: Vec<StateCompare>,
2867}
2868
2869impl Trap {
2870    /// `(item, count)` if this trap declares a legacy `dispense` effect.
2871    pub fn dispense(&self) -> Option<(&str, u32)> {
2872        match &self.effect {
2873            Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
2874            None => None,
2875        }
2876    }
2877
2878    /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
2879    /// forced critical path).
2880    pub fn is_lethal(&self) -> bool {
2881        matches!(self.lethality, Lethality::Lethal)
2882    }
2883}
2884
2885/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
2886/// redstone-native — the hardware fires without any command — so the compiler
2887/// emits no detection for them; it only models the trigger cell as a hazard and
2888/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
2889/// is deliberately *not* a trap trigger: it is already fully expressible as an
2890/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
2891#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2892#[serde(rename_all = "kebab-case")]
2893pub enum TrapTrigger {
2894    /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
2895    PressurePlate,
2896    /// A tripwire line: any entity crossing it. Auto-rearms.
2897    Tripwire,
2898    /// A trapped chest: a *player opening it* (comparator pulse). The only
2899    /// player-distinct trigger — a controlled mob cannot spring it.
2900    TrappedChest,
2901}
2902
2903impl TrapTrigger {
2904    /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
2905    pub fn kind(&self) -> &'static str {
2906        match self {
2907            TrapTrigger::PressurePlate => "pressure-plate",
2908            TrapTrigger::Tripwire => "tripwire",
2909            TrapTrigger::TrappedChest => "trapped-chest",
2910        }
2911    }
2912
2913    /// Whether `block` (an id, with or without its blockstate) is the hardware
2914    /// this trigger kind names: the block the party sees and springs. A plate
2915    /// is any `*_pressure_plate`, a tripwire is the string itself
2916    /// (`minecraft:tripwire`, not the hook), a trapped chest is
2917    /// `minecraft:trapped_chest`.
2918    pub fn is_trigger_block(&self, block: &str) -> bool {
2919        let id = block.split('[').next().unwrap_or(block);
2920        match self {
2921            TrapTrigger::PressurePlate => id.ends_with("_pressure_plate"),
2922            TrapTrigger::Tripwire => id == "minecraft:tripwire",
2923            TrapTrigger::TrappedChest => id == "minecraft:trapped_chest",
2924        }
2925    }
2926
2927    /// The block [`TrapTrigger::is_trigger_block`] accepts, as a refusal names it.
2928    pub fn trigger_block_name(&self) -> &'static str {
2929        match self {
2930            TrapTrigger::PressurePlate => "a pressure plate (`minecraft:*_pressure_plate`)",
2931            TrapTrigger::Tripwire => "a tripwire string (`minecraft:tripwire`)",
2932            TrapTrigger::TrappedChest => "a trapped chest (`minecraft:trapped_chest`)",
2933        }
2934    }
2935}
2936
2937/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
2938/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
2939/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
2940/// block-destroying and unmodeled effects out of the schema by construction
2941/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
2942#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2943#[serde(rename_all = "kebab-case", deny_unknown_fields)]
2944pub enum TrapEffect {
2945    /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
2946    /// arrows, splash potions). The redstone fires it; terrain is untouched
2947    /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
2948    /// `Items` NBT — a deterministic, static payload.
2949    Dispense {
2950        /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
2951        item: String,
2952        /// How many to load into the dispenser stack.
2953        count: u32,
2954    },
2955}
2956
2957/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
2958/// forced-critical-path completability obligation (`DW0342`).
2959#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2960#[serde(rename_all = "kebab-case")]
2961pub enum Lethality {
2962    /// Can kill a full-health player — carries the `DW0342` obligation on the path.
2963    Lethal,
2964    /// Hurts but is not designed to kill (the default).
2965    #[default]
2966    Harmful,
2967    /// Cosmetic / trivial (a stumble, a scare).
2968    Nonlethal,
2969}
2970
2971/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
2972#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2973#[serde(rename_all = "kebab-case")]
2974pub enum TrapReset {
2975    /// Fires once, then is spent — the survivability path for a forced lethal trap
2976    /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
2977    /// soft-loop).
2978    Once,
2979    /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
2980    /// trap must be avoidable or disarmable, else `DW0342`.
2981    #[default]
2982    Rearm,
2983}
2984
2985/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
2986/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
2987/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
2988/// before the trap cell is forced. Discharges the `DW0342` obligation when the
2989/// affordance is reachable ahead of the trap without crossing it.
2990#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2991#[serde(deny_unknown_fields)]
2992pub struct TrapDisarm {
2993    /// The anchor the player interacts with to disarm.
2994    pub via: AnchorId,
2995    /// The flag set when the trap is disarmed (a new flag this trap produces; other
2996    /// objectives/triggers may read it via `requires_flags`).
2997    pub sets_flag: FlagId,
2998}
2999
3000/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
3001/// deterministic open/close clock, so passage is a timing read rather than a
3002/// permanent state.
3003///
3004/// **The proof is deliberately NOT all-phase passability** — a gate that
3005/// punishes bad timing is the entire point. What the
3006/// compiler requires is that the gate is *readable*: the set of entry phases from
3007/// which a walking player clears the span before it shuts must cover **≥ 20% of
3008/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
3009/// amount of learning the level makes it fair.
3010#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3011#[serde(deny_unknown_fields)]
3012pub struct TimedGate {
3013    /// Unique timed-gate id (`timed-gate/<kebab>`).
3014    pub id: TimedGateId,
3015    /// The gate anchor the clock drives. Its prefab metadata must declare a fill
3016    /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
3017    pub gate: AnchorId,
3018    /// Ticks the gate stays OPEN each cycle (> 0).
3019    pub open_ticks: u32,
3020    /// Ticks the gate stays CLOSED each cycle (> 0).
3021    pub closed_ticks: u32,
3022    /// Ticks after world init before the first open window begins (default 0) —
3023    /// how two gates in the same room are put out of step with each other. Must
3024    /// be less than the full cycle.
3025    #[serde(default, skip_serializing_if = "is_zero")]
3026    pub phase: u32,
3027    /// Whether the gate **kills** a player caught inside its region when it
3028    /// shuts (spec-0016 §4 addendum). A portcullis
3029    /// that merely shoves you aside teaches nothing; mistiming the crossing is
3030    /// supposed to be a death you learn from, which is the whole point of §4's
3031    /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
3032    /// absolute.
3033    ///
3034    /// This is a real judgement issued by command on the closing tick, not
3035    /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
3036    /// escapable, so it would make the portcullis a suggestion. `damage` at the
3037    /// closing edge is exact and unarguable.
3038    ///
3039    /// **Defaults to `false`**, so every campaign authored before this field
3040    /// existed compiles byte-identically; a delve opts its portcullis in.
3041    #[serde(default, skip_serializing_if = "is_false")]
3042    pub crush: bool,
3043    /// Optional **disarm** affordance (souls dossier §5.2): the third
3044    /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
3045    /// The real games' best timed hazards can be removed for good (Smouldering
3046    /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
3047    /// dance with is one rung short of the vocabulary.
3048    ///
3049    /// Interacting with the affordance suppresses the clock **permanently, with
3050    /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
3051    /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
3052    /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
3053    ///
3054    /// **Defaults to absent**, so every campaign authored before this field
3055    /// existed compiles byte-identically; a delve opts its portcullis in.
3056    #[serde(default, skip_serializing_if = "Option::is_none")]
3057    pub disarm: Option<TimedGateDisarm>,
3058}
3059
3060/// A [`TimedGate`]'s disarm affordance (souls dossier §5.2) — the
3061/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
3062/// grammar for every mechanism the party can switch off.
3063///
3064/// The player acts on the `via` anchor (a compiler-emitted interaction entity
3065/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
3066/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
3067/// put the gate back.
3068#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3069#[serde(deny_unknown_fields)]
3070pub struct TimedGateDisarm {
3071    /// The anchor the player interacts with to jam the gate. Must be an anchor
3072    /// some area's prefab provides, and never the gate anchor itself — the
3073    /// mechanism belongs beside the doorway, not inside the span that crushes.
3074    pub via: AnchorId,
3075    /// The flag set when the gate is disarmed (a new flag this gate produces;
3076    /// other objectives/triggers may read it via `requires_flags`).
3077    pub sets_flag: FlagId,
3078}
3079
3080/// serde `skip_serializing_if` helper: skip a `0`.
3081fn is_zero(n: &u32) -> bool {
3082    *n == 0
3083}
3084
3085/// A stage-5 **ambush** (spec-0016 §3) — one declaration for a beat that
3086/// otherwise takes a deferred actor set plus a hand-wired trigger.
3087///
3088/// **`telegraph` is optional, and that is a design ruling, not an oversight.**
3089/// The un-telegraphed ambush — the shove off the cliff you
3090/// could not have known about — is core souls vocabulary: 初见杀 is how the level
3091/// teaches. The engine does not sand that edge off.
3092///
3093/// What the engine *does* owe the player is **counterplay on the retry**: having
3094/// died once, an informed player must have something to do about it. Determinism
3095/// guarantees the second attempt meets the same ambushers in the same cells; the
3096/// compiler adds the missing half — `DW0376` proves the trigger cell is not a
3097/// sealed pocket, i.e. that with every ambusher standing where it will stand,
3098/// a route out still exists (a retreat, luring ground, a positioning line).
3099/// Dying uninformed is a lesson; dying with no play available is a broken beat.
3100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3101#[serde(deny_unknown_fields)]
3102pub struct Ambush {
3103    /// Unique ambush id (`ambush/<kebab>`).
3104    pub id: AmbushId,
3105    /// The anchor the trigger watches — the corner, doorway or ledge the player
3106    /// walks into.
3107    pub at: AnchorId,
3108    /// The stage-5 actors that spring (1..N). Each is summoned at its own
3109    /// declared anchor and immediately unleashed to real AI.
3110    pub actors: Vec<ActorId>,
3111    /// What springs it (`approach{range}` / `strike` / `use`), exactly the v0.4
3112    /// environment-trigger vocabulary.
3113    pub trigger: TriggerOn,
3114    /// The **optional** tell, fired at the trigger before the ambushers exist:
3115    /// a sound, a shadow, a line of narration. Empty = un-telegraphed, which is
3116    /// fully legal.
3117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3118    pub telegraph: Vec<QuestEffect>,
3119    /// What the spring does to the story (DSL v0.8, spec-0025; required at
3120    /// 0.8.0, `DW0481`).
3121    ///
3122    /// **The declaration lives here because this is the object the author
3123    /// wrote.** An ambush desugars into a `spawn-actor` plus an `unleash-actor`
3124    /// per listed actor, and every one of those is a story node `DW0481` demands
3125    /// a `happening` from — on effects the author never wrote and cannot reach.
3126    /// So for as long as this field did not exist, an `ambushes[]` entry could
3127    /// not compile at 0.8.0 or above at all: declared in the schema, accepted by
3128    /// the schema check, and refused at validation with a prescription naming a
3129    /// field that was not there. Nothing caught it because no campaign and no
3130    /// fixture had ever written an ambush at a version where the obligation was
3131    /// live — which is the case spec-0039 exists to make impossible.
3132    ///
3133    /// An ambush is **one** beat, not `2N` of them: the ambushers appearing and
3134    /// coming at you is a single dramatic moment, so one declaration covers the
3135    /// whole spring. [`Self::to_trigger`] stamps it onto the first generated
3136    /// `spawn-actor` so the chronicle carries the line at the right position,
3137    /// and `branch::check_happenings` reads the obligation off the ambush rather
3138    /// than off the beats derived from it. Repeating it on all `2N` is the
3139    /// obvious alternative and it is wrong twice over: the chronicle would gain
3140    /// `N` duplicate lines, and `DW0485` would see one subject act repeatedly
3141    /// and be right to call it a contradiction.
3142    #[serde(default, skip_serializing_if = "Option::is_none")]
3143    pub happening: Option<Happening>,
3144}
3145
3146impl Ambush {
3147    /// The environment trigger this ambush desugars to (spec-0016 §3): a
3148    /// one-shot trigger at `at` whose effects are the telegraph, then a
3149    /// `spawn-actor` + `unleash-actor` per listed actor, in declared order.
3150    ///
3151    /// This is the **single** expansion authority. Every consumer — validation,
3152    /// the l10n inventory, the flag/wave producer scans, nav, emission — reads
3153    /// triggers through [`QuestsContent::all_triggers`], so the sugar cannot
3154    /// diverge from a hand-written equivalent, and an ambush is exactly as
3155    /// debuggable as the trigger an author would otherwise type.
3156    pub fn to_trigger(&self) -> EnvTrigger {
3157        let mut effects = self.telegraph.clone();
3158        for (i, a) in self.actors.iter().enumerate() {
3159            effects.push(QuestEffect {
3160                when: None,
3161                // The ambush declaration, on the beat where the spring becomes
3162                // real. Only the FIRST, for the reason the field documents: one
3163                // ambush is one beat, and stamping the line on every generated
3164                // effect would pad the chronicle and trip `DW0485`.
3165                happening: if i == 0 { self.happening.clone() } else { None },
3166                audience: None,
3167                within: None,
3168                verb: Verb::SpawnActor { actor: a.clone() },
3169            });
3170        }
3171        for a in &self.actors {
3172            effects.push(Verb::UnleashActor { actor: a.clone() }.into());
3173        }
3174        EnvTrigger {
3175            id: TriggerId(format!(
3176                "trigger/{}",
3177                crate::l10n::local_id(self.id.as_str())
3178            )),
3179            at: Some(self.at.clone()),
3180            on: self.trigger.clone(),
3181            requires_flags: Vec::new(),
3182            forbids_flags: Vec::new(),
3183            requires_state: Vec::new(),
3184            once: true,
3185            // An ambush is a party beat by construction — it springs actors at
3186            // the room, not a reply to the one who walked in.
3187            audience: TriggerAudience::Party,
3188            effects,
3189        }
3190    }
3191}
3192
3193/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
3194///
3195/// The owner's definition of the pattern: between two rest points there are two
3196/// routes. The **short** one starts sealed and holds nothing; the **long** one is
3197/// full of enemies and mechanisms. You earn the far side the hard way, pull one
3198/// mechanism, and the short route opens **forever**. That moment is the design.
3199///
3200/// The compiler owns three obligations, none of them optional:
3201/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
3202///    long route genuinely exists (`DW0373`);
3203/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
3204///    pays nothing is a leak, not a shortcut (`DW0360`);
3205/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
3206///    (`DW0372`). There is no re-sealing verb to reach for.
3207///
3208/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
3209/// untouched by this — the two verbs are deliberately disjoint.
3210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3211#[serde(deny_unknown_fields)]
3212pub struct Shortcut {
3213    /// Unique shortcut id (`shortcut/<kebab>`).
3214    pub id: ShortcutId,
3215    /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
3216    /// carries the physical fill), and its metadata must declare the fill `block`
3217    /// the compiler clears — the same requirement `close-gate` has.
3218    pub gate: AnchorId,
3219    /// The FAR-side anchor whose interaction fires the permanent open. The
3220    /// compiler summons the affordance there and polls it, reusing the v0.4
3221    /// interaction-entity `use` primitive.
3222    pub unlock: AnchorId,
3223    /// Effects fired once, when the shortcut opens — the bar lifting, the
3224    /// elevator descending, the sound of a door you will never have to earn
3225    /// again. Emitted server-source-safe (the poll lives on the tick).
3226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3227    pub on_unlock: Vec<QuestEffect>,
3228}
3229
3230/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
3231/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
3232/// entity's attack/interaction records; `approach` is a `distance` selector on
3233/// the tick. Look-at / break-attempt detection is excluded on principle (no
3234/// vanilla primitive).
3235#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3236#[serde(deny_unknown_fields)]
3237pub struct EnvTrigger {
3238    /// Unique trigger id (`trigger/<kebab>`).
3239    pub id: TriggerId,
3240    /// The anchor this trigger watches. Required for `strike` / `use` /
3241    /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
3242    /// v0.6), which watches a *character* and names it in `on.npc` instead —
3243    /// there is no cell for the author to supply and no cell the compiler
3244    /// would use. Either mismatch is `DW0194`.
3245    #[serde(default, skip_serializing_if = "Option::is_none")]
3246    pub at: Option<AnchorId>,
3247    /// The event that fires it.
3248    pub on: TriggerOn,
3249    /// Flags that must be set before the trigger can fire (DSL v0.4).
3250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3251    pub requires_flags: Vec<FlagId>,
3252    /// Negative flag gate (DSL v0.6): the trigger is
3253    /// **suppressed** while ANY listed flag is set (by any player — flags are
3254    /// campaign state). The dual of `requires_flags`, so an "armed between two
3255    /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
3256    /// retaliation trigger with `requires_flags: [flag/sealed]` and
3257    /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
3258    /// the moment the wake beat takes over.
3259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3260    pub forbids_flags: Vec<FlagId>,
3261    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
3262    /// hold for this gate to be open. The third field of the one gate, carried by
3263    /// every gate consumer — never by the verb that first wanted it. Default
3264    /// empty, so a pre-0.10 campaign is byte-identical.
3265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3266    pub requires_state: Vec<StateCompare>,
3267    /// Fire at most once (default `true`, mirroring objective completion). Set
3268    /// `false` to allow re-firing every time the condition is met.
3269    #[serde(default = "default_true")]
3270    pub once: bool,
3271    /// **Who the trigger's effects address** (DSL v0.11). Default
3272    /// [`TriggerAudience::Party`], so every campaign written before this field
3273    /// existed is byte-identical.
3274    ///
3275    /// A trigger is two different things depending on what the author means by
3276    /// it. A pressure plate that opens a gate and narrates the room is a **party
3277    /// beat**: everyone should see it, and it does not matter who stepped on the
3278    /// plate. A barred door that answers *"this cannot be opened from this
3279    /// side"* is a **reply to one person**: broadcasting it tells four players
3280    /// about a door three of them are nowhere near.
3281    ///
3282    /// Until this field the second was inexpressible, which is why the two verbs
3283    /// that needed it (`close-gate`'s seal answer, and nothing at all for a
3284    /// shortcut door) grew their own private reply machinery instead. The
3285    /// capability belongs to the press, not to the verb.
3286    #[serde(default, skip_serializing_if = "TriggerAudience::is_party")]
3287    pub audience: TriggerAudience,
3288    /// Effects fired when the trigger matches.
3289    pub effects: Vec<QuestEffect>,
3290}
3291
3292impl EnvTrigger {
3293    /// The anchor this trigger watches, if it watches a place at all. `None`
3294    /// for `strike-npc`, whose target is a character.
3295    pub fn at_anchor(&self) -> Option<&str> {
3296        self.at.as_ref().map(|a| a.as_str())
3297    }
3298
3299    /// Whether this trigger's bundle is addressed to the player who pressed it.
3300    pub fn addresses_presser(&self) -> bool {
3301        self.audience == TriggerAudience::Presser
3302    }
3303}
3304
3305/// Who an [`EnvTrigger`]'s effects address (DSL v0.11).
3306///
3307/// **This is a dispatch decision, not a cosmetic one.** A `party` trigger is
3308/// polled on the tick with no executor, so `@s` does not exist and every
3309/// player-facing command addresses `@a`. A `presser` trigger is dispatched by a
3310/// `minecraft:player_interacted_with_entity` advancement — the one vanilla
3311/// primitive that runs a function *as the player who clicked* — so `@s` is the
3312/// presser and the bundle addresses them alone.
3313///
3314/// That primitive exists for **right-clicks only**. Vanilla records a left-click
3315/// on an interaction entity in NBT (which names a UUID no command can become) and
3316/// offers no criterion for it, so `presser` on a `strike` is refused (`DW0427`)
3317/// rather than approximated: per CLAUDE.md's no-hack rule, a capability with no
3318/// vanilla primitive under it is excluded, never faked downstream.
3319#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3320#[serde(rename_all = "kebab-case")]
3321pub enum TriggerAudience {
3322    /// The whole party (the default, and what every trigger did before v0.11).
3323    #[default]
3324    Party,
3325    /// The one player whose click fired it.
3326    Presser,
3327}
3328
3329impl TriggerAudience {
3330    /// Serde skip predicate: the default needs no field on the wire, so a
3331    /// canonical round-trip of a pre-0.11 campaign is byte-identical.
3332    fn is_party(&self) -> bool {
3333        *self == TriggerAudience::Party
3334    }
3335}
3336
3337/// The event an [`EnvTrigger`] watches (DSL v0.4).
3338#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3339#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
3340pub enum TriggerOn {
3341    /// The player attacks (left-clicks) the interaction entity at the anchor.
3342    Strike,
3343    /// The player uses (right-clicks) the interaction entity at the anchor.
3344    Use,
3345    /// The player comes within `range` blocks of the anchor.
3346    Approach {
3347        /// Approach radius (blocks).
3348        range: u32,
3349    },
3350    /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6).
3351    ///
3352    /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
3353    /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
3354    /// body eclipses that cell (`DW0359`), so the click never reaches the
3355    /// trigger — the owner's island round-7 finding. This form has no cell. It
3356    /// rides the interaction entity the NPC already owns, which is the entity a
3357    /// click on that NPC reaches by construction.
3358    ///
3359    /// Right-click and left-click stay separate all the way down: a
3360    /// `minecraft:interaction` records them in two distinct NBT fields
3361    /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
3362    /// and this trigger takes the left-click, on one shared hitbox.
3363    StrikeNpc {
3364        /// The NPC (stage-2 ref) whose body is the target.
3365        npc: NpcId,
3366    },
3367    /// The player attacks (left-clicks) an **assembly's hitbox** (spec-0082).
3368    ///
3369    /// The exact shape of [`TriggerOn::StrikeNpc`]: no `at`, because the target
3370    /// is an object with a hitbox of its own, and the trigger rides it. Melee
3371    /// only — the hitbox is a `minecraft:interaction`, and an arrow passes
3372    /// through one without writing its `attack` record (spec-0082 §8 row 5).
3373    /// `once: false` with an `add-state` is how a hit count is built.
3374    StrikeAssembly {
3375        /// The assembly (stage-5 `assemblies` ref) whose hitbox is the target.
3376        assembly: AssemblyId,
3377    },
3378}
3379
3380impl TriggerOn {
3381    /// The kebab tag (`strike` / `use` / `approach` / `strike-npc` /
3382    /// `strike-assembly`).
3383    pub fn kind(&self) -> &'static str {
3384        match self {
3385            TriggerOn::Strike => "strike",
3386            TriggerOn::Use => "use",
3387            TriggerOn::Approach { .. } => "approach",
3388            TriggerOn::StrikeNpc { .. } => "strike-npc",
3389            TriggerOn::StrikeAssembly { .. } => "strike-assembly",
3390        }
3391    }
3392
3393    /// Whether this event needs an `at` anchor — true for everything that
3394    /// watches a place, false for `strike-npc` and `strike-assembly`, which
3395    /// watch an object that carries its own hitbox.
3396    pub fn needs_anchor(&self) -> bool {
3397        !matches!(
3398            self,
3399            TriggerOn::StrikeNpc { .. } | TriggerOn::StrikeAssembly { .. }
3400        )
3401    }
3402
3403    /// The assembly whose hitbox this event watches (`strike-assembly` only).
3404    pub fn assembly_target(&self) -> Option<&AssemblyId> {
3405        match self {
3406            TriggerOn::StrikeAssembly { assembly } => Some(assembly),
3407            _ => None,
3408        }
3409    }
3410
3411    /// The NPC whose body this event watches (`strike-npc` only).
3412    pub fn npc_target(&self) -> Option<&NpcId> {
3413        match self {
3414            TriggerOn::StrikeNpc { npc } => Some(npc),
3415            _ => None,
3416        }
3417    }
3418}
3419
3420/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
3421/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
3422/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
3423/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
3424/// and the `kill` objective completes when the count reaches zero.
3425#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3426#[serde(deny_unknown_fields)]
3427pub struct Wave {
3428    /// Unique wave id.
3429    pub id: WaveId,
3430    /// The anchor the wave's mobs spawn at.
3431    pub anchor: AnchorId,
3432    /// The mobs that make up the wave (1..N).
3433    pub mobs: Vec<WaveMob>,
3434    /// Re-seat this wave every time the party rests at (or respawns from) a
3435    /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
3436    /// enemies come back. The compiler kills any survivor carrying the wave tag
3437    /// and re-runs the wave's own spawn function, so the room is restored to its
3438    /// authored composition and spawn cells.
3439    ///
3440    /// Inert without a `bonfire` in the campaign, which is a compile error
3441    /// (`DW0370`) rather than a silent no-op.
3442    #[serde(default, skip_serializing_if = "is_false")]
3443    pub respawns_on_rest: bool,
3444    /// Tower-defense lane routing (spec-0016 §6): march this wave along a
3445    /// waypoint polyline while distant, hand it to native AI the instant a
3446    /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
3447    /// stand), byte-identical.
3448    #[serde(default, skip_serializing_if = "Option::is_none")]
3449    pub lane: Option<WaveLane>,
3450    /// Where the wave's mobs materialize (spec-0016 §6). Absent =
3451    /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
3452    /// wave `anchor`.
3453    #[serde(default, skip_serializing_if = "Option::is_none")]
3454    pub summon: Option<WaveSummon>,
3455    /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
3456    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
3457    ///
3458    /// This is a **declaration, not a knob**: the compiler never scales content
3459    /// from it (spec-0023 "Out of scope"). It exists because the validation
3460    /// ladder's inverted floor gate needs to know which fights the content
3461    /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
3462    /// on its first attempt is reported as too easy for its billing. Marking it
3463    /// is how the author opts into that scrutiny; the alternative — inferring
3464    /// "elite" from how tuned a stack looks — is exactly the downstream folklore
3465    /// CLAUDE.md's no-hack rule forbids.
3466    ///
3467    /// **It does reach emission in exactly one place** (spec-0016 §1): in a
3468    /// campaign with a `bonfire`, a billed `elite`/
3469    /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
3470    /// rest *while it is still standing* — deleted and re-seated at full count
3471    /// and full health, so chipping it down one life at a time is never a path.
3472    /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
3473    /// `respawns_on_rest` at once.
3474    #[serde(default, skip_serializing_if = "Option::is_none")]
3475    pub tier: Option<EncounterTier>,
3476    /// A health bar over this wave's bodies (DSL v0.31, spec-0073): a named bar
3477    /// over their total health, drawn for every player within `range` blocks of a
3478    /// live one. Absent = no bar, byte-identical. Declared, never derived from
3479    /// `tier`; a `boss`-billed wave without one is advised (`DW0912`).
3480    #[serde(default, skip_serializing_if = "Option::is_none")]
3481    pub health_bar: Option<crate::healthbar::HealthBar>,
3482    /// What happens each time a player is credited with killing one of this
3483    /// wave's bodies (spec-0074) — effect root R9, the same
3484    /// [`OnKill`] an actor declares. Absent = no bundle, and the
3485    /// wave's emission is byte-identical.
3486    #[serde(default, skip_serializing_if = "Option::is_none")]
3487    pub on_kill: Option<OnKill>,
3488}
3489
3490/// What happens each time a body of this fight is killed (spec-0074): the
3491/// `on_kill` of a wave or an actor.
3492#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3493#[serde(deny_unknown_fields)]
3494pub struct OnKill {
3495    /// Whether a body that comes back pays again. Required where the fight comes
3496    /// back after the party has met it — a bonfire re-seats it, or the beat that
3497    /// seats it can fire more than once (`DW0915`); left off where it does not,
3498    /// and `every-kill` there is refused as inert (`DW0914`). No default: whether
3499    /// an economy can be farmed is the creator's judgement.
3500    #[serde(default, skip_serializing_if = "Option::is_none")]
3501    pub fires: Option<KillFires>,
3502    /// The effects, run as the credited player for each credited kill. Every verb
3503    /// an `on_objective_complete` bundle accepts, each gated by its own `when`.
3504    #[schemars(length(min = 1))]
3505    pub effects: Vec<QuestEffect>,
3506}
3507
3508/// Whether a body that comes back after a rest pays again (spec-0074 §4).
3509#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3510#[serde(rename_all = "kebab-case")]
3511pub enum KillFires {
3512    /// Over the whole delve the fight pays at most once per body it seats — the
3513    /// wave's body count, or once for an actor. A re-seat does not renew it.
3514    FirstKill,
3515    /// Every credited kill pays, however many times the fight is re-seated.
3516    EveryKill,
3517}
3518
3519impl KillFires {
3520    /// The kebab token, as it appears in the DSL.
3521    pub fn token(self) -> &'static str {
3522        match self {
3523            KillFires::FirstKill => "first-kill",
3524            KillFires::EveryKill => "every-kill",
3525        }
3526    }
3527}
3528
3529/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
3530/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
3531/// — by one emission site: a bonfire refreshes a billed wave that is still
3532/// standing. Nothing about the encounter itself is scaled from it.
3533#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3534#[serde(rename_all = "kebab-case")]
3535pub enum EncounterTier {
3536    /// Trash / pressure. No floor expectation: a bot that wins it cold proves
3537    /// nothing either way. The default.
3538    #[default]
3539    Ordinary,
3540    /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
3541    /// and spawn-and-unleash vocabulary).
3542    Elite,
3543    /// A campaign's named fight. Same floor rule as `elite`; the distinction is
3544    /// for the run report a human reads.
3545    Boss,
3546}
3547
3548impl EncounterTier {
3549    /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
3550    pub fn token(self) -> &'static str {
3551        match self {
3552            EncounterTier::Ordinary => "ordinary",
3553            EncounterTier::Elite => "elite",
3554            EncounterTier::Boss => "boss",
3555        }
3556    }
3557
3558    /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
3559    /// content bills as hard carries an expectation the bot can measure; an
3560    /// ordinary one does not.
3561    pub fn has_floor_expectation(self) -> bool {
3562        matches!(self, EncounterTier::Elite | EncounterTier::Boss)
3563    }
3564}
3565
3566/// Where a wave's mobs materialize (spec-0016 §6).
3567#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3568#[serde(rename_all = "kebab-case")]
3569pub enum WaveSummon {
3570    /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
3571    /// behaviour and the default.
3572    Anchor,
3573    /// **Spirit-summoned at the edge of perception**:
3574    /// each mob appears on the ring at its own `attributes.follow_range` from
3575    /// the wave `anchor`, so it acquires a target the instant it exists and
3576    /// closes under pure native AI. Species without patrol AI never march a
3577    /// lane; this is what they do instead — never a spawn on top of the party,
3578    /// never a mob that brushes past.
3579    ///
3580    /// With this mode the wave `anchor` is the **defended point** (what the ring
3581    /// is drawn around), not the spawn point. Each mob stack must declare its
3582    /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
3583    /// never guessed from a vanilla defaults table the compiler cannot verify.
3584    AggroEdge,
3585}
3586
3587/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
3588/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
3589/// (`docs/notes/td-routing-spike.md`).
3590///
3591/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
3592/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
3593/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
3594/// From that instant the mob is a plain native hostile. "Combat preempts
3595/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
3596/// no target — so the owner's rule (march while distant, fight with NATIVE AI
3597/// once aggroed, never brush past) falls out of the primitive unforced.
3598///
3599/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
3600/// pillager must keep its crossbow (`DW0384`).
3601#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3602#[serde(deny_unknown_fields)]
3603pub struct WaveLane {
3604    /// The lane polyline, in march order: anchors some area's prefab provides.
3605    /// At least one; consecutive legs (counting the wave `anchor` as the origin)
3606    /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
3607    /// within 10 blocks of arrival, so a tighter lane is a lane the engine
3608    /// quietly stops following (`DW0386`).
3609    pub waypoints: Vec<AnchorId>,
3610    /// The release radius, in blocks: the compiler sets every lane mob's
3611    /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
3612    /// at the same distance. The two MUST be equal — a patrolling raider that
3613    /// targets a player it cannot engage holds ground instead of marching, so a
3614    /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
3615    pub aggro_radius: u32,
3616}
3617
3618/// One mob stack in a [`Wave`].
3619#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3620#[serde(deny_unknown_fields)]
3621pub struct WaveMob {
3622    /// Vanilla entity id, validated against the pinned 1.21.11 registry.
3623    pub entity: String,
3624    /// How many to spawn.
3625    pub count: u32,
3626    /// Optional custom name (shown above the mob).
3627    #[serde(default, skip_serializing_if = "Option::is_none")]
3628    pub name: Option<String>,
3629    /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
3630    /// components. Enables e.g. a weakened live warden as a survivable stealth
3631    /// threat. Omitted = vanilla defaults.
3632    #[serde(default, skip_serializing_if = "Option::is_none")]
3633    pub attributes: Option<MobAttributes>,
3634    /// Optional permanent, ambient status effects (DSL v0.4).
3635    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3636    pub effects: Vec<MobEffect>,
3637    /// Optional worn/held equipment (DSL v0.6). A helmet is the
3638    /// sanctioned fix for daylight-burning undead — never
3639    /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
3640    /// (`DW0143`, the give-item family); every emitted slot carries drop
3641    /// chance 0 so players can never farm wave gear (no-grind constitution).
3642    #[serde(default, skip_serializing_if = "Option::is_none")]
3643    pub equipment: Option<MobEquipment>,
3644    /// What this mob leaves behind when it dies (DSL v0.9). Only an `elite`/`boss` wave may declare it (`DW0491`)
3645    /// — an ordinary mob's kit is never farmable. Empty = drop chance 0 on
3646    /// every slot.
3647    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3648    pub drops: Vec<MobDrop>,
3649}
3650
3651/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
3652/// id for the matching vanilla equipment slot; an unset slot stays empty —
3653/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
3654/// wither-skeleton sword) still applies unless overridden. Emitted as the
3655/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
3656/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
3657#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3658#[serde(deny_unknown_fields)]
3659pub struct MobEquipment {
3660    /// Head slot.
3661    #[serde(default, skip_serializing_if = "Option::is_none")]
3662    pub head: Option<EquipItem>,
3663    /// Chest slot.
3664    #[serde(default, skip_serializing_if = "Option::is_none")]
3665    pub chest: Option<EquipItem>,
3666    /// Legs slot.
3667    #[serde(default, skip_serializing_if = "Option::is_none")]
3668    pub legs: Option<EquipItem>,
3669    /// Feet slot.
3670    #[serde(default, skip_serializing_if = "Option::is_none")]
3671    pub feet: Option<EquipItem>,
3672    /// Main-hand slot. Overrides the compiler's armed-mob default.
3673    #[serde(default, skip_serializing_if = "Option::is_none")]
3674    pub main_hand: Option<EquipItem>,
3675    /// Off-hand slot.
3676    #[serde(default, skip_serializing_if = "Option::is_none")]
3677    pub off_hand: Option<EquipItem>,
3678    /// Body slot: horse armour, wolf armour, a llama's carpet, a nautilus's
3679    /// armour, a happy ghast's harness (spec-0067). Shown only on a body whose
3680    /// entity type draws it (`DW0898`).
3681    #[serde(default, skip_serializing_if = "Option::is_none")]
3682    pub body: Option<EquipItem>,
3683    /// Saddle slot (spec-0067). Shown only on a body whose entity type draws a
3684    /// saddle (`DW0898`).
3685    #[serde(default, skip_serializing_if = "Option::is_none")]
3686    pub saddle: Option<EquipItem>,
3687}
3688
3689impl MobEquipment {
3690    /// Every slot as `(dsl_field_name, piece)`, in [`EquipSlot::ALL`]'s order —
3691    /// the single iteration source for validation paths and emission.
3692    pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); EquipSlot::ALL.len()] {
3693        EquipSlot::ALL.map(|s| (s.field(), self.filled(s)))
3694    }
3695
3696    /// Every slot as `(slot, piece)`, in [`EquipSlot::ALL`]'s order.
3697    pub fn pieces(&self) -> [(EquipSlot, Option<&EquipItem>); EquipSlot::ALL.len()] {
3698        EquipSlot::ALL.map(|s| (s, self.filled(s)))
3699    }
3700
3701    /// The piece this equipment declaration puts in `slot`, if any. The single
3702    /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
3703    /// actually wears (`DW0490`).
3704    pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
3705        match slot {
3706            EquipSlot::Head => self.head.as_ref(),
3707            EquipSlot::Chest => self.chest.as_ref(),
3708            EquipSlot::Legs => self.legs.as_ref(),
3709            EquipSlot::Feet => self.feet.as_ref(),
3710            EquipSlot::MainHand => self.main_hand.as_ref(),
3711            EquipSlot::OffHand => self.off_hand.as_ref(),
3712            EquipSlot::Body => self.body.as_ref(),
3713            EquipSlot::Saddle => self.saddle.as_ref(),
3714        }
3715    }
3716}
3717
3718/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
3719/// fills it. The DSL name and the summon-NBT key differ (`main_hand` vs
3720/// `mainhand`), so both live here and nowhere else.
3721///
3722/// **The set is the pinned game's equipment-slot set** (spec-0067 §2), a
3723/// [`crate::metrics::Provenance::VanillaRule`]: the eight values the
3724/// `minecraft:equippable` component's `slot` field takes, per the Minecraft
3725/// Wiki page *Data component format/equippable* for Java 1.21.11, which are the
3726/// serialised names of the client's `EquipmentSlot` enum. The pinned item data
3727/// is the cross-check, not the source: every `slot` value an item declares is
3728/// asserted to be one of these, and no item declares `mainhand`, because a hand
3729/// takes anything.
3730#[derive(
3731    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
3732)]
3733#[serde(rename_all = "snake_case")]
3734pub enum EquipSlot {
3735    /// Head slot.
3736    Head,
3737    /// Chest slot.
3738    Chest,
3739    /// Legs slot.
3740    Legs,
3741    /// Feet slot.
3742    Feet,
3743    /// Main-hand slot.
3744    MainHand,
3745    /// Off-hand slot.
3746    OffHand,
3747    /// Body slot (horse armour, wolf armour, carpet, harness).
3748    Body,
3749    /// Saddle slot.
3750    Saddle,
3751}
3752
3753impl EquipSlot {
3754    /// Every slot, in emission order: the two hands, the four armour slots,
3755    /// then `body` and `saddle`. The one enumeration every slot list derives
3756    /// from — `MobEquipment::slots()`, the summon `equipment` / `drop_chances`
3757    /// compounds and the drop-strip line.
3758    pub const ALL: [EquipSlot; 8] = [
3759        EquipSlot::MainHand,
3760        EquipSlot::OffHand,
3761        EquipSlot::Head,
3762        EquipSlot::Chest,
3763        EquipSlot::Legs,
3764        EquipSlot::Feet,
3765        EquipSlot::Body,
3766        EquipSlot::Saddle,
3767    ];
3768
3769    /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
3770    pub fn field(self) -> &'static str {
3771        match self {
3772            EquipSlot::Head => "head",
3773            EquipSlot::Chest => "chest",
3774            EquipSlot::Legs => "legs",
3775            EquipSlot::Feet => "feet",
3776            EquipSlot::MainHand => "main_hand",
3777            EquipSlot::OffHand => "off_hand",
3778            EquipSlot::Body => "body",
3779            EquipSlot::Saddle => "saddle",
3780        }
3781    }
3782
3783    /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`) — also the
3784    /// game's own name for the slot, as an `equippable` component spells it.
3785    pub fn nbt(self) -> &'static str {
3786        match self {
3787            EquipSlot::Head => "head",
3788            EquipSlot::Chest => "chest",
3789            EquipSlot::Legs => "legs",
3790            EquipSlot::Feet => "feet",
3791            EquipSlot::MainHand => "mainhand",
3792            EquipSlot::OffHand => "offhand",
3793            EquipSlot::Body => "body",
3794            EquipSlot::Saddle => "saddle",
3795        }
3796    }
3797
3798    /// The slot the game names `name` (`mainhand`), if it is one.
3799    pub fn from_nbt(name: &str) -> Option<EquipSlot> {
3800        EquipSlot::ALL.into_iter().find(|s| s.nbt() == name)
3801    }
3802
3803    /// Whether this is a hand: a hand takes any item, so an item's own declared
3804    /// slot never contradicts it.
3805    pub fn is_hand(self) -> bool {
3806        matches!(self, EquipSlot::MainHand | EquipSlot::OffHand)
3807    }
3808}
3809
3810/// One declared drop of an elite or boss (DSL v0.9).
3811///
3812/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
3813/// usually one piece and never automatically everything. Two forms, told apart
3814/// by which field is present:
3815///
3816/// ```json
3817/// { "slot": "main_hand" }                                  // its axe
3818/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
3819/// ```
3820///
3821/// A `slot` entry must name a slot the same entity's `equipment` really fills
3822/// (`DW0490`) — the drop is the piece the player has been *looking at* through
3823/// the whole fight, so the two declarations cannot disagree. An `item` entry is
3824/// a token the fight *yields* rather than wears, and rides the entity's own
3825/// death loot table.
3826///
3827/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
3828/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
3829#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3830#[serde(untagged)]
3831pub enum MobDrop {
3832    /// A worn/held piece, named by its equipment slot.
3833    Slot(SlotDrop),
3834    /// A quest token the fight yields (not worn).
3835    Item(ItemDrop),
3836}
3837
3838/// The worn-piece form of a [`MobDrop`].
3839#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3840#[serde(deny_unknown_fields)]
3841pub struct SlotDrop {
3842    /// The equipment slot whose piece drops. Must be filled by the entity's own
3843    /// `equipment` declaration (`DW0490`).
3844    pub slot: EquipSlot,
3845}
3846
3847/// The quest-token form of a [`MobDrop`].
3848#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3849#[serde(deny_unknown_fields)]
3850pub struct ItemDrop {
3851    /// Vanilla item id, validated against the pinned 1.21.11 registry
3852    /// (`DW0143`, the give-item family).
3853    pub item: String,
3854    /// Display name the dropped stack carries as a `custom_name` component.
3855    /// Player-visible, so it enters the l10n string inventory and translates
3856    /// like any other line.
3857    #[serde(default, skip_serializing_if = "Option::is_none")]
3858    pub name: Option<String>,
3859}
3860
3861impl MobDrop {
3862    /// The equipment slot this entry names, or `None` for a quest-item drop.
3863    pub fn slot(&self) -> Option<EquipSlot> {
3864        match self {
3865            MobDrop::Slot(s) => Some(s.slot),
3866            MobDrop::Item(_) => None,
3867        }
3868    }
3869
3870    /// The item id this entry names, or `None` for a worn-piece drop.
3871    pub fn item(&self) -> Option<&str> {
3872        match self {
3873            MobDrop::Slot(_) => None,
3874            MobDrop::Item(i) => Some(&i.item),
3875        }
3876    }
3877
3878    /// The declared display name of a quest-item drop, when set.
3879    pub fn name(&self) -> Option<&String> {
3880        match self {
3881            MobDrop::Slot(_) => None,
3882            MobDrop::Item(i) => i.name.as_ref(),
3883        }
3884    }
3885
3886    /// Mutable access to the display name — the l10n traversal's hook.
3887    pub fn name_mut(&mut self) -> Option<&mut String> {
3888        match self {
3889            MobDrop::Slot(_) => None,
3890            MobDrop::Item(i) => i.name.as_mut(),
3891        }
3892    }
3893}
3894
3895/// One equipped item: either a bare item id, or an id carrying enchantments.
3896///
3897/// The plain form is the common case and stays a plain JSON string, which is
3898/// what keeps every campaign written before enchantments existed byte-identical
3899/// on re-serialisation:
3900///
3901/// ```json
3902/// "main_hand": "minecraft:netherite_sword"
3903/// "head": { "item": "minecraft:netherite_helmet",
3904///           "enchantments": { "minecraft:protection": 4 } }
3905/// ```
3906#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3907#[serde(untagged)]
3908pub enum EquipItem {
3909    /// A bare item id — no enchantments.
3910    Plain(String),
3911    /// An item id plus its enchantments.
3912    Enchanted(EnchantedItem),
3913}
3914
3915impl EquipItem {
3916    /// The item id, whichever form was authored.
3917    pub fn item(&self) -> &str {
3918        match self {
3919            EquipItem::Plain(s) => s,
3920            EquipItem::Enchanted(e) => &e.item,
3921        }
3922    }
3923
3924    /// The enchantments on this piece — empty for the plain form. `BTreeMap`
3925    /// ordered, so emission order is the id order and never hash order
3926    /// (ADR-0006).
3927    pub fn enchantments(&self) -> &BTreeMap<String, u32> {
3928        static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
3929            std::sync::LazyLock::new(BTreeMap::new);
3930        match self {
3931            EquipItem::Plain(_) => &EMPTY,
3932            EquipItem::Enchanted(e) => &e.enchantments,
3933        }
3934    }
3935}
3936
3937/// **The item component a stack's enchantments are written to** — vanilla's own
3938/// rule (`EnchantmentHelper.getComponentType` at 1.21.11): an enchanted book
3939/// STORES its enchantments (`minecraft:stored_enchantments`, what an anvil
3940/// applies to the item it is combined with), and every other item CARRIES them
3941/// (`minecraft:enchantments`). Both are in the pinned 1.21.11
3942/// `data_component_type` registry.
3943///
3944/// A property of the item, not of the surface that writes the stack: a book in
3945/// a `loot` chest, a book handed over by `give-item` and a book on an equipped
3946/// piece are one object, so every emitter asks this one function. An
3947/// enchantment map written to the other component is a book that glints and
3948/// an anvil ignores, which the game accepts without a word.
3949pub fn enchantment_component(item: &str) -> &'static str {
3950    match item.strip_prefix("minecraft:").unwrap_or(item) {
3951        "enchanted_book" => "minecraft:stored_enchantments",
3952        _ => "minecraft:enchantments",
3953    }
3954}
3955
3956/// The enchanted form of [`EquipItem`].
3957#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3958#[serde(deny_unknown_fields)]
3959pub struct EnchantedItem {
3960    /// Item id (e.g. `minecraft:netherite_chestplate`).
3961    pub item: String,
3962    /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
3963    /// the 1.21 `minecraft:enchantments` item component.
3964    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3965    pub enchantments: BTreeMap<String, u32>,
3966}
3967
3968/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
3969/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
3970#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3971#[serde(deny_unknown_fields)]
3972pub struct MobAttributes {
3973    /// `minecraft:max_health` base value.
3974    #[serde(default, skip_serializing_if = "Option::is_none")]
3975    pub max_health: Option<f64>,
3976    /// `minecraft:attack_damage` base value.
3977    #[serde(default, skip_serializing_if = "Option::is_none")]
3978    pub attack_damage: Option<f64>,
3979    /// `minecraft:movement_speed` base value.
3980    #[serde(default, skip_serializing_if = "Option::is_none")]
3981    pub movement_speed: Option<f64>,
3982    /// `minecraft:follow_range` base value.
3983    #[serde(default, skip_serializing_if = "Option::is_none")]
3984    pub follow_range: Option<f64>,
3985}
3986
3987/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
3988/// non-expiring `effect give`.
3989#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3990#[serde(deny_unknown_fields)]
3991pub struct MobEffect {
3992    /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
3993    /// pinned registry (`DW0192`).
3994    pub effect: String,
3995    /// Amplifier (0 = level I).
3996    pub amplifier: u32,
3997}
3998
3999/// One expanded quest.
4000#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4001#[serde(deny_unknown_fields)]
4002pub struct Quest {
4003    /// Quest id (matches a stage-4 planned quest).
4004    pub id: QuestId,
4005    /// What starts the quest.
4006    pub trigger: Trigger,
4007    /// Ordered objectives (intra-quest DAG via `after`).
4008    pub objectives: Vec<Objective>,
4009    /// Effects fired when a given objective completes.
4010    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4011    pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
4012    /// Effects fired when the whole quest completes.
4013    pub on_complete: Vec<QuestEffect>,
4014    /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
4015    /// live during this quest — spawned and not explicitly removed — where it
4016    /// stands, what it is doing, and what its right-click offers *for this
4017    /// quest's duration*.
4018    ///
4019    /// The ledger exists because an NPC's dialogue used to be one tree for the
4020    /// whole campaign: after the climactic escape a crew member still offered
4021    /// "Tell me what he is." — a premise question absurd once the story moved on.
4022    /// Declaring the scene per quest makes the compiler able to check it
4023    /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
4024    /// emitted right-click shows the root this quest declares, so a stale root
4025    /// retires *because the ledger says so*, not because an author remembered a
4026    /// flag.
4027    ///
4028    /// Every NPC live during the quest owes an entry (`DW0460`).
4029    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4030    pub cast: BTreeMap<NpcId, CastEntry>,
4031    /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
4032    /// `DW0481`).
4033    #[serde(default, skip_serializing_if = "Option::is_none")]
4034    pub happening: Option<Happening>,
4035}
4036
4037/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
4038///
4039/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
4040/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
4041/// per-branch casts, each gated by the flags that select its branch (spec-0020
4042/// proof 4: where the effect history is branch-dependent, a single flat
4043/// declaration cannot hold on every reachable branch, and `DW0462` says so).
4044#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4045#[serde(untagged)]
4046pub enum CastEntry {
4047    /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
4048    /// placement whose `at` is that keyword and which carries no `doing` or
4049    /// `dialogue` — a character who is not in the world has no business and
4050    /// answers no right-click.
4051    Absent(CastAbsence),
4052    /// A single placement that must hold on every reachable branch.
4053    Placement(CastPlacement),
4054    /// Per-branch placements (spec-0020 proof 4). Each carries the
4055    /// `requires_flags`/`forbids_flags` that select its branch.
4056    Branches(Vec<CastPlacement>),
4057}
4058
4059impl CastEntry {
4060    /// Every placement this entry declares, in declared order. The bare-keyword
4061    /// form yields none — there is no scene to check.
4062    pub fn placements(&self) -> Vec<&CastPlacement> {
4063        match self {
4064            CastEntry::Absent(_) => Vec::new(),
4065            CastEntry::Placement(p) => vec![p],
4066            CastEntry::Branches(ps) => ps.iter().collect(),
4067        }
4068    }
4069
4070    /// Every placement this entry declares, mutably (the l10n traversal).
4071    pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
4072        match self {
4073            CastEntry::Absent(_) => Vec::new(),
4074            CastEntry::Placement(p) => vec![p],
4075            CastEntry::Branches(ps) => ps.iter_mut().collect(),
4076        }
4077    }
4078
4079    /// The bare-keyword absence this entry declares, if it is that form.
4080    pub fn absence(&self) -> Option<CastAbsence> {
4081        match self {
4082            CastEntry::Absent(a) => Some(*a),
4083            _ => None,
4084        }
4085    }
4086
4087    /// True if this entry declares the NPC out of the world entirely — the bare
4088    /// keyword, or every placement's `at` being a keyword.
4089    pub fn is_absent(&self) -> bool {
4090        match self {
4091            CastEntry::Absent(_) => true,
4092            CastEntry::Placement(p) => p.at.absence().is_some(),
4093            CastEntry::Branches(ps) => {
4094                !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
4095            }
4096        }
4097    }
4098}
4099
4100/// A declared absence: the NPC is deliberately not in the world.
4101#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4102#[serde(rename_all = "kebab-case")]
4103pub enum CastAbsence {
4104    /// Removed from the story for good (must match a `despawn-npc` with no later
4105    /// `spawn-npc`).
4106    Dead,
4107    /// Not in the world for this quest, but may return (must match a
4108    /// `despawn-npc`).
4109    Offstage,
4110}
4111
4112impl CastAbsence {
4113    /// The authored keyword (`dead` / `offstage`).
4114    pub fn token(self) -> &'static str {
4115        match self {
4116            CastAbsence::Dead => "dead",
4117            CastAbsence::Offstage => "offstage",
4118        }
4119    }
4120}
4121
4122/// Where a cast entry puts an NPC: a prefab anchor, a mark, or a declared
4123/// absence.
4124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4125#[serde(untagged)]
4126pub enum CastPlace {
4127    /// `"dead"` or `"offstage"` — explicitly not in the world.
4128    Absent(CastAbsence),
4129    /// The anchor the NPC stands on for this quest's duration. Must equal the
4130    /// position the effect history actually produces (`DW0461`): declaring an
4131    /// anchor does not teleport anybody.
4132    Anchor(AnchorId),
4133    /// The mark the NPC stands on for this quest's duration (spec-0066): the
4134    /// spelling for a body that stands at an offset from its anchor. `DW0461`
4135    /// compares anchor and offset both.
4136    Mark(Mark),
4137}
4138
4139impl CastPlace {
4140    /// The anchor this place names, if it names one.
4141    pub fn anchor(&self) -> Option<&AnchorId> {
4142        match self {
4143            CastPlace::Anchor(a) => Some(a),
4144            CastPlace::Mark(m) => Some(&m.anchor),
4145            CastPlace::Absent(_) => None,
4146        }
4147    }
4148
4149    /// The mark this place names, if it names one: a bare anchor is the mark at
4150    /// a zero offset.
4151    pub fn mark(&self) -> Option<Mark> {
4152        match self {
4153            CastPlace::Anchor(a) => Some(Mark::at(a.clone())),
4154            CastPlace::Mark(m) => Some(m.clone()),
4155            CastPlace::Absent(_) => None,
4156        }
4157    }
4158
4159    /// The declared absence, if this place is one.
4160    pub fn absence(&self) -> Option<CastAbsence> {
4161        match self {
4162            CastPlace::Absent(a) => Some(*a),
4163            CastPlace::Anchor(_) | CastPlace::Mark(_) => None,
4164        }
4165    }
4166
4167    /// The authored token, for diagnostics.
4168    pub fn token(&self) -> String {
4169        match self {
4170            CastPlace::Absent(a) => a.token().to_string(),
4171            CastPlace::Anchor(a) => a.as_str().to_string(),
4172            CastPlace::Mark(m) => m.display(),
4173        }
4174    }
4175}
4176
4177/// One declared scene for one NPC in one quest.
4178#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4179#[serde(deny_unknown_fields)]
4180pub struct CastPlacement {
4181    /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
4182    pub at: CastPlace,
4183    /// What the character is *doing* — free prose, never machine-checked.
4184    ///
4185    /// This field is the forcing function, which is the whole reason it is
4186    /// required (`DW0463`) despite being unverifiable: you cannot fill it in
4187    /// without deciding the character's business in this beat, and stage 6
4188    /// receives it as the context the NPC's lines are written against.
4189    #[serde(default, skip_serializing_if = "Option::is_none")]
4190    pub doing: Option<String>,
4191    /// What right-click offers during this quest. Required for an on-stage
4192    /// placement (`DW0463`) — including the explicit `"none"`.
4193    #[serde(default, skip_serializing_if = "Option::is_none")]
4194    pub dialogue: Option<CastDialogue>,
4195    /// Branch gate (per-branch casts): this placement describes the world only
4196    /// once every listed flag is set. Mirrors an option's `requires_flags`.
4197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4198    pub requires_flags: Vec<FlagId>,
4199    /// Negative branch gate: this placement describes the world only while no
4200    /// listed flag is set. Mirrors an option's `forbids_flags`.
4201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4202    pub forbids_flags: Vec<FlagId>,
4203    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
4204    /// hold for this gate to be open. The third field of the one gate, carried by
4205    /// every gate consumer — never by the verb that first wanted it. Default
4206    /// empty, so a pre-0.10 campaign is byte-identical.
4207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4208    pub requires_state: Vec<StateCompare>,
4209}
4210
4211/// What an NPC's right-click offers for a quest's duration.
4212///
4213/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
4214/// `{"barks": […]}` pool, then a dialogue root id.
4215#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4216#[serde(untagged)]
4217pub enum CastDialogue {
4218    /// A keyword: `"none"` or `"unchanged"`.
4219    Keyword(CastDialogueKeyword),
4220    /// A bark pool: right-click yields one inconsequential in-character line —
4221    /// no tree, no options, no consequences.
4222    Barks(CastBarks),
4223    /// A dialogue root id: right-click opens this node of the NPC's stage-6
4224    /// tree. Must be a node of *that* NPC's tree (`DW0464`).
4225    Root(DialogueId),
4226}
4227
4228/// The keyword forms of [`CastDialogue`].
4229#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4230#[serde(rename_all = "kebab-case")]
4231pub enum CastDialogueKeyword {
4232    /// Genuinely no reaction: the right-click is recorded and consumed, and
4233    /// nothing opens. Legal, but a last resort — if a body is clickable, the
4234    /// world should answer; prefer a bark.
4235    None,
4236    /// Carry forward whatever this NPC's dialogue was at its **previous**
4237    /// appearance in the quest-DAG ordering.
4238    ///
4239    /// The point is that carrying dialogue forward is a *conscious, declared
4240    /// act*. It is never an implicit default — an omitted `dialogue` is
4241    /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
4242    /// intent without re-spelling a root id that then drifts out of sync. It
4243    /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
4244    /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
4245    ///
4246    /// Emission is a **no-op** — no root swap is emitted for that NPC at that
4247    /// quest — which is what makes the sugar cheap and byte-stable.
4248    Unchanged,
4249}
4250
4251/// A bark pool: inconsequential in-character lines, cycled deterministically.
4252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4253#[serde(deny_unknown_fields)]
4254pub struct CastBarks {
4255    /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
4256    /// they enter the l10n inventory like any narrate text.
4257    pub barks: Vec<String>,
4258}
4259
4260/// What triggers a quest.
4261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4262#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
4263pub enum Trigger {
4264    /// Fires when the campaign starts.
4265    CampaignStart,
4266    /// Fires when another quest completes.
4267    QuestComplete {
4268        /// The prerequisite quest.
4269        quest: QuestId,
4270    },
4271}
4272
4273/// A quest objective.
4274///
4275/// Every variant may carry `requires_flags`:
4276/// flag-gated activation, satisfied only once each referenced flag has been set
4277/// by a `set-flag` effect.
4278#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4279#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
4280pub enum Objective {
4281    /// Completed by a dialogue option's `complete-objective` effect.
4282    TalkTo {
4283        /// Objective id.
4284        id: ObjectiveId,
4285        /// Short player-facing objective name (v0.3, optional).
4286        #[serde(default, skip_serializing_if = "Option::is_none")]
4287        title: Option<String>,
4288        /// One-line location/direction hint (v0.3, optional).
4289        #[serde(default, skip_serializing_if = "Option::is_none")]
4290        hint: Option<String>,
4291        /// The NPC to talk to.
4292        npc: NpcId,
4293        /// Prerequisite objectives (intra-quest ordering).
4294        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4295        after: Vec<ObjectiveId>,
4296        /// Flags that must be set before this objective activates (v0.3).
4297        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4298        requires_flags: Vec<FlagId>,
4299        /// Negative flag gate (DSL v0.6): the
4300        /// objective is suppressed (cannot activate or complete) while ANY listed
4301        /// flag is set for the player — the dual of `requires_flags`.
4302        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4303        forbids_flags: Vec<FlagId>,
4304        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4305        /// must hold for this gate to be open. The third field of the one gate,
4306        /// carried by every gate consumer — never by the verb that first wanted
4307        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4308        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4309        requires_state: Vec<StateCompare>,
4310        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4311        /// bot should traverse sneaking (sprint disabled). Emitted into
4312        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4313        /// hint; no datapack effect.
4314        #[serde(default, skip_serializing_if = "is_false")]
4315        stealth: bool,
4316        /// What this objective does to the story (DSL v0.8, spec-0025; required
4317        /// at 0.8.0, `DW0481`).
4318        #[serde(default, skip_serializing_if = "Option::is_none")]
4319        happening: Option<Happening>,
4320    },
4321    /// Completed by reaching an anchor once prerequisites are met.
4322    ReachAnchor {
4323        /// Objective id.
4324        id: ObjectiveId,
4325        /// Short player-facing objective name (v0.3, optional).
4326        #[serde(default, skip_serializing_if = "Option::is_none")]
4327        title: Option<String>,
4328        /// One-line location/direction hint (v0.3, optional).
4329        #[serde(default, skip_serializing_if = "Option::is_none")]
4330        hint: Option<String>,
4331        /// The anchor to reach.
4332        anchor: AnchorId,
4333        /// Completion radius (blocks).
4334        radius: u32,
4335        /// Prerequisite objectives (intra-quest ordering).
4336        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4337        after: Vec<ObjectiveId>,
4338        /// Flags that must be set before this objective activates (v0.3).
4339        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4340        requires_flags: Vec<FlagId>,
4341        /// Negative flag gate (DSL v0.6): the
4342        /// objective is suppressed (cannot activate or complete) while ANY listed
4343        /// flag is set for the player — the dual of `requires_flags`.
4344        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4345        forbids_flags: Vec<FlagId>,
4346        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4347        /// must hold for this gate to be open. The third field of the one gate,
4348        /// carried by every gate consumer — never by the verb that first wanted
4349        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4350        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4351        requires_state: Vec<StateCompare>,
4352        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4353        /// bot should traverse sneaking (sprint disabled). Emitted into
4354        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4355        /// hint; no datapack effect.
4356        #[serde(default, skip_serializing_if = "is_false")]
4357        stealth: bool,
4358        /// What this objective does to the story (DSL v0.8, spec-0025; required
4359        /// at 0.8.0, `DW0481`).
4360        #[serde(default, skip_serializing_if = "Option::is_none")]
4361        happening: Option<Happening>,
4362    },
4363    /// Completed when the referenced wave is fully slain (v0.3).
4364    Kill {
4365        /// Objective id.
4366        id: ObjectiveId,
4367        /// Short player-facing objective name (v0.3, optional).
4368        #[serde(default, skip_serializing_if = "Option::is_none")]
4369        title: Option<String>,
4370        /// One-line location/direction hint (v0.3, optional).
4371        #[serde(default, skip_serializing_if = "Option::is_none")]
4372        hint: Option<String>,
4373        /// The wave (stage-5 `waves` ref) whose mobs must be slain.
4374        wave: WaveId,
4375        /// Prerequisite objectives.
4376        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4377        after: Vec<ObjectiveId>,
4378        /// Flags that must be set before this objective activates (v0.3).
4379        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4380        requires_flags: Vec<FlagId>,
4381        /// Negative flag gate (DSL v0.6): the
4382        /// objective is suppressed (cannot activate or complete) while ANY listed
4383        /// flag is set for the player — the dual of `requires_flags`.
4384        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4385        forbids_flags: Vec<FlagId>,
4386        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4387        /// must hold for this gate to be open. The third field of the one gate,
4388        /// carried by every gate consumer — never by the verb that first wanted
4389        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4390        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4391        requires_state: Vec<StateCompare>,
4392        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4393        /// bot should traverse sneaking (sprint disabled). Emitted into
4394        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4395        /// hint; no datapack effect.
4396        #[serde(default, skip_serializing_if = "is_false")]
4397        stealth: bool,
4398        /// What this objective does to the story (DSL v0.8, spec-0025; required
4399        /// at 0.8.0, `DW0481`).
4400        #[serde(default, skip_serializing_if = "Option::is_none")]
4401        happening: Option<Happening>,
4402    },
4403    /// Completed when `count` of `item` have been collected (v0.3).
4404    ///
4405    /// The items are provided in a container: the compiler's own chest at
4406    /// `anchor` by default, or — since DSL v0.8 — the prefab's existing
4407    /// chest/barrel at [`Objective::Collect::container`], optionally carrying an
4408    /// [`Objective::Collect::item_name`] and padded to read full with
4409    /// [`Objective::Collect::fill_count`].
4410    Collect {
4411        /// Objective id.
4412        id: ObjectiveId,
4413        /// Short player-facing objective name (v0.3, optional).
4414        #[serde(default, skip_serializing_if = "Option::is_none")]
4415        title: Option<String>,
4416        /// One-line location/direction hint (v0.3, optional).
4417        #[serde(default, skip_serializing_if = "Option::is_none")]
4418        hint: Option<String>,
4419        /// Vanilla item id to collect (validated against the registry).
4420        item: String,
4421        /// How many are required.
4422        count: u32,
4423        /// The anchor items are provided at (chest / pickup).
4424        anchor: AnchorId,
4425        /// **Adopt the container the prefab already placed**: the anchor whose
4426        /// assembled-world cell holds
4427        /// a `chest` / `trapped_chest` / `barrel` this collect fills instead of
4428        /// conjuring its own chest at [`Objective::Collect::anchor`].
4429        ///
4430        /// Same division of labour a `loot` entry and a trap's dispenser already
4431        /// keep with the prefab: furniture belongs in the piece. A beach camp's
4432        /// barrel is scenery the player has been walking past since minute one —
4433        /// having the compiler `setblock` a *second*, floating chest beside it to
4434        /// hold the quest item is exactly the downstream workaround the no-hack
4435        /// rule forbids. A `container` whose cell holds no container is a build
4436        /// error (`DW0438`), never a silent fill into a wall.
4437        ///
4438        /// The critical-path step's position follows the container (the bot opens
4439        /// *this* block), and no chest is placed at `anchor` when it is set.
4440        #[serde(default, skip_serializing_if = "Option::is_none")]
4441        container: Option<AnchorId>,
4442        /// **The item comes off a body, not out of a box**: the wave whose
4443        /// declared `drops[]` yield
4444        /// this objective's item. No container is placed — not the compiler's own
4445        /// chest at `anchor`, not a prefab one — and `container` is therefore
4446        /// mutually exclusive with it (`DW0100`-adjacent; `DW0492`).
4447        ///
4448        /// This is what makes "kill the boss → pick up its key → open the door"
4449        /// a *proved* chain rather than an authoring intention. The compiler
4450        /// requires (a) that the named wave really declares an `{item}` drop of
4451        /// this item (`DW0492`), and (b) that a `kill` objective for that wave
4452        /// precedes this collect in the objective graph (`DW0493`). The existing
4453        /// flow machinery then carries the ordering the rest of the way: the
4454        /// door's `requires_flags` hangs off this collect exactly as it would off
4455        /// a chest one.
4456        ///
4457        /// **Waves only.** An actor's death is not observable by any objective —
4458        /// there is no vanilla-side signal the flow machinery could consume — so
4459        /// an actor-gated collect would be an unprovable claim, and per the
4460        /// no-hack doctrine it is excluded rather than approximated. An actor may
4461        /// still declare `drops[]`; those drops just cannot gate a quest.
4462        #[serde(default, skip_serializing_if = "Option::is_none")]
4463        dropped_by: Option<WaveId>,
4464        /// Display name for the collected item (DSL v0.8), emitted as the vanilla `custom_name` item component.
4465        ///
4466        /// A quest item is a *named thing* in the story ("Cheese", "Tide
4467        /// Ledger"), and a player who opens the barrel must read that name — an
4468        /// unnamed `minecraft:pumpkin_pie` says nothing about what the quest asked
4469        /// for. Player-visible, so it enters the l10n string inventory
4470        /// (`obj.<quest>.<obj>.item_name`) and translates like any other line.
4471        ///
4472        /// Naming changes nothing about adjudication: the completion advancement
4473        /// and the per-tick held check both match on the ITEM ID, which a named
4474        /// stack still carries.
4475        #[serde(default, skip_serializing_if = "Option::is_none")]
4476        item_name: Option<String>,
4477        /// Padding stacks that make the container **read full**. Default `0` =
4478        /// the single required
4479        /// stack and nothing else.
4480        ///
4481        /// A barrel of cheese that opens on one lonely wheel reads as a bug, and
4482        /// vanilla's notion of "full" is *occupied slots*, not stack size — so
4483        /// this counts SLOTS: the objective's own stack lands in `container.0` and
4484        /// each padding stack repeats it in `container.1`, `container.2`, … Slot
4485        /// assignment is positional and total, the same determinism story `loot`
4486        /// tells (ADR-0006): no RNG, no loot tables, nothing to reseed.
4487        ///
4488        /// The padding is the same item, so taking the whole barrel still
4489        /// completes the objective and never over- or under-counts it.
4490        #[serde(default, skip_serializing_if = "is_zero")]
4491        fill_count: u32,
4492        /// Prerequisite objectives.
4493        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4494        after: Vec<ObjectiveId>,
4495        /// Flags that must be set before this objective activates (v0.3).
4496        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4497        requires_flags: Vec<FlagId>,
4498        /// Negative flag gate (DSL v0.6): the
4499        /// objective is suppressed (cannot activate or complete) while ANY listed
4500        /// flag is set for the player — the dual of `requires_flags`.
4501        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4502        forbids_flags: Vec<FlagId>,
4503        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4504        /// must hold for this gate to be open. The third field of the one gate,
4505        /// carried by every gate consumer — never by the verb that first wanted
4506        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4507        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4508        requires_state: Vec<StateCompare>,
4509        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4510        /// bot should traverse sneaking (sprint disabled). Emitted into
4511        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4512        /// hint; no datapack effect.
4513        #[serde(default, skip_serializing_if = "is_false")]
4514        stealth: bool,
4515        /// What this objective does to the story (DSL v0.8, spec-0025; required
4516        /// at 0.8.0, `DW0481`).
4517        #[serde(default, skip_serializing_if = "Option::is_none")]
4518        happening: Option<Happening>,
4519    },
4520    /// Completed by interacting with an entity at `anchor`; if `requires_item` is
4521    /// set, the item must be **held in the main hand** (v0.3; held semantics since
4522    /// DSL v0.7 — see [`Objective::Interact::requires_item`]).
4523    Interact {
4524        /// Objective id.
4525        id: ObjectiveId,
4526        /// Short player-facing objective name (v0.3, optional).
4527        #[serde(default, skip_serializing_if = "Option::is_none")]
4528        title: Option<String>,
4529        /// One-line location/direction hint (v0.3, optional).
4530        #[serde(default, skip_serializing_if = "Option::is_none")]
4531        hint: Option<String>,
4532        /// The anchor the interaction entity stands at.
4533        anchor: AnchorId,
4534        /// Item the player must be **holding in the main hand** for the
4535        /// interaction to complete (optional).
4536        ///
4537        /// Held, not merely possessed: presenting the
4538        /// item IS the action — a player who right-clicks a sleeping giant with a
4539        /// sharpened stake buried in their backpack has not stabbed anything.
4540        /// Before this ruling the gate read the whole inventory, which made every
4541        /// `requires_item` interaction fire the moment the item was picked up
4542        /// anywhere, whatever the player was actually doing with their hands.
4543        #[serde(default, skip_serializing_if = "Option::is_none")]
4544        requires_item: Option<String>,
4545        /// Diegetic feedback for a click that arrives without the required item in
4546        /// hand (DSL v0.7): narrated to that player in
4547        /// chat instead of the silence the gate used to answer with. Requires
4548        /// `requires_item` (`DW0437`).
4549        ///
4550        /// Only fires while the objective is genuinely open — same activation gate
4551        /// as the affordance itself — so a finished or not-yet-active interaction
4552        /// stays quiet.
4553        #[serde(default, skip_serializing_if = "Option::is_none")]
4554        missing_item_hint: Option<String>,
4555        /// Prop block that IS the interaction affordance (DSL v0.4, spec-0008
4556        /// §2): the compiler `setblock`s it at the anchor on activation (exactly
4557        /// as `collect` uses a real chest). Omitted = the glowing-lantern
4558        /// hologram marker (the v0.3 fallback).
4559        #[serde(default, skip_serializing_if = "Option::is_none")]
4560        prop: Option<Prop>,
4561        /// Prerequisite objectives.
4562        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4563        after: Vec<ObjectiveId>,
4564        /// Flags that must be set before this objective activates (v0.3).
4565        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4566        requires_flags: Vec<FlagId>,
4567        /// Negative flag gate (DSL v0.6): the
4568        /// objective is suppressed (cannot activate or complete) while ANY listed
4569        /// flag is set for the player — the dual of `requires_flags`.
4570        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4571        forbids_flags: Vec<FlagId>,
4572        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4573        /// must hold for this gate to be open. The third field of the one gate,
4574        /// carried by every gate consumer — never by the verb that first wanted
4575        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4576        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4577        requires_state: Vec<StateCompare>,
4578        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4579        /// bot should traverse sneaking (sprint disabled). Emitted into
4580        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4581        /// hint; no datapack effect.
4582        #[serde(default, skip_serializing_if = "is_false")]
4583        stealth: bool,
4584        /// What this objective does to the story (DSL v0.8, spec-0025; required
4585        /// at 0.8.0, `DW0481`).
4586        #[serde(default, skip_serializing_if = "Option::is_none")]
4587        happening: Option<Happening>,
4588    },
4589}
4590
4591/// A prop block for an `interact` objective (DSL v0.4). The block is the
4592/// affordance the player interacts with; its id is validated against the pinned
4593/// 1.21.11 block registry (`DW0193`).
4594#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4595#[serde(deny_unknown_fields)]
4596pub struct Prop {
4597    /// Vanilla block id (e.g. `minecraft:lever`).
4598    pub block: String,
4599}
4600
4601// ---------------------------------------------------------------------------
4602// Stage 5 — scripted actors (DSL v0.6, spec-0014)
4603// ---------------------------------------------------------------------------
4604
4605/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
4606/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
4607/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
4608/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
4609/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
4610/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
4611/// by a real-AI twin with `unleash-actor`.
4612#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4613#[serde(deny_unknown_fields)]
4614pub struct Actor {
4615    /// Unique actor id (`actor/<kebab>`).
4616    pub id: ActorId,
4617    /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
4618    /// pinned 1.21.11 entity registry (`DW0173`).
4619    pub entity: String,
4620    /// Optional custom name shown above the puppet.
4621    #[serde(default, skip_serializing_if = "Option::is_none")]
4622    pub name: Option<String>,
4623    /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
4624    #[serde(default, skip_serializing_if = "Option::is_none")]
4625    pub skin: Option<NpcSkin>,
4626    /// The anchor the puppet is summoned on (resolved across areas, like an
4627    /// `open-gate` / `move-npc` destination).
4628    pub anchor: AnchorId,
4629    /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
4630    /// `[0, 0, 0]`): the puppet stands at the [`Mark`] the two fields spell, so
4631    /// a rank of bodies is one anchor and an offset apiece.
4632    #[serde(default, skip_serializing_if = "is_zero3")]
4633    pub offset: [i32; 3],
4634    /// Initial facing (default `south`). The puppet spawns yawed this way.
4635    #[serde(default, skip_serializing_if = "Option::is_none")]
4636    pub facing: Option<Facing>,
4637    /// If `true`, the puppet is damageable (a tower-defense creep) but stays
4638    /// knockback-immune; default `false` (fully `Invulnerable`).
4639    #[serde(default, skip_serializing_if = "is_false")]
4640    pub vulnerable: bool,
4641    /// Gear the actor wears and holds, in the same shape a wave mob uses
4642    /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
4643    /// unleashed twin, so the dormant elite the player has been circling is
4644    /// visibly the same armoured thing that stands up. Drop chances are zero —
4645    /// wave gear and actor gear are never farmable (no-grind constitution).
4646    #[serde(default, skip_serializing_if = "Option::is_none")]
4647    pub equipment: Option<MobEquipment>,
4648    /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
4649    /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
4650    /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
4651    /// elite the party fights is the elite the author tuned; without it an actor
4652    /// was stuck at vanilla base values while every wave mob could be tuned,
4653    /// which is what blocked elite authoring. A `vulnerable` actor's
4654    /// knockback-immunity is emitted first and is not authorable.
4655    #[serde(default, skip_serializing_if = "Option::is_none")]
4656    pub attributes: Option<MobAttributes>,
4657    /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
4658    /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
4659    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
4660    ///
4661    /// A wave is not the only shape an elite takes. The set-piece souls fight —
4662    /// the armoured thing kneeling among the graves that stands up when you hit
4663    /// it — is an **actor**: staged by `spawn-actor`, given AI by
4664    /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
4665    /// this field nothing anywhere stated what such a fight was billed as.
4666    ///
4667    /// Like the wave field this is a **declaration, not a knob**: the compiler
4668    /// never scales an actor from it, and emission is unchanged whichever tier is
4669    /// declared. Its readers are the health-bar advisory (`DW0912`) and the drop
4670    /// rule — only a billed fight leaves anything behind.
4671    #[serde(default, skip_serializing_if = "Option::is_none")]
4672    pub tier: Option<EncounterTier>,
4673    /// A health bar over this actor's fight (DSL v0.31, spec-0073) — the same
4674    /// [`HealthBar`](crate::healthbar::HealthBar) a [`Wave`] declares. It reads the
4675    /// bodies whose health can move: the unleashed twin, or the puppet itself when
4676    /// the actor is `vulnerable`. A bar on an actor that is neither is `DW0909`.
4677    /// Absent = no bar, byte-identical.
4678    #[serde(default, skip_serializing_if = "Option::is_none")]
4679    pub health_bar: Option<crate::healthbar::HealthBar>,
4680    /// What this actor leaves behind when a player kills it. Only an
4681    /// `elite`/`boss` actor may
4682    /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
4683    /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
4684    /// not to one of its two lifecycles. A `despawn-actor` strips the
4685    /// declaration off the body before removing it, so re-caging an elite (a
4686    /// souls re-seat) never scatters its axe.
4687    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4688    pub drops: Vec<MobDrop>,
4689    /// What this body can do when it moves (DSL v0.11, spec-0034) — the same
4690    /// [`BodyTraversal`] a stage-2 [`Npc`] carries, because traversal belongs to
4691    /// the body and not to the stage that declares it. Absent = the class the
4692    /// compiler derives from `entity` (or from `minecraft:mannequin` when `skin`
4693    /// is set).
4694    #[serde(default, skip_serializing_if = "Option::is_none")]
4695    pub traversal: Option<BodyTraversal>,
4696    /// What happens each time a player is credited with killing this actor's
4697    /// body (spec-0074) — effect root R9, the same [`OnKill`] a
4698    /// wave declares. Absent = no bundle, and the actor's emission is
4699    /// byte-identical.
4700    #[serde(default, skip_serializing_if = "Option::is_none")]
4701    pub on_kill: Option<OnKill>,
4702}
4703
4704/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
4705/// (MC: yaw 0 = +z/south).
4706#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4707#[serde(rename_all = "kebab-case")]
4708pub enum Facing {
4709    /// Facing +z (yaw 0) — the default.
4710    South,
4711    /// Facing -z (yaw 180).
4712    North,
4713    /// Facing -x (yaw 90).
4714    West,
4715    /// Facing +x (yaw 270).
4716    East,
4717}
4718
4719impl Facing {
4720    /// The kebab token (`south` / `north` / `west` / `east`).
4721    pub fn token(self) -> &'static str {
4722        match self {
4723            Facing::South => "south",
4724            Facing::North => "north",
4725            Facing::West => "west",
4726            Facing::East => "east",
4727        }
4728    }
4729}
4730
4731/// How a `despawn-actor` removes its puppet (DSL v0.6).
4732#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4733#[serde(rename_all = "kebab-case")]
4734pub enum DespawnStyle {
4735    /// The body leaves unseen: no death animation, red flash or death particles
4736    /// where it stood. It is moved under the world and removed there.
4737    Vanish,
4738    /// The body dies where it stands, with the vanilla death animation (a death
4739    /// the player is meant to watch).
4740    Kill,
4741}
4742
4743impl DespawnStyle {
4744    /// The kebab token (`vanish` / `kill`).
4745    pub fn token(self) -> &'static str {
4746        match self {
4747            DespawnStyle::Vanish => "vanish",
4748            DespawnStyle::Kill => "kill",
4749        }
4750    }
4751}
4752
4753// ---------------------------------------------------------------------------
4754// Stage 5 — assemblies (spec-0082)
4755// ---------------------------------------------------------------------------
4756
4757/// A fixed thing that can be hit and hits back (spec-0082): an object built of
4758/// display entities standing at a [`Mark`], moving through the clips of a
4759/// library [`rig`](crate::rig), struck in melee through an optional
4760/// `minecraft:interaction` hitbox, and striking a player who stands in its
4761/// arming region.
4762///
4763/// It is not a fight class and never dies: no health, equipment, traversal,
4764/// health bar or kill credit. A hit count is an ordinary `state` datum a
4765/// `strike-assembly` trigger adds to, and what happens at a count is an effect
4766/// behind the ordinary gate.
4767#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4768#[serde(deny_unknown_fields)]
4769pub struct Assembly {
4770    /// Unique assembly id (`assembly/<kebab>`).
4771    pub id: AssemblyId,
4772    /// The library rig (`rig/<name>`, resolved to `rigs/<name>/rig.json`
4773    /// beside the prefab library) whose parts and clips this assembly is.
4774    pub rig: RigId,
4775    /// Where the rig's origin stands: an anchor and an optional offset. The
4776    /// rig's origin is the mark cell's centre at its floor plane.
4777    pub at: Mark,
4778    /// Which way the rig's `+z` front faces (default `south`). Applied by the
4779    /// compiler to every frame, so the emitted entities stand at yaw 0.
4780    #[serde(default, skip_serializing_if = "Option::is_none")]
4781    pub facing: Option<Facing>,
4782    /// The clip playing from spawn. Absent: the parts stand in the rig's rest
4783    /// pose and no clip plays until a `play-clip`.
4784    #[serde(default, skip_serializing_if = "Option::is_none")]
4785    pub initial: Option<String>,
4786    /// The `minecraft:interaction` a player strikes. Absent: the assembly
4787    /// cannot be struck, and a `strike-assembly` on it is refused (`DW0936`).
4788    #[serde(default, skip_serializing_if = "Option::is_none")]
4789    pub hitbox: Option<AssemblyHitbox>,
4790    /// The blows it deals. Absent: it never strikes.
4791    #[serde(default, skip_serializing_if = "Option::is_none")]
4792    pub strikes: Option<AssemblyStrikes>,
4793}
4794
4795impl Assembly {
4796    /// The declared facing, `south` when absent.
4797    pub fn facing(&self) -> Facing {
4798        self.facing.unwrap_or(Facing::South)
4799    }
4800}
4801
4802/// An assembly's hitbox (spec-0082 §3.2): a `minecraft:interaction` of
4803/// `width × height` whose bottom centre is the mark's cell centre plus
4804/// `offset`. Melee only: an arrow passes through an interaction.
4805#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4806#[serde(deny_unknown_fields)]
4807pub struct AssemblyHitbox {
4808    /// Width in blocks (`0 < width <= 6`, `DW0936`).
4809    pub width: f64,
4810    /// Height in blocks (`0 < height <= 22`, `DW0936`).
4811    pub height: f64,
4812    /// Integer `[x, y, z]` block offset of the box's bottom centre from the
4813    /// mark (default `[0, 0, 0]`).
4814    #[serde(default, skip_serializing_if = "is_zero3")]
4815    pub offset: [i32; 3],
4816}
4817
4818/// An assembly's strike pattern (spec-0082 §3.2).
4819///
4820/// The pattern runs, repeating from its first step, on every tick on which
4821/// some player's body is in `while_in`, and stops at the end of the step in
4822/// flight when nobody is.
4823#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4824#[serde(deny_unknown_fields)]
4825pub struct AssemblyStrikes {
4826    /// The arming region: an anchor-centred box. Every landing box lies inside
4827    /// it (`DW0938`), so a player who never entered it is never struck.
4828    pub while_in: StealthZone,
4829    /// The steps, in order.
4830    pub pattern: Vec<StrikeStep>,
4831    /// Aim (spec-0082 §5.7). Absent: every blow lands where its `on_land`
4832    /// boxes say. Present: at the start of every wind-up the assembly turns to
4833    /// the one of its declared facings nearest the bearing of the nearest
4834    /// player in `while_in`, and every `damage-players` box in `on_land` turns
4835    /// with it.
4836    #[serde(default, skip_serializing_if = "Option::is_none")]
4837    pub aim: Option<StrikeAim>,
4838}
4839
4840/// An aimed strike pattern's facings (spec-0082 §5.7): `facings` turns
4841/// evenly spaced round the vertical axis through the mark, the first being the
4842/// assembly's declared `facing`. Every `on_land` box is written for that first
4843/// facing; the compiler turns it to each of the others and proves every facing
4844/// a player in `while_in` can draw (`DW0938`, judged per facing). Which facing
4845/// a blow takes is chosen at run time among the proven ones; nothing about
4846/// where it lands is computed there.
4847#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4848#[serde(deny_unknown_fields)]
4849pub struct StrikeAim {
4850    /// How many facings, evenly spaced: 4 is a quarter turn apart, 8 an eighth,
4851    /// 16 a sixteenth. At least 1 (1 is the declared facing alone).
4852    pub facings: std::num::NonZeroU32,
4853}
4854
4855/// One step of a strike pattern: wind up, hold, strike, land.
4856///
4857/// How long the wind-up is and how hard the blow lands are the creator's
4858/// judgement, by spec-0016's standing ruling: no telegraph rule and no
4859/// one-shot rule.
4860#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4861#[serde(deny_unknown_fields)]
4862pub struct StrikeStep {
4863    /// The clip played first.
4864    pub windup: String,
4865    /// Ticks the windup's last frame is held before the strike clip starts.
4866    pub hold: u32,
4867    /// The clip the blow is.
4868    pub strike: String,
4869    /// The step's pace: the keyframe cadence, in ticks per frame (1–20, the
4870    /// rig's own bounds), its wind-up and strike clips play at. Absent: each
4871    /// clip's own. The wind-up lasts `1 + (frames - 1) × cadence` ticks, then
4872    /// `hold`; the blow lands one cadence after the strike's last frame.
4873    #[serde(default, skip_serializing_if = "Option::is_none")]
4874    pub ticks_per_frame: Option<u32>,
4875    /// Effects run, with no acting player, on the tick a client has drawn the
4876    /// strike clip's last frame whole (one cadence after it is applied). A step
4877    /// with none is a feint.
4878    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4879    pub on_land: Vec<QuestEffect>,
4880}
4881
4882/// One step of a [`Verb::Sequence`] (DSL v0.6): a group of effects fired at
4883/// an exact tick offset from the sequence's start.
4884#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4885#[serde(deny_unknown_fields)]
4886pub struct SequenceStep {
4887    /// Tick offset from the sequence start at which `effects` fire.
4888    pub at_ticks: u32,
4889    /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
4890    /// `sequence` (rejected with `DW0329`).
4891    pub effects: Vec<QuestEffect>,
4892}
4893
4894impl Objective {
4895    /// This objective's id.
4896    pub fn id(&self) -> &ObjectiveId {
4897        match self {
4898            Objective::TalkTo { id, .. }
4899            | Objective::ReachAnchor { id, .. }
4900            | Objective::Kill { id, .. }
4901            | Objective::Collect { id, .. }
4902            | Objective::Interact { id, .. } => id,
4903        }
4904    }
4905
4906    /// This objective's prerequisites.
4907    pub fn after(&self) -> &[ObjectiveId] {
4908        match self {
4909            Objective::TalkTo { after, .. }
4910            | Objective::ReachAnchor { after, .. }
4911            | Objective::Kill { after, .. }
4912            | Objective::Collect { after, .. }
4913            | Objective::Interact { after, .. } => after,
4914        }
4915    }
4916
4917    /// The short player-facing objective title (v0.3, optional).
4918    pub fn title(&self) -> Option<&str> {
4919        match self {
4920            Objective::TalkTo { title, .. }
4921            | Objective::ReachAnchor { title, .. }
4922            | Objective::Kill { title, .. }
4923            | Objective::Collect { title, .. }
4924            | Objective::Interact { title, .. } => title.as_deref(),
4925        }
4926    }
4927
4928    /// The one-line location/direction hint (v0.3, optional).
4929    pub fn hint(&self) -> Option<&str> {
4930        match self {
4931            Objective::TalkTo { hint, .. }
4932            | Objective::ReachAnchor { hint, .. }
4933            | Objective::Kill { hint, .. }
4934            | Objective::Collect { hint, .. }
4935            | Objective::Interact { hint, .. } => hint.as_deref(),
4936        }
4937    }
4938
4939    /// Mutable access to the optional player-facing title (i18n localization).
4940    pub fn title_mut(&mut self) -> &mut Option<String> {
4941        match self {
4942            Objective::TalkTo { title, .. }
4943            | Objective::ReachAnchor { title, .. }
4944            | Objective::Kill { title, .. }
4945            | Objective::Collect { title, .. }
4946            | Objective::Interact { title, .. } => title,
4947        }
4948    }
4949
4950    /// Mutable access to the optional one-line hint (i18n localization).
4951    pub fn hint_mut(&mut self) -> &mut Option<String> {
4952        match self {
4953            Objective::TalkTo { hint, .. }
4954            | Objective::ReachAnchor { hint, .. }
4955            | Objective::Kill { hint, .. }
4956            | Objective::Collect { hint, .. }
4957            | Objective::Interact { hint, .. } => hint,
4958        }
4959    }
4960
4961    /// The flags that must be set before this objective activates (v0.3).
4962    pub fn requires_flags(&self) -> &[FlagId] {
4963        match self {
4964            Objective::TalkTo { requires_flags, .. }
4965            | Objective::ReachAnchor { requires_flags, .. }
4966            | Objective::Kill { requires_flags, .. }
4967            | Objective::Collect { requires_flags, .. }
4968            | Objective::Interact { requires_flags, .. } => requires_flags,
4969        }
4970    }
4971
4972    /// The negative flag gate (DSL v0.6): flags whose being set **suppresses**
4973    /// this objective. The dual of [`Objective::requires_flags`].
4974    pub fn forbids_flags(&self) -> &[FlagId] {
4975        match self {
4976            Objective::TalkTo { forbids_flags, .. }
4977            | Objective::ReachAnchor { forbids_flags, .. }
4978            | Objective::Kill { forbids_flags, .. }
4979            | Objective::Collect { forbids_flags, .. }
4980            | Objective::Interact { forbids_flags, .. } => forbids_flags,
4981        }
4982    }
4983
4984    /// The numeric gate terms (DSL v0.10, spec-0031): comparisons that must hold
4985    /// before this objective activates. See [`StateCompare`].
4986    pub fn requires_state(&self) -> &[StateCompare] {
4987        match self {
4988            Objective::TalkTo { requires_state, .. }
4989            | Objective::ReachAnchor { requires_state, .. }
4990            | Objective::Kill { requires_state, .. }
4991            | Objective::Collect { requires_state, .. }
4992            | Objective::Interact { requires_state, .. } => requires_state,
4993        }
4994    }
4995
4996    /// What this objective does to the story (DSL v0.8, spec-0025).
4997    pub fn happening(&self) -> Option<&Happening> {
4998        match self {
4999            Objective::TalkTo { happening, .. }
5000            | Objective::ReachAnchor { happening, .. }
5001            | Objective::Kill { happening, .. }
5002            | Objective::Collect { happening, .. }
5003            | Objective::Interact { happening, .. } => happening.as_ref(),
5004        }
5005    }
5006
5007    /// The bot stealth hint (DSL v0.4): traverse this leg sneaking.
5008    pub fn stealth(&self) -> bool {
5009        match self {
5010            Objective::TalkTo { stealth, .. }
5011            | Objective::ReachAnchor { stealth, .. }
5012            | Objective::Kill { stealth, .. }
5013            | Objective::Collect { stealth, .. }
5014            | Objective::Interact { stealth, .. } => *stealth,
5015        }
5016    }
5017
5018    /// The container this objective ADOPTS (DSL v0.8), if it is a `collect` that
5019    /// declares one: the anchor whose prefab-placed chest/barrel it fills instead
5020    /// of conjuring its own chest. `None` on every other objective and on a
5021    /// `collect` that keeps the compiler-placed chest.
5022    pub fn collect_container(&self) -> Option<&AnchorId> {
5023        match self {
5024            Objective::Collect { container, .. } => container.as_ref(),
5025            _ => None,
5026        }
5027    }
5028
5029    /// The wave whose declared drops provide this objective's item (DSL v0.9),
5030    /// if it is a `collect` that declares one. `None` on every other objective
5031    /// and on a `collect` fed by a container.
5032    pub fn collect_dropped_by(&self) -> Option<&WaveId> {
5033        match self {
5034            Objective::Collect { dropped_by, .. } => dropped_by.as_ref(),
5035            _ => None,
5036        }
5037    }
5038
5039    /// The padding-stack count of a `collect` (DSL v0.8); `0` for every other
5040    /// objective and for a `collect` that fills the single required stack only.
5041    pub fn collect_fill_count(&self) -> u32 {
5042        match self {
5043            Objective::Collect { fill_count, .. } => *fill_count,
5044            _ => 0,
5045        }
5046    }
5047
5048    /// The `interact` prop block (DSL v0.4), if this is an `interact` with a prop.
5049    pub fn prop(&self) -> Option<&Prop> {
5050        match self {
5051            Objective::Interact { prop, .. } => prop.as_ref(),
5052            _ => None,
5053        }
5054    }
5055
5056    /// The kebab type tag.
5057    pub fn kind(&self) -> &'static str {
5058        match self {
5059            Objective::TalkTo { .. } => "talk-to",
5060            Objective::ReachAnchor { .. } => "reach-anchor",
5061            Objective::Kill { .. } => "kill",
5062            Objective::Collect { .. } => "collect",
5063            Objective::Interact { .. } => "interact",
5064        }
5065    }
5066
5067    /// The v0.3 verb name if this objective is one of the verbs introduced in
5068    /// DSL v0.3 (`kill`/`collect`/`interact`). These validate in v0.3 campaigns
5069    ///.
5070    pub fn v03_verb(&self) -> Option<&'static str> {
5071        match self {
5072            Objective::Kill { .. } => Some("kill"),
5073            Objective::Collect { .. } => Some("collect"),
5074            Objective::Interact { .. } => Some("interact"),
5075            Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => None,
5076        }
5077    }
5078}
5079
5080/// The condition under which an effect fires, as one object.
5081///
5082/// The three axes of [`crate::gate::Gate`] in their declared form: the flags that
5083/// must be set, the flags that must not be, and the numeric comparisons that must
5084/// hold. Declared once and carried by [`QuestEffect::when`], so every verb is
5085/// gatable on exactly the same terms and a fourth axis is one field here.
5086#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
5087#[serde(deny_unknown_fields)]
5088pub struct Guard {
5089    /// Flags that must ALL be set (per party) for the effect to fire. Emission
5090    /// wraps the effect's commands in `execute if score #party dw.f_<flag> matches 1`.
5091    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5092    pub requires_flags: Vec<FlagId>,
5093    /// Flags whose being set SUPPRESSES the effect — the dual of `requires_flags`,
5094    /// emitted as `execute unless score #party dw.f_<flag> matches 1`.
5095    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5096    pub forbids_flags: Vec<FlagId>,
5097    /// Numeric comparisons that must ALL hold (spec-0031), emitted as
5098    /// `execute if score <holder> dw.s_<state> matches <range>`.
5099    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5100    pub requires_state: Vec<StateCompare>,
5101}
5102
5103/// An effect fired by quest progress: **one guard, one story note, one verb.**
5104///
5105/// The guard is a property of an effect, not of the verb that first wanted one, so
5106/// it is declared once in [`Guard`] and every verb carries it — including the
5107/// staging and souls vocabulary (`spawn-actor`, `move-actor`, `set-checkpoint`,
5108/// `bonfire`, `begin-stealth`, `sequence`, …) that could not be branch-gated while
5109/// the fields lived on the variants. A gate that can never open on a
5110/// `campaign-complete` is caught where it belongs, by the completability proof.
5111///
5112/// `verb` is `#[serde(flatten)]`, so the JSON is unchanged in shape apart from the
5113/// guard moving under `when`: `{"type": "open-gate", "anchor": "…", "when":
5114/// {"requires_flags": ["…"]}}`. [`Verb`] keeps `deny_unknown_fields`, which is what
5115/// the flattened deserializer applies to everything the outer struct did not claim
5116/// — an author's typo is still `DW0100`.
5117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5118pub struct QuestEffect {
5119    /// When this effect fires. `None` is the always-open gate.
5120    #[serde(default, skip_serializing_if = "Option::is_none")]
5121    pub when: Option<Guard>,
5122    /// What this beat does to the story (spec-0025) — validation metadata with no
5123    /// emission of its own.
5124    #[serde(default, skip_serializing_if = "Option::is_none")]
5125    pub happening: Option<Happening>,
5126    /// **Who a player-facing effect addresses** (spec-0085 §3.2): `party` or
5127    /// `actor`, the one player whose act fired the root. Absent = the root's own
5128    /// answer — a quest completion addresses the party, a death, a respawn, a
5129    /// purchase, a credited kill and a `presser` trigger address their actor, a
5130    /// polled trigger, a trap and a shortcut address the party — so a campaign
5131    /// that never writes the field emits exactly what it emitted before it
5132    /// existed.
5133    ///
5134    /// A property of the envelope rather than of any verb: it reaches every
5135    /// verb the emitter addresses to players ([`Verb::addresses_players`]), and
5136    /// on any other verb it is refused (`DW0942`). `actor` where emission has no
5137    /// acting player is `DW0503`.
5138    #[serde(default, skip_serializing_if = "Option::is_none")]
5139    pub audience: Option<EffectAudience>,
5140    /// **Narrows the audience to players inside an anchor-centred box**
5141    /// (`anchor ± extent`, spec-0085 §3.2) at the moment the effect fires — the
5142    /// same [`StealthZone`] a `begin-stealth` zone and a `lethal_volumes[]`
5143    /// region take, resolved through the one `Plan::zone_box`. Composes with
5144    /// [`Self::audience`]: `actor` + `in` is the actor, if they stand in the box.
5145    ///
5146    /// One field on the envelope, reaching every player-facing verb; refused on
5147    /// any other (`DW0942`) — a box narrows an audience, and a world fact has
5148    /// none.
5149    #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5150    pub within: Option<StealthZone>,
5151    /// What the effect does.
5152    #[serde(flatten)]
5153    pub verb: Verb,
5154}
5155
5156/// **Who a player-facing effect addresses** (spec-0085 §3.2).
5157#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5158#[serde(rename_all = "kebab-case")]
5159pub enum EffectAudience {
5160    /// Every player — `@a`.
5161    Party,
5162    /// The one player whose act fired the root — the completing player, the
5163    /// presser, the dying or respawning player, the buyer, the credited killer.
5164    /// Emitted `@s`, so it exists only where emission has an acting player
5165    /// (`DW0503`).
5166    Actor,
5167}
5168
5169impl EffectAudience {
5170    /// The token the document spells.
5171    pub fn token(self) -> &'static str {
5172        match self {
5173            EffectAudience::Party => "party",
5174            EffectAudience::Actor => "actor",
5175        }
5176    }
5177}
5178
5179impl From<Verb> for QuestEffect {
5180    /// An unguarded effect with no story note and the root's own audience — the
5181    /// shape a compiler-synthesized beat and most tests want.
5182    fn from(verb: Verb) -> Self {
5183        QuestEffect {
5184            when: None,
5185            happening: None,
5186            audience: None,
5187            within: None,
5188            verb,
5189        }
5190    }
5191}
5192
5193/// What an effect does, without the guard: the closed set of verbs.
5194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5195#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
5196pub enum Verb {
5197    /// Opens a prefab-declared gate (one-way).
5198    OpenGate {
5199        /// The gate anchor to open.
5200        anchor: AnchorId,
5201    },
5202    /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
5203    /// fills the gate anchor's region with the block the anchor declares (e.g. the
5204    /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
5205    /// The declared fill block is prefab metadata; a gate anchor with no `block` is
5206    /// rejected (`DW0343`). The completability model treats the region as **solid**
5207    /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
5208    /// clearing is modelled) — a critical path that must cross a gate after it seals
5209    /// fails the DW0311 reachability proof.
5210    CloseGate {
5211        /// The gate anchor to seal.
5212        anchor: AnchorId,
5213        /// 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
5214        /// and press: the compiler answers that press on the actionbar. Absent, the
5215        /// compiler's canonical English is baked in (`The way is sealed.`) exactly
5216        /// as `world.boundary.message` does; authored, the line is l10n-inventoried
5217        /// under `<effect-key>.sealed_hint` and translates like every other
5218        /// player-visible string.
5219        ///
5220        /// Unlike `happening`, this **does** print in the hand-written `Debug`
5221        /// below when present — it changes emission, so two otherwise-identical
5222        /// sequences that differ only in their seal's answer are different content.
5223        #[serde(default, skip_serializing_if = "Option::is_none")]
5224        sealed_hint: Option<String>,
5225    },
5226    /// Marks the campaign complete (final advancement + credits). Terminal — not
5227    /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
5228    /// so this variant carries no `requires_flags`.
5229    CampaignComplete {
5230        /// Which ENDING this is (DSL v0.8, spec-0025).
5231        /// A campaign with more than one `campaign-complete` has more than one
5232        /// ending, and a branch that runs to an ending names it here — so the
5233        /// terminality proof (`DW0482`) can state *which* ending a branch reached
5234        /// instead of merely that something ended. There is no separate `endings`
5235        /// section: the set of endings is exactly the set named here, the same
5236        /// rule flags follow.
5237        #[serde(default, skip_serializing_if = "Option::is_none")]
5238        ending: Option<EndingId>,
5239    },
5240    /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
5241    GiveItem {
5242        /// Vanilla item id to give (validated against the registry).
5243        item: String,
5244        /// How many to give.
5245        count: u32,
5246        /// Optional display name (DSL v0.4), matching [`KitItem::name`].
5247        #[serde(default, skip_serializing_if = "Option::is_none")]
5248        name: Option<String>,
5249        /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
5250        /// party member. `one` hands a single copy to the player whose action fired
5251        /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
5252        /// has no acting player.
5253        #[serde(default, skip_serializing_if = "Option::is_none")]
5254        carrier: Option<Carrier>,
5255        /// Enchantments on the given stack (`{"minecraft:sharpness": 2}`) —
5256        /// the field a `loot` stack and an equipped piece carry, under the same
5257        /// checks (`DW0433`/`DW0434`) and written by the same rule
5258        /// ([`enchantment_component`]): on a `minecraft:enchanted_book` they are
5259        /// the book's stored enchantments, the ones an anvil applies.
5260        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
5261        enchantments: BTreeMap<String, u32>,
5262    },
5263    /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
5264    SetFlag {
5265        /// The flag to set.
5266        flag: FlagId,
5267    },
5268    /// Writes a declared datum to an absolute value (DSL v0.10, spec-0031).
5269    SetState {
5270        /// The datum to write (stage-5 `state` ref).
5271        state: StateId,
5272        /// The value to write.
5273        value: i32,
5274    },
5275    /// Moves a declared datum by a signed amount (DSL v0.10, spec-0031).
5276    ///
5277    /// **Signed on purpose.** A purse that a shop debits and a stake that a death
5278    /// forfeits are the same operation with the sign flipped; a separate
5279    /// `subtract-state` would be a second verb for one mechanism, and the first
5280    /// campaign to need "add a negative" would have to choose between them.
5281    AddState {
5282        /// The datum to move (stage-5 `state` ref).
5283        state: StateId,
5284        /// How far to move it. Negative counts down.
5285        amount: i32,
5286    },
5287    /// Leaves a declared [`Stake`] behind for the acting player (DSL v0.10,
5288    /// spec-0032): forfeit the declared share of its datum, and place a
5289    /// collectable marker at the compile-time anchor for where they are.
5290    ///
5291    /// **Nothing about this verb says "death".** It is written in `on_death`
5292    /// because that is where a souls-shaped delve wants it, but the mechanism is
5293    /// "leave a recoverable cache where the acting player stands", and the effect
5294    /// carries the ordinary gate so any root may run it. What *is* death-specific
5295    /// — that the corpse stands on the death position, so the placement lookup has
5296    /// a position to key on — is a property of the `on_death` root, not of this
5297    /// verb.
5298    DropStake {
5299        /// The stake (stage-5 `stakes` ref) to leave.
5300        stake: StakeId,
5301    },
5302    /// Returns a declared datum to its declared `initial` (DSL v0.10,
5303    /// spec-0031) — the verb `FlagId` has never had.
5304    ClearState {
5305        /// The datum to clear (stage-5 `state` ref).
5306        state: StateId,
5307    },
5308    /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
5309    SpawnWave {
5310        /// The wave (stage-5 `waves` ref) to spawn.
5311        wave: WaveId,
5312    },
5313    /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
5314    /// l10n key inventory like any player-visible string.
5315    Narrate {
5316        /// The line shown to the player.
5317        text: String,
5318        /// Presentation channel (default `chat`).
5319        #[serde(default, skip_serializing_if = "Option::is_none")]
5320        style: Option<NarrateStyle>,
5321        /// Optional sound id played alongside the line.
5322        #[serde(default, skip_serializing_if = "Option::is_none")]
5323        sound: Option<String>,
5324    },
5325    /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
5326    /// placement. Block id validated against the pinned 1.21.11 block registry;
5327    /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
5328    /// accepted and passed through verbatim (DSL v0.6).
5329    SetBlock {
5330        /// The anchor to place the block at.
5331        anchor: AnchorId,
5332        /// Vanilla block id to place.
5333        block: String,
5334    },
5335    /// **Fill a declared region with a block** at runtime (DSL v0.10, spec-0031).
5336    ///
5337    /// The general spelling of the capability `open-gate` / `close-gate` carried
5338    /// privately: a region, filled or cleared, from a point in the quest DAG.
5339    /// `close-gate` is this verb with the region and the block read off a prefab
5340    /// gate anchor instead of authored; `set-block` is the one-cell case at a
5341    /// point anchor. All three lower through one emission
5342    /// (`emit::fill_region_command`) and are modelled by one completability rule
5343    /// (`plan::RegionEvent`), so a third consumer inherits the proof instead of
5344    /// re-deriving it.
5345    ///
5346    /// From the DAG point at which this fires, the completability model treats the
5347    /// filled cells as whatever the **block** makes them. A full-cube block leaves
5348    /// them **solid**, exactly as a `close-gate` seal is: a critical path that must
5349    /// cross the region afterwards fails `DW0311`. `minecraft:water` /
5350    /// `minecraft:lava` leave them **flooded** — impassable and never floor, because
5351    /// nothing stands on a fluid — and because a fill carries no `replace` filter it
5352    /// takes away whatever floor was in the box, so a forced leg that needed that
5353    /// footing fails `DW0544`.
5354    FillRegion {
5355        /// The volume to fill, as an anchor-centred box (`anchor ± extent`).
5356        ///
5357        /// Deliberately the existing [`StealthZone`] — the engine's one
5358        /// anchor-centred box object class, already shared by `damage-players`'s
5359        /// `in` filter, `collapse`'s `region_anchor`, a `volley` kill zone and a
5360        /// `lethal_volumes[]` region, and resolved through the single
5361        /// `Plan::zone_box`. A private twin with the same two fields would be
5362        /// `tools/ci/check-capability-ownership.py` check C by construction.
5363        ///
5364        /// An anchor-centred box rather than a prefab `region` anchor for the
5365        /// reason `collapse` states: the assembled model deletes every gate-region
5366        /// anchor's cells, so a slab declared that way would already be gone.
5367        region: StealthZone,
5368        /// The block the region is filled with (validated against the pinned
5369        /// 1.21.11 block registry, `DW0193`).
5370        block: String,
5371    },
5372    /// **Clear a declared region to air** at runtime (DSL v0.10, spec-0031) — the
5373    /// physical dual of [`Verb::FillRegion`], and the general spelling of
5374    /// what `open-gate` does to a gate anchor's region.
5375    ///
5376    /// The completability model treats the cleared cells as **passable** from the
5377    /// DAG point at which this fires, with one exception it states out loud: a
5378    /// cleared cell the model already floods stays impassable, because clearing a
5379    /// block does not remove water (`nav::World::with_cleared`).
5380    ClearRegion {
5381        /// The volume to clear, as an anchor-centred box (`anchor ± extent`) —
5382        /// the same object class [`Verb::FillRegion`] fills.
5383        region: StealthZone,
5384    },
5385    /// **Repaint a volume's sky** (spec-0080 §3.3): `/fillbiome` over the
5386    /// volume with the named atmosphere's biome, while the party stands in it.
5387    ///
5388    /// A runtime edit of the world keyed to a volume, so it is a verb of the
5389    /// physical-edit family [`Verb::FillRegion`] / [`Verb::ClearRegion`] form.
5390    /// Exactly one of `region` / `place` (`DW0929`): a volume inside a place is
5391    /// the creator's judgement, a whole place's bounds are a derivation the
5392    /// creator never types. Painting back is this verb naming the place's own
5393    /// atmosphere, or `atmosphere: null` for the horizon's biome.
5394    ///
5395    /// A hard cut: the client blends fog over its biome-blend radius and grass
5396    /// not at all, and biome cells are 4×4×4, so the painted volume is the
5397    /// enclosing 4-aligned box, up to three blocks past each face.
5398    SetAtmosphere {
5399        /// One of `world.atmospheres[]`, or `null` for the horizon's biome.
5400        #[serde(default)]
5401        atmosphere: Option<AtmosphereId>,
5402        /// The volume, as an anchor-centred box (`anchor ± extent`) — the same
5403        /// object class [`Verb::FillRegion`] fills, resolved through the same
5404        /// `Plan::zone_box`.
5405        #[serde(default, skip_serializing_if = "Option::is_none")]
5406        region: Option<StealthZone>,
5407        /// A whole place: an `area/…` id, or a site-plan box's `node/…`. Its
5408        /// volume is the cells the place's own `atmosphere` paints at setup:
5409        /// its bounds, grown as far as the client's biome blend reads.
5410        #[serde(default, skip_serializing_if = "Option::is_none")]
5411        place: Option<String>,
5412    },
5413    /// **Opens a placed piece's contingent way** (DSL v0.12, spec-0042 §2.4): the
5414    /// broken flight a beat repairs, the bridge a beat lowers, the rubble a beat
5415    /// clears.
5416    ///
5417    /// A piece's spatial contract may declare a traversal edge whose crossability
5418    /// depends on a named region — `laid` (empty as built, opening fills it) or
5419    /// `cleared` (built solid, opening voids it). The prefab checker proves the
5420    /// piece is severed as shipped and joined once that region is opened; this is
5421    /// the verb that opens it, and it is the only one, because a way is the object
5422    /// and opening it is the operation.
5423    ///
5424    /// **There is no region, no block and no sign on this effect, and that is the
5425    /// design rather than an omission.** All three are read from the piece's own
5426    /// exported metadata (`spatial_contract.edges[].way`), so the effect and the
5427    /// building cannot disagree about what a way is — two authorities plus an
5428    /// equality check is the defect this shape avoids, not a variant of the fix
5429    /// (spec-0042 AC8). What the campaign decides is *when*.
5430    ///
5431    /// Completability: the way is **shut until this fires**, and from the DAG
5432    /// point at which it fires the region is solid-and-footing (`laid`) or
5433    /// passable (`cleared`) — the same [`Verb::FillRegion`] /
5434    /// [`Verb::ClearRegion`] model, fed from metadata instead of from an
5435    /// authored box, so this verb inherits the forced-footing rule (`DW0546`)
5436    /// rather than restating it. Required content standing beyond a way that no
5437    /// forced opening precedes is `DW0548`, which names the way, the effect and
5438    /// the element.
5439    OpenWay {
5440        /// The placed piece whose way this opens (`prefab/<name>`).
5441        ///
5442        /// A piece, not an anchor: the way's cells are the contract's, not a gate
5443        /// anchor's, and a piece placed twice has two ways. The reference must
5444        /// name exactly one placement; naming none or several is `DW0547`.
5445        piece: PrefabId,
5446        /// The way's region name, as the piece's contract exports it
5447        /// (`spatial_contract.edges[].way.region`).
5448        way: String,
5449    },
5450    /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5). The
5451    /// NPC leaves unseen: no death animation, red flash or death particles.
5452    DespawnNpc {
5453        /// The NPC (stage-2 ref) to remove.
5454        npc: NpcId,
5455    },
5456    /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
5457    /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
5458    /// path** by A* over the solved voxel grid and emits per-tick teleport
5459    /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
5460    /// a solid affordance. An unroutable move is a compile error (`DW0307`).
5461    MoveNpc {
5462        /// The NPC (stage-2 ref) to move.
5463        npc: NpcId,
5464        /// The destination mark: an anchor and an optional offset (spec-0066).
5465        to: Mark,
5466        /// Optional travel speed in blocks/tick (defaults to ~0.15).
5467        #[serde(default, skip_serializing_if = "Option::is_none")]
5468        speed: Option<f64>,
5469        /// Effects fired once the NPC arrives at the destination cell — exact
5470        /// parity with [`Verb::MoveActor`]
5471        /// `on_arrive`: same arrival detection (the walk driver's final tick), same
5472        /// execution context, and every deep effect walker recurses into it via
5473        /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
5474        /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
5475        /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
5476        /// mark).
5477        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5478        on_arrive: Vec<QuestEffect>,
5479    },
5480    /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
5481    /// gamemode+position, spectator, then dolly two co-located cameras along a
5482    /// straight-line lerp between waypoints and alternate `spectate` between them
5483    /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
5484    /// no-op and is never emitted), and restore on completion. The compiler
5485    /// validates the dolly path passes only through non-solid blocks — cameras
5486    /// fly but must not clip a solid (`DW0308`).
5487    ///
5488    /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
5489    /// emission to face that world point from its own position, so the shot keeps
5490    /// its subject framed for the whole move; without it, the camera faces along
5491    /// the direction of travel (the segment it is currently traversing).
5492    ///
5493    /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
5494    /// inside one save/restore bracket (hard cut between shots). Two accepted,
5495    /// mutually exclusive spellings, both normalized by
5496    /// [`QuestEffect::cutscene_shots`]:
5497    /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
5498    /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
5499    ///   exactly equivalent to a one-entry `shots` list.
5500    ///
5501    /// Mixing or omitting both is `DW0199`.
5502    Cutscene {
5503        /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
5504        /// with the single-shot `path`/`seconds` fields (`DW0199`).
5505        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5506        shots: Vec<CameraShot>,
5507        /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
5508        /// lerp between them).
5509        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5510        path: Vec<Mark>,
5511        /// Single-shot form (DSL v0.4): shot duration in seconds.
5512        #[serde(default, skip_serializing_if = "Option::is_none")]
5513        seconds: Option<u32>,
5514        /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
5515        /// Absent = face along the direction of travel.
5516        #[serde(default, skip_serializing_if = "Option::is_none")]
5517        look_at: Option<Mark>,
5518    },
5519    /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
5520    /// Instantaneous (vanilla has no gradual transition); the state persists
5521    /// because the daylight cycle is frozen by sealing.
5522    SetTime {
5523        /// The time state to cut to.
5524        time: WorldTime,
5525    },
5526    /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
5527    /// Instantaneous; persists because the weather cycle is frozen by sealing.
5528    SetWeather {
5529        /// The weather state to cut to.
5530        weather: WorldWeather,
5531    },
5532    /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
5533    /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
5534    /// sound-event registry (`DW0326` unknown). `at` selects where the sound
5535    /// originates (default: each player's own position); `volume`/`pitch` map to
5536    /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
5537    /// vanilla).
5538    PlaySound {
5539        /// The vanilla sound-event id (`minecraft:` prefix optional).
5540        sound: String,
5541        /// Where the sound plays from (default: `players`).
5542        #[serde(default, skip_serializing_if = "Option::is_none")]
5543        at: Option<SoundAt>,
5544        /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
5545        #[serde(default, skip_serializing_if = "Option::is_none")]
5546        volume: Option<f64>,
5547        /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
5548        #[serde(default, skip_serializing_if = "Option::is_none")]
5549        pitch: Option<f64>,
5550    },
5551    /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
5552    /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
5553    /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
5554    /// acting player: at top level it damages every player once; inside a stealth
5555    /// `on_caught` it damages the caught player (the "caught → death → respawn at
5556    /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
5557    /// is lethal through golden apples / absorption. The envelope's `in`
5558    /// ([`QuestEffect::within`]) narrows to acting players standing inside an
5559    /// anchor-centred box, keeping the per-`@s` semantics. `damage_type` is the damage
5560    /// type — a curated set of vanilla types that all respect `keepInventory` and do
5561    /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
5562    /// (The field is `damage_type`, not `type`, because the effect enum is
5563    /// internally tagged on `type`.)
5564    DamagePlayers {
5565        /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
5566        amount: u32,
5567        /// The damage type (default [`DamageKind::Generic`]).
5568        #[serde(default, skip_serializing_if = "Option::is_none")]
5569        damage_type: Option<DamageKind>,
5570    },
5571    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
5572    /// `spawnpoint @a` at the anchor cell and mirrors the coords into
5573    /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
5574    /// `set-checkpoint` always replaces an earlier one). The compiler proves the
5575    /// cell is standable (`DW0316`) and that the remaining critical path stays
5576    /// reachable from it (`DW0315`).
5577    SetCheckpoint {
5578        /// The prefab checkpoint anchor the party respawns at.
5579        anchor: AnchorId,
5580        /// Per-player effects re-run each time a player respawns while this
5581        /// checkpoint is the active one — scene reset (e.g. re-caging an
5582        /// unleashed actor). Emitted idempotently in declared order; empty = no
5583        /// hook. Respawn is detected via the vanilla `deathCount` criterion.
5584        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5585        on_respawn: Vec<QuestEffect>,
5586    },
5587    /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
5588    /// [`Verb::SetCheckpoint`] for souls-mode pacing. The effect *arms*
5589    /// the rest affordance (a `minecraft:interaction` the player right-clicks at
5590    /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
5591    /// only **when the party actually rests**. Resting fires `on_rest` — the
5592    /// scene reset that makes retry cheap: re-arming traps, re-seating waves
5593    /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
5594    /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
5595    /// so the world's answer to a death and to a rest is identical (spec-0016:
5596    /// death is an investment, never a tax).
5597    ///
5598    /// Proofs are inherited from the checkpoint machinery: the anchor must be
5599    /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
5600    /// the beat that arms the bonfire (the earliest moment a rest can happen).
5601    Bonfire {
5602        /// The prefab anchor the rest affordance stands at, and the cell the
5603        /// party respawns at once rested.
5604        anchor: AnchorId,
5605        /// Effects re-run on every rest **and** on every respawn at this bonfire
5606        /// — the scene reset. Emitted in declared order and expected to be
5607        /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
5608        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5609        on_rest: Vec<QuestEffect>,
5610        /// Title of the two-option rest dialog (DSL v0.8).
5611        /// Absent = the compiler's canonical English `Bonfire`,
5612        /// baked at emit time (the `world.boundary.message` precedent): an
5613        /// authored line is inventoried and translates like every other
5614        /// player-visible string.
5615        #[serde(default, skip_serializing_if = "Option::is_none")]
5616        prompt: Option<String>,
5617        /// Label of the **rest and save** button. Absent = `Rest and save`.
5618        /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
5619        /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
5620        #[serde(default, skip_serializing_if = "Option::is_none")]
5621        rest_label: Option<String>,
5622        /// Label of the **save only** button. Absent = `Save only`.
5623        #[serde(default, skip_serializing_if = "Option::is_none")]
5624        save_label: Option<String>,
5625        /// Hover tooltip of the **rest and save** button (spec-0078) — the same
5626        /// optional `tooltip` every dialog button carries, beside the label it
5627        /// explains. Absent = no tooltip. Not subject to `DW0331`: a tooltip
5628        /// wraps in its own hover box. Inventoried as `fx.….rest_tooltip`.
5629        #[serde(default, skip_serializing_if = "Option::is_none")]
5630        rest_tooltip: Option<String>,
5631        /// Hover tooltip of the **save only** button (spec-0078). Absent = no
5632        /// tooltip. Inventoried as `fx.….save_tooltip`.
5633        #[serde(default, skip_serializing_if = "Option::is_none")]
5634        save_tooltip: Option<String>,
5635    },
5636    /// Begins a stealth beat (DSL v0.6, spec-0014):
5637    /// zone presence alone = hidden — no sneak requirement, which collides with
5638    /// the spectator cutscene camera. While active, every player must be
5639    /// inside some `zone` each tick; a player outside every zone for
5640    /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
5641    /// Zone membership is read from the player's position. The compiler proves
5642    /// each zone is standable and reachable from the activating beat (`DW0327`).
5643    BeginStealth {
5644        /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
5645        zones: Vec<StealthZone>,
5646        /// Per-player effects fired when a player is caught (out of every zone
5647        /// for `grace_ticks`). Empty = no consequence.
5648        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5649        on_caught: Vec<QuestEffect>,
5650        /// Ticks a player may be exposed before `on_caught` fires (default 20).
5651        #[serde(default = "default_grace_ticks")]
5652        grace_ticks: u32,
5653    },
5654    /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
5655    EndStealth,
5656    /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
5657    /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
5658    /// scripted entrance a staged character needs. Idempotent: spawning an NPC
5659    /// already in the world is a no-op. Only meaningful for an NPC declared
5660    /// `deferred: true`; a non-deferred NPC is already in the world from init.
5661    SpawnNpc {
5662        /// The NPC (stage-2 ref) to summon.
5663        npc: NpcId,
5664    },
5665    // --- DSL v0.6 actor staging effects (spec-0014) ---
5666    /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
5667    /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
5668    SpawnActor {
5669        /// The actor (stage-5 `actors` ref) to summon.
5670        actor: ActorId,
5671    },
5672    /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
5673    /// animation (cutscene deaths); `vanish` is silent removal.
5674    DespawnActor {
5675        /// The actor (stage-5 `actors` ref) to remove.
5676        actor: ActorId,
5677        /// How the puppet is removed.
5678        style: DespawnStyle,
5679    },
5680    /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
5681    /// the assembled model, using the actor's hitbox footprint, yawed along the
5682    /// path tangent (DSL v0.6). Concurrent movers are allowed (a herded
5683    /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
5684    /// effects fire once the puppet reaches the destination cell.
5685    MoveActor {
5686        /// The actor (stage-5 `actors` ref) to move.
5687        actor: ActorId,
5688        /// The destination mark: an anchor and an optional offset (spec-0066).
5689        to: Mark,
5690        /// Optional travel speed in blocks/tick (defaults to ~0.15).
5691        #[serde(default, skip_serializing_if = "Option::is_none")]
5692        speed: Option<f64>,
5693        /// Effects fired once the puppet arrives at the destination cell.
5694        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5695        on_arrive: Vec<QuestEffect>,
5696    },
5697    /// Replaces an actor's puppet with a real-AI twin of the same type / position /
5698    /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
5699    /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
5700    /// special verb.
5701    UnleashActor {
5702        /// The actor (stage-5 `actors` ref) to unleash.
5703        actor: ActorId,
5704    },
5705    // --- spec-0082 assembly verbs ---
5706    /// Summons an assembly (spec-0082): its root, one display per rig part
5707    /// riding the root, and its hitbox when declared, at its mark, playing its
5708    /// `initial` clip. Idempotent: a spawn of a present assembly is a no-op.
5709    SpawnAssembly {
5710        /// The assembly (stage-5 `assemblies` ref) to summon.
5711        assembly: AssemblyId,
5712    },
5713    /// Removes an assembly's root, parts and hitbox (spec-0082). They leave
5714    /// unseen: a display entity has no death, so there is no `style`.
5715    DespawnAssembly {
5716        /// The assembly (stage-5 `assemblies` ref) to remove.
5717        assembly: AssemblyId,
5718    },
5719    /// Switches the clip an assembly plays (spec-0082); its first frame is
5720    /// applied on the next tick. While a strike step is in flight the switch
5721    /// waits for the step to end, so a story beat never cuts a strike at the
5722    /// frame before it lands.
5723    PlayClip {
5724        /// The assembly (stage-5 `assemblies` ref).
5725        assembly: AssemblyId,
5726        /// A clip the assembly's rig declares.
5727        clip: String,
5728    },
5729    /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
5730    /// at exact tick offsets. Effects are any in the stage-5 set except a nested
5731    /// `sequence` (rejected with `DW0329`).
5732    Sequence {
5733        /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
5734        steps: Vec<SequenceStep>,
5735    },
5736    /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
5737    /// projectiles with real velocity vectors, fired from a gallery slot into a
5738    /// declared kill zone.
5739    ///
5740    /// The contract is **saturation, not sniping**.
5741    /// Every salvo puts one projectile on the trajectory to **every standable
5742    /// cell of `kill_zone`**, plus one aimed at the triggering player's
5743    /// fire-time position (which punishes standing still). A player therefore
5744    /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
5745    /// decision rather than a lucky step. Coverage is proven at compile time:
5746    /// `from_anchor` must have clear line of fire to every standable kill-zone
5747    /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
5748    /// on the stairs" is a build-time fact, not a hope.
5749    ///
5750    /// Projectiles are summoned `NoGravity` so the flown path is exactly the
5751    /// straight segment the coverage proof checks — proof and runtime share one
5752    /// geometry. Drag scales speed but not direction, so the line is preserved.
5753    Volley {
5754        /// Projectile entity id (default `minecraft:arrow`; validated against
5755        /// the pinned 1.21.11 registry, `DW0441`).
5756        #[serde(default, skip_serializing_if = "Option::is_none")]
5757        projectile: Option<String>,
5758        /// The gallery slot the volley is fired FROM — a point anchor. Its cell
5759        /// must be clear (a projectile spawned inside a wall never leaves it).
5760        from_anchor: AnchorId,
5761        /// The zone the volley must blanket, as an anchor-centred box
5762        /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
5763        /// `begin-stealth`'s `zones` use. Every standable cell in it receives
5764        /// fire each salvo.
5765        ///
5766        /// Deliberately NOT a bare prefab `region` anchor: the assembled model
5767        /// clears every gate-region anchor's cells unconditionally, so
5768        /// describing a zone that way would delete the geometry it names.
5769        kill_zone: StealthZone,
5770        /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
5771        #[serde(default, skip_serializing_if = "Option::is_none")]
5772        salvos: Option<u32>,
5773        /// Ticks between salvos (default 10, `DW0443` bounds it).
5774        #[serde(default, skip_serializing_if = "Option::is_none")]
5775        interval: Option<u32>,
5776    },
5777    /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
5778    /// drop them as `falling_block` entities — the buried-alive trap redstone
5779    /// cannot express at all.
5780    ///
5781    /// The post-collapse world is **modeled, not guessed**: the compiler clears
5782    /// the region, settles every dropped column through the existing gravity
5783    /// model (spec-0010), optionally paves the landing surface with
5784    /// `then_floor`, and re-runs the critical-path completability proof against
5785    /// that mutated world (`DW0445`). A collapse that buries the only route is a
5786    /// build error, exactly as a `shortcut` seal that strands the party is.
5787    Collapse {
5788        /// The volume whose blocks fall, as an anchor-centred box
5789        /// (`anchor ± extent`). Must hold blocks and sit above standable footing
5790        /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
5791        ///
5792        /// An anchor-centred box rather than a prefab `region` anchor for a
5793        /// load-bearing reason: the assembled model deletes every gate-region
5794        /// anchor's cells, so a ceiling slab declared that way would already be
5795        /// gone before the trap ever fired.
5796        region_anchor: StealthZone,
5797        /// The block the falling entities are made of (default
5798        /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
5799        #[serde(default, skip_serializing_if = "Option::is_none")]
5800        falling_block: Option<String>,
5801        /// Optional block the landing surface is paved with once the debris
5802        /// settles — the authored post-collapse floor the completability proof
5803        /// reasons over.
5804        #[serde(default, skip_serializing_if = "Option::is_none")]
5805        then_floor: Option<String>,
5806    },
5807    /// Grants a vanilla **status effect** for a stated duration (DSL v0.10,
5808    /// spec-0031). The engine has emitted status effects since v0.6 — the
5809    /// night-vision area mitigation is a self-rescheduling, region-scoped
5810    /// `effect give` — and exposed none, so an author who wanted blindness for a
5811    /// lift ride, slowness in deep water or regeneration at a shrine had no
5812    /// surface at all. This is that surface, over the whole pinned 1.21.11
5813    /// `mob_effect` registry (`DW0192` rejects an id outside it).
5814    ///
5815    /// **A grant carries a duration, and there is no way to spell one that does
5816    /// not.** Vanilla's `infinite` keyword is deliberately absent from this
5817    /// surface: an effect whose only removal is a later command is an effect the
5818    /// player keeps forever whenever that command does not run — a logout, a
5819    /// crash, a chain interrupted by a death. A duration expires on its own, with
5820    /// no cooperation from anything. `seconds` is therefore required and bounded
5821    /// (1..=[`MAX_EFFECT_SECONDS`], `DW0541`), and the *pattern* that reintroduces
5822    /// the same hazard — pairing a grant with a `clear-effect` that removes it
5823    /// while it is still live — is `DW0540`.
5824    ///
5825    /// The envelope's `in` ([`QuestEffect::within`]) narrows to players inside an
5826    /// anchor-centred box. It is what makes "blind whoever is riding the car"
5827    /// expressible without blinding the whole party. A blinding grant owes the
5828    /// blind-reach proof wherever it is written (`DW0943`, spec-0085 §6).
5829    GiveEffect {
5830        /// Vanilla status-effect id (e.g. `minecraft:blindness`), validated
5831        /// against the pinned 1.21.11 registry (`DW0192`).
5832        effect: String,
5833        /// Duration in seconds. Required, `1..=`[`MAX_EFFECT_SECONDS`]
5834        /// (`DW0541`) — see the variant docs for why there is no infinite form.
5835        seconds: u32,
5836        /// Amplifier (0 = level I), `0..=`[`MAX_POTION_AMPLIFIER`] (`DW0541`).
5837        /// Absent = 0.
5838        #[serde(default, skip_serializing_if = "Option::is_none")]
5839        amplifier: Option<u32>,
5840        /// Suppress the swirling particles (vanilla's `hideParticles`). Absent =
5841        /// `false`, vanilla's own default.
5842        #[serde(default, skip_serializing_if = "Option::is_none")]
5843        hide_particles: Option<bool>,
5844    },
5845    /// Removes a status effect (DSL v0.10, spec-0031) — vanilla's `effect clear`.
5846    ///
5847    /// This is **not** how a `give-effect` is supposed to end: a duration is. It
5848    /// exists for the effects the engine did not grant — a potion the player
5849    /// drank, a `wither` a mob applied, the whole set at a bonfire — which is why
5850    /// `effect` is optional (absent = clear everything, exactly as vanilla's
5851    /// `effect clear <targets>` does). Pairing it with a live grant of the same
5852    /// effect in the same bundle is `DW0540`.
5853    ClearEffect {
5854        /// The status-effect id to remove (`DW0192`). **Absent clears every
5855        /// effect**, matching `effect clear <targets>` with no id.
5856        #[serde(default, skip_serializing_if = "Option::is_none")]
5857        effect: Option<String>,
5858    },
5859    /// Teleports **everything inside a declared volume** to an anchor (DSL v0.10,
5860    /// spec-0031).
5861    ///
5862    /// **The selector is a region, never a block.** "Whoever is standing on this
5863    /// block" has three different answers for a player half a foot over the edge,
5864    /// a player mid-jump and a player sneaking on the lip; a volume has one, and
5865    /// it is the same one every tick. `from` is the anchor-centred
5866    /// [`StealthZone`] box the rest of the engine already uses.
5867    ///
5868    /// **The selection is total over bodies.** Emission is a single `tp
5869    /// @e[<box>,tag=!dw_fixture] <cell>` with no `type=`, no `limit=` and no
5870    /// `sort=` — every body in the volume moves, and
5871    /// `crates/delvec/tests/v10_teleport.rs` asserts that from the emitted
5872    /// selector rather than from anyone's memory. A machinery-**type** exemption
5873    /// of the kind a `lethal_volumes[]` entry must carry was considered and
5874    /// **rejected**: an NPC is a body plus a co-located `minecraft:interaction`
5875    /// hitbox, so exempting `minecraft:interaction` — as the lethal volume does —
5876    /// would teleport the speaker and leave its dialogue box behind. Everyone on
5877    /// the car travels, players and entities
5878    /// alike; totality over bodies is how that is true.
5879    ///
5880    /// The one narrowing is a **class the engine's own furniture declares about
5881    /// itself**: `dw_fixture` means *my position IS engine state*. A bonfire's
5882    /// hitbox, a shortcut lever and a recovery stake's marker are places, not
5883    /// passengers, and carrying one does not move a thing — it rewrites a fact
5884    /// (for a stake, the ledger holds the marker's coordinates, and the next tick
5885    /// retires a marker nobody has a wager at). Places whose cell is known at
5886    /// compile time are refused outright instead, because the author can move
5887    /// them (`DW0542`); places the runtime puts down are excluded by the selector,
5888    /// because nobody can (`DW0545`). Nothing an author writes carries either tag,
5889    /// and no campaign JSON can turn either off.
5890    ///
5891    /// **A teleport is not a rescue.** Accumulated fall distance carries across
5892    /// one unchanged and is charged in full at the destination — measured Δ
5893    /// exactly `0.0000` in 46/46 trials on the pinned 1.21.11, including
5894    /// teleports 143 and 157 blocks straight *up*, with landing damage
5895    /// `floor(fall_distance) − 3` (`docs/notes/death-and-teleport-spike.md` §3).
5896    /// A platform that arrives under a falling player past ~20 blocks of fall is
5897    /// the surface they die on. The compiler does not try to reset the counter:
5898    /// what *does* reset it was explicitly not measured, and inventing a
5899    /// mechanism from recall is the folklore this project forbids.
5900    Teleport {
5901        /// The volume whose contents are moved, as an anchor-centred box
5902        /// (`anchor ± extent`) — the same shape a `begin-stealth` zone, a
5903        /// `damage-players` `in` filter and a `lethal_volumes[]` region take.
5904        ///
5905        /// Deliberately NOT a bare prefab `region` anchor, for the reason
5906        /// [`Verb::Collapse`] records: the assembled model clears every
5907        /// gate-region anchor's cells, so a volume described that way would
5908        /// delete the geometry it names.
5909        from: StealthZone,
5910        /// The destination mark (spec-0066). Resolved to a literal cell at build
5911        /// time, so the emitted `tp` carries absolute coordinates and no runtime
5912        /// search.
5913        to: Mark,
5914    },
5915    /// Fires a **firework rocket** from a mark (DSL v0.29, spec-0068).
5916    ///
5917    /// One effect at a point, the member of the same class as [`Verb::PlaySound`]
5918    /// — a one-shot thing that happens where the campaign says, beside the sound
5919    /// that goes with it. A display of many rockets is a [`Verb::Sequence`] of
5920    /// these, not a verb with timing of its own.
5921    ///
5922    /// # The burst height is a stated number, not a roll
5923    ///
5924    /// The emitter writes the entity's `LifeTime`
5925    /// ([`crate::firework::lifetime_ticks`]) rather than leaving it to the game,
5926    /// which randomises it at launch: two runs of one datapack would otherwise
5927    /// burst at two heights and nothing could be proven about where the burst is.
5928    /// Fixed at the floor of the game's range, the burst stands
5929    /// [`crate::firework::burst_height`] blocks over the mark.
5930    ///
5931    /// # A burst hurts, so the compiler asks where it is
5932    ///
5933    /// A build refuses a rocket whose column to that height is roofed, and one
5934    /// whose burst lies within [`crate::firework::BLAST_RADIUS`] blocks of a
5935    /// place the campaign posts a body (`DW0899`). Players are **not** posted:
5936    /// a player standing level with a burst takes up to
5937    /// [`crate::firework::worst_damage_hp`] HP, under a full body's twenty, and
5938    /// that is a hazard a player can see coming.
5939    Firework {
5940        /// The mark the rocket is launched from — the cell's centre, at the
5941        /// mark's own plane.
5942        at: Mark,
5943        /// Flight duration, 1–3 (the three the game crafts). Absent =
5944        /// [`crate::firework::MIN_FLIGHT`].
5945        #[serde(default, skip_serializing_if = "Option::is_none")]
5946        #[schemars(range(min = 1, max = 3))]
5947        flight: Option<u8>,
5948        /// One to seven bursts, in the order the component carries them.
5949        #[schemars(length(min = 1, max = 7))]
5950        explosions: Vec<FireworkExplosion>,
5951    },
5952    /// Spawns **particles** (spec-0085 §4.3) — the next one-shot point effect
5953    /// after [`Verb::Firework`], at a mark or at each addressed player.
5954    ///
5955    /// `particle` is a vanilla particle type id validated against the pinned
5956    /// registry `crates/dsl/data/particles-1.21.11.json`; an unknown id, or one
5957    /// whose type **takes options** (`dust`, `block`, `item`, …), is `DW0941`.
5958    ///
5959    /// Emitted as one vanilla `particle` command, always in **`force`** mode,
5960    /// whose viewers are the effect's audience: a particle a creator writes is
5961    /// meant to be seen, and `force` is the mode the game sends 512 blocks out
5962    /// and draws even at the client's Minimal particle setting. A
5963    /// `minecraft:elder_guardian` at `players` is the full-screen face.
5964    Particle {
5965        /// The particle type id (`minecraft:` prefix optional).
5966        particle: String,
5967        /// Where the particles spawn: a [`Mark`], or `players` — at each
5968        /// addressed player's own position.
5969        at: ParticleAt,
5970        /// How many (vanilla `<count>`, default 1). Zero is vanilla's spelling of
5971        /// a different thing — one particle with a velocity — and is refused.
5972        #[serde(default, skip_serializing_if = "Option::is_none")]
5973        #[schemars(range(min = 1))]
5974        count: Option<u32>,
5975        /// Standard deviations `[x, y, z]` of the spawn spread, in blocks
5976        /// (vanilla `<delta>`, default `[0, 0, 0]`).
5977        #[serde(default, skip_serializing_if = "Option::is_none")]
5978        spread: Option<[f64; 3]>,
5979        /// Vanilla `<speed>` (default 0).
5980        #[serde(default, skip_serializing_if = "Option::is_none")]
5981        speed: Option<f64>,
5982    },
5983}
5984
5985/// Where a [`Verb::Particle`] spawns (spec-0085 §4.3): a mark, or the literal
5986/// `players`.
5987#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5988#[serde(untagged)]
5989pub enum ParticleAt {
5990    /// `"players"` — at each addressed player's own position.
5991    Players(PlayersKeyword),
5992    /// A mark — the cell's centre at the mark's plane.
5993    Mark(Mark),
5994}
5995
5996/// The literal `players`, the one keyword [`ParticleAt`] admits.
5997#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5998#[serde(rename_all = "kebab-case")]
5999pub enum PlayersKeyword {
6000    /// At each addressed player.
6001    Players,
6002}
6003
6004/// Default `grace_ticks` for [`Verb::BeginStealth`] (spec-0014).
6005fn default_grace_ticks() -> u32 {
6006    20
6007}
6008
6009/// Default projectile for [`Verb::Volley`] (spec-0022).
6010pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
6011/// Default salvo count for [`Verb::Volley`] (spec-0022).
6012pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
6013/// Default ticks between salvos for [`Verb::Volley`] (spec-0022).
6014pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
6015/// Largest admissible `salvos` — beyond this a volley is an entity-count
6016/// hazard rather than a trap (`DW0443`).
6017pub const MAX_VOLLEY_SALVOS: u32 = 16;
6018/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
6019/// slower than this is no longer one event the player reads as a trap.
6020pub const MAX_VOLLEY_INTERVAL: u32 = 200;
6021/// Default falling block for [`Verb::Collapse`] (spec-0022).
6022pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
6023
6024/// A stealth "shadow" region (DSL v0.6, spec-0014): an axis-aligned box centred
6025/// on `anchor`, extending `extent` blocks along each axis (so the box spans
6026/// `anchor ± extent`). Presented in-world via dark cells but judged purely by
6027/// region membership, so the check is deterministic and provable.
6028#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6029#[serde(deny_unknown_fields)]
6030pub struct StealthZone {
6031    /// The prefab anchor at the centre of the zone box.
6032    pub anchor: AnchorId,
6033    /// Half-extents `[x, y, z]` in blocks from the anchor (each component ≥ 0);
6034    /// the zone AABB is `[anchor - extent, anchor + extent]`.
6035    pub extent: [u32; 3],
6036}
6037
6038/// Where a [`Verb::PlaySound`] originates (DSL v0.6, spec-0014). A sound
6039/// plays at fixed coordinates or at each listener's own position; the compiler
6040/// resolves no position for a live actor, so the `actor` variant is accepted by
6041/// the schema and rejected with `DW0335`.
6042#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6043#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
6044pub enum SoundAt {
6045    /// Play the sound positioned at a resolved anchor, audible to all players.
6046    Anchor {
6047        /// The anchor the sound plays from.
6048        anchor: AnchorId,
6049        /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
6050        /// `[0, 0, 0]`): the sound plays at the [`Mark`] the two fields spell.
6051        #[serde(default, skip_serializing_if = "is_zero3")]
6052        offset: [i32; 3],
6053    },
6054    /// Play the sound at each player's own position (the default), or at
6055    /// `offset` in **the listener's own frame** (spec-0085 §4.4): `+x` to the
6056    /// listener's left, `+y` up, `+z` the way the listener faces, with the pitch
6057    /// flattened so *behind* stays at ear height. `[0, 0, -3]` is three blocks
6058    /// behind. Integer blocks, as a [`Mark`]'s offset is.
6059    Players {
6060        /// Integer `[x, y, z]` offset in the listener's local frame (default
6061        /// `[0, 0, 0]`).
6062        #[serde(default, skip_serializing_if = "is_zero3")]
6063        offset: [i32; 3],
6064    },
6065    /// Play the sound at a scripted actor's position (rejected — `DW0335`; no
6066    /// actor position resolves at emission).
6067    Actor {
6068        /// The actor id (stage-5 `actors[]`).
6069        actor: String,
6070    },
6071}
6072
6073/// The damage type of a [`Verb::DamagePlayers`] effect (DSL v0.6). A
6074/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
6075/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
6076/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
6077/// `generic_kill` types are deliberately excluded, so a scripted consequence can
6078/// never silently void a player's held totem. Modelled as an enum (not a free
6079/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
6080/// registry / diagnostic. `generic` is the default: command damage that respects
6081/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
6082#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6083#[serde(rename_all = "kebab-case")]
6084pub enum DamageKind {
6085    /// `minecraft:generic` — armor-ignoring command damage (default).
6086    Generic,
6087    /// `minecraft:magic` — magical damage.
6088    Magic,
6089    /// `minecraft:wither` — the wither/withering effect's damage type.
6090    Wither,
6091    /// `minecraft:on_fire` — burning damage.
6092    Fire,
6093    /// `minecraft:drown` — drowning damage.
6094    Drown,
6095    /// `minecraft:freeze` — powder-snow freezing damage.
6096    Freeze,
6097    /// `minecraft:fall` — fall damage.
6098    Fall,
6099    /// `minecraft:lightning_bolt` — a lightning strike's damage type.
6100    LightningBolt,
6101    /// `minecraft:explosion` — a (non-player) explosion's damage type.
6102    Explosion,
6103}
6104
6105impl DamageKind {
6106    /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
6107    pub fn id(self) -> &'static str {
6108        match self {
6109            DamageKind::Generic => "minecraft:generic",
6110            DamageKind::Magic => "minecraft:magic",
6111            DamageKind::Wither => "minecraft:wither",
6112            DamageKind::Fire => "minecraft:on_fire",
6113            DamageKind::Drown => "minecraft:drown",
6114            DamageKind::Freeze => "minecraft:freeze",
6115            DamageKind::Fall => "minecraft:fall",
6116            DamageKind::LightningBolt => "minecraft:lightning_bolt",
6117            DamageKind::Explosion => "minecraft:explosion",
6118        }
6119    }
6120}
6121
6122/// A stage-5 **lethal volume** (DSL v0.10, spec-0031): a declared box that kills
6123/// whatever enters it, and states — in the campaign's own words — what killed it.
6124///
6125/// # A mechanism, not a fiction
6126///
6127/// The commissioning case was a cliff whose fall must be fatal, but nothing here
6128/// knows what a cliff is: a lava pit, an acid pool, an out-of-bounds plane and the
6129/// bottom of a lift shaft are the same declaration, differently dressed and
6130/// differently worded. The alternative considered and **rejected** for the cliff
6131/// was making the world's horizon void so the fall kills anyway — that changes
6132/// approved art to obtain a behaviour, and it serves exactly one fiction.
6133///
6134/// # It is geometry, so the completability proof owns it
6135///
6136/// A volume that kills is, for a route, a volume that cannot be crossed. The
6137/// compiler models its cells as impassable in the same navigation world every
6138/// other reachability proof runs on, exactly as a `close-gate`'s sealed region is
6139/// modelled solid — so a forced path that has no way to an objective except
6140/// through a lethal volume is a build failure (`DW0510`) naming the volume, never
6141/// a shipped delve that kills the player on the critical path. A respawn seat
6142/// inside one is the death loop that failure mode ends in, and is its own
6143/// error (`DW0511`).
6144///
6145/// # It rides the death edge that already exists
6146///
6147/// The kill is a `/damage`, exactly like `damage-players`, a trap payload or a
6148/// timed gate's crush. Everything downstream — the vanilla `deathCount` edge
6149/// (`dw.deaths` / `dw.death_ack`), the checkpoint re-seat (`cp_respawn_check`),
6150/// `keep_inventory` — sees an ordinary death and needs no second detector.
6151#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6152#[serde(deny_unknown_fields)]
6153pub struct LethalVolume {
6154    /// Unique lethal-volume id (`lethal/<kebab>`).
6155    pub id: LethalVolumeId,
6156    /// The volume: an anchor-centred box (`anchor ± extent`).
6157    ///
6158    /// **Deliberately the existing zone type**, not a second struct with the same
6159    /// two fields. `StealthZone` is already the engine's anchor-centred box object
6160    /// class — `damage-players`'s `in` filter reuses it, and the compiler resolves
6161    /// every one of them through the single `Plan::zone_box`. A private twin here
6162    /// would be `tools/ci/check-capability-ownership.py` check C by construction, and
6163    /// would fork the resolution the very next time a box grew a capability.
6164    pub region: StealthZone,
6165    /// What this volume says when it kills — a player-visible line, inventoried
6166    /// under `lethal.<id>.message` and translated like every other one.
6167    ///
6168    /// Required, and deliberately so: a volume with no words is a player who dies
6169    /// with no idea why, and there is no compiler-owned default that could be
6170    /// right for a cliff, a lava pit and an acid pool at once.
6171    pub message: String,
6172    /// The damage type the kill is dealt with (default [`DamageKind::Generic`]).
6173    ///
6174    /// This is what words vanilla's own broadcast — `fall` says the party member
6175    /// fell from a high place, `on_fire` that they burnt to a crisp — while
6176    /// [`Self::message`] says what the *place* was. The curated enum is shared
6177    /// with `damage-players`, so a lethal volume can no more void a held totem
6178    /// than a scripted hit can.
6179    #[serde(default, skip_serializing_if = "Option::is_none")]
6180    pub damage_type: Option<DamageKind>,
6181    /// **The blocks that show a player this floor kills** (spec-0062 §3).
6182    ///
6183    /// A lava surface, a magma floor, a bed of spikes, a burning strip: the
6184    /// danger is level with the footing because the block *is* the signal. This
6185    /// is where a volume says so, and it is a claim about the assembled bytes
6186    /// rather than a word that switches a rule off — `DW0891` checks it per
6187    /// caught cell against the block under or in that cell, refuses a listed
6188    /// block no caught cell bears out, and refuses at validation any id vanilla
6189    /// does not hurt a body with.
6190    ///
6191    /// Empty is the ordinary case and means the ordinary thing: this volume
6192    /// catches no floor the party walks, because it sits at the bottom of a pit
6193    /// or a course under a lake's surface. It does **not** exempt a cell from
6194    /// the walk graph — a visible hazard is still a hazard, and the router
6195    /// refuses every cell of the keep-out either way.
6196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6197    pub shown_by: Vec<String>,
6198    /// **When this volume kills** (spec-0088): the one [`Guard`] every other
6199    /// gated object carries, verbatim. Absent, the volume is live from
6200    /// world-load to the end. Present, it is live while its gate holds —
6201    /// `requires_flags` is a pit that kills from a beat on, `forbids_flags` a
6202    /// shaft that kills until one, `requires_state` a chamber that kills while
6203    /// a party datum stands in range.
6204    ///
6205    /// A volume's liveness is a fact about the place, so the gate is a fact
6206    /// about the party: `when: {}` (a stage with no term) and a term on a
6207    /// `player`-scoped datum are refused at the document (`DW0953`). The
6208    /// navigation world holds the volume per quest configuration, and `DW0891`
6209    /// judges what shows it in every configuration a body can meet it in,
6210    /// including the last one before it goes live.
6211    #[serde(default, skip_serializing_if = "Option::is_none")]
6212    pub when: Option<Guard>,
6213}
6214
6215/// A stage-5 **loop** (spec-0086): a slab a body crosses and is returned from,
6216/// by a whole-block offset, to an earlier section that looks exactly the same —
6217/// its position inside the cell, its facing and its velocity all kept.
6218///
6219/// # It is a region with a standing property
6220///
6221/// A loop acts on whatever body enters a volume, every tick, the way a
6222/// [`LethalVolume`] does; nothing completes and nobody is addressed. So it is
6223/// declared beside the lethal volume, with the same region type
6224/// ([`StealthZone`], resolved through the one `Plan::zone_box`), and not as a
6225/// trigger with a relative teleport in it: the seamlessness proof is a property
6226/// of the region-plus-offset pair, which a compiler would otherwise have to
6227/// recognise by pattern-matching a trigger's effect list.
6228///
6229/// # The offset is derived, never typed
6230///
6231/// `to` is a [`Mark`] naming where the slab's own anchor cell lands, so the
6232/// offset is `cell(to) − cell(region.anchor)` — a whole-block vector by
6233/// construction, and a judgement about the world (*the fourth bay's anchor lands
6234/// on the second's*) rather than a vector the compiler could compute.
6235///
6236/// # The gate is the release
6237///
6238/// The loop **holds** while its gate is open and stands down while it is shut,
6239/// read against the party: flags are campaign state, and a `requires_state` term
6240/// must name a `party` datum (`DW0949`). A loop with no gate term holds forever
6241/// and is refused at the document (`DW0949`).
6242#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6243#[serde(deny_unknown_fields)]
6244pub struct Loop {
6245    /// Unique loop id (`loop/<kebab>`).
6246    pub id: LoopId,
6247    /// The slab a body crosses: an anchor-centred box (`anchor ± extent`), one
6248    /// axis of which is the crossing axis. The existing zone type, for the reason
6249    /// [`LethalVolume::region`] gives.
6250    pub region: StealthZone,
6251    /// Where the slab's own anchor cell lands: the loop's offset is
6252    /// `cell(to) − cell(region.anchor)`. Lies inside its anchor's piece
6253    /// (`DW0897`).
6254    pub to: Mark,
6255    /// Flags that must all be set for the loop to hold.
6256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6257    pub requires_flags: Vec<FlagId>,
6258    /// Flags any one of which stands the loop down — `forbids_flags:
6259    /// [flag/the-bell-found]` is a loop that ends the moment the bell is found.
6260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6261    pub forbids_flags: Vec<FlagId>,
6262    /// Numeric gate terms: every comparison must hold for the loop to hold. Each
6263    /// names a `party`-scoped datum (`DW0949`). The third field of the one gate,
6264    /// carried by every gate consumer.
6265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6266    pub requires_state: Vec<StateCompare>,
6267    /// A `party`-scoped datum the loop raises by one on every move, before
6268    /// `on_cross` runs — the counter a crossing-counted release reads.
6269    #[serde(default, skip_serializing_if = "Option::is_none")]
6270    pub counts: Option<StateId>,
6271    /// The dungeon's answer to a move: effects run on every move, after the body
6272    /// is moved and the count raised, from the server command source (no acting
6273    /// player). Each effect's own `when` keys a write to a count. A `teleport`
6274    /// here is refused (`DW0949`).
6275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6276    pub on_cross: Vec<QuestEffect>,
6277}
6278
6279// ---------------------------------------------------------------------------
6280// Stage 5 — trade and the recovery stake (DSL v0.10, spec-0032)
6281// ---------------------------------------------------------------------------
6282
6283/// A stage-5 **shop** (DSL v0.10, spec-0032): an interaction point that opens a
6284/// list of offers, each of which is a gate and a bundle of effects.
6285///
6286/// # It is the rest flow with different buttons, and that is the whole design
6287///
6288/// A bonfire is already an interaction entity, a player-interaction advancement
6289/// that supplies the acting player, a `minecraft:multi_action` dialog, buttons
6290/// that run `/trigger`, and tick dispatch. A shop is that same hardware with an
6291/// authored button list, so nothing here is new machinery — it is the machinery
6292/// spec-0016 §1 shipped, made author-visible.
6293///
6294/// # There is no price field, and that is deliberate
6295///
6296/// A price is *"may this happen yet?"*, which is the question
6297/// [`Gate`](crate::gate::Gate) answers for six other object classes. An offer is
6298/// therefore the **seventh gate consumer**: it carries `requires_flags`,
6299/// `forbids_flags` and `requires_state`, and the numeric comparison it needs is
6300/// the one spec-0031 put in the gate rather than in the verb that first asked.
6301/// A `price` field would be a second comparison surface for one meaning, which is
6302/// the defect CLAUDE.md names first.
6303///
6304/// # Villager `Offers` is excluded
6305///
6306/// Three independent reasons, any one sufficient (spec-0032): a vanilla trade
6307/// cost can only ever be an item, never a scoreboard value; right-click on a
6308/// villager body is already allocated to dialogue; and the data-driven trade
6309/// registry post-dates the pinned 1.21.11.
6310#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6311#[serde(deny_unknown_fields)]
6312pub struct Shop {
6313    /// Unique shop id (`shop/<kebab>`).
6314    pub id: ShopId,
6315    /// The prefab anchor the shop's interaction point stands on.
6316    pub anchor: AnchorId,
6317    /// The dialog's title line. Player-visible, inventoried under
6318    /// `shop.<id>.title`.
6319    pub title: String,
6320    /// The item the visible marker renders as (default `minecraft:emerald`).
6321    /// Cosmetic only: the marker is a `minecraft:item_display`, so it never
6322    /// occupies the cell or obstructs the hitbox.
6323    #[serde(default, skip_serializing_if = "Option::is_none")]
6324    pub marker_item: Option<String>,
6325    /// The offers, in declaration order — which is button order, and the order
6326    /// the `/trigger` routing values are assigned in.
6327    pub offers: Vec<ShopOffer>,
6328}
6329
6330/// One button in a [`Shop`]: a gate, a label, and the effects choosing it runs.
6331///
6332/// **Effect root R8.** The bundle hangs off an object with runtime machinery of
6333/// its own (an advancement, a dialog, a trigger objective and a tick dispatch),
6334/// which is spec-0031's stated rule for adding a root rather than desugaring.
6335///
6336/// **Refusal is authored, not a field.** An offer whose own gate is closed is not
6337/// shown at all; an offer that should be *shown and refuse* leaves its own gate
6338/// open and gates its effects instead — the purchase behind `at-least <price>`,
6339/// the apology behind `at-most <price − 1>`. Both are the ordinary per-effect
6340/// gate every effect already carries, so the engine adds no `refused` field and
6341/// no second way to say one thing.
6342#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6343#[serde(deny_unknown_fields)]
6344pub struct ShopOffer {
6345    /// The button's caption. Player-visible, inventoried under
6346    /// `shop.<id>.offer.<i>.label`, and width-checked like any dialog button.
6347    pub label: String,
6348    /// The button's hover tooltip — the natural home for a price in words.
6349    /// Player-visible, inventoried under `shop.<id>.offer.<i>.tooltip`.
6350    #[serde(default, skip_serializing_if = "Option::is_none")]
6351    pub tooltip: Option<String>,
6352    /// Flags that must all be set for this offer to be shown (the one gate).
6353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6354    pub requires_flags: Vec<FlagId>,
6355    /// Flags whose being set hides this offer (the one gate).
6356    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6357    pub forbids_flags: Vec<FlagId>,
6358    /// Numeric gate terms (the one gate) — **this is where a price lives**.
6359    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6360    pub requires_state: Vec<StateCompare>,
6361    /// What choosing this offer does. Effect root R8; runs with the choosing
6362    /// player as the acting player, so a `player`-scoped datum is writable here.
6363    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6364    pub effects: Vec<QuestEffect>,
6365}
6366
6367impl ShopOffer {
6368    /// This offer's whole gate, as one value (DSL v0.10) — see
6369    /// [`crate::gate::Gate`].
6370    pub fn gate(&self) -> crate::gate::Gate<'_> {
6371        crate::gate::Gate::of(
6372            &self.requires_flags,
6373            &self.forbids_flags,
6374            &self.requires_state,
6375        )
6376    }
6377}
6378
6379/// How much of a datum a death forfeits into a [`Stake`] (DSL v0.10, spec-0032).
6380///
6381/// A creator who wants **no death cost at all** picks [`Forfeit::None`]; the
6382/// stake then still marks where they fell, which is the memorial case.
6383#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6384#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
6385pub enum Forfeit {
6386    /// The whole balance.
6387    All,
6388    /// A percentage of the balance, rounded toward zero (integer arithmetic —
6389    /// ADR-0006 forbids anything a second implementation could round differently).
6390    Proportion {
6391        /// 0–100.
6392        percent: u32,
6393    },
6394    /// A fixed amount, capped at the balance so a purse can never go negative.
6395    Fixed {
6396        /// The amount.
6397        amount: i32,
6398    },
6399    /// Nothing is taken. The stake is a marker, not a wager.
6400    None,
6401}
6402
6403/// What a death does when the player already holds the maximum number of live
6404/// stakes (DSL v0.10, spec-0032).
6405#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6406#[serde(rename_all = "kebab-case")]
6407pub enum OnFull {
6408    /// Retire the player's oldest live stake — its contents are lost — and place a
6409    /// new one. With `max_live: 1` this is the souls behaviour.
6410    Replace,
6411    /// Leave the existing stakes alone: the new death places nothing and forfeits
6412    /// nothing. One wager at a time.
6413    Keep,
6414}
6415
6416impl OnFull {
6417    /// The wire token (`replace` / `keep`).
6418    pub fn token(self) -> &'static str {
6419        match self {
6420            OnFull::Replace => "replace",
6421            OnFull::Keep => "keep",
6422        }
6423    }
6424}
6425
6426/// Who may collect a stake that is not theirs (DSL v0.10, spec-0032).
6427#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6428#[serde(rename_all = "kebab-case")]
6429pub enum CollectBy {
6430    /// Only the player who left it. The default.
6431    Owner,
6432    /// Anyone standing at it. The whole of every live stake at that place is
6433    /// transferred to the collector.
6434    Anyone,
6435}
6436
6437impl CollectBy {
6438    /// The wire token (`owner` / `anyone`).
6439    pub fn token(self) -> &'static str {
6440        match self {
6441            CollectBy::Owner => "owner",
6442            CollectBy::Anyone => "anyone",
6443        }
6444    }
6445}
6446
6447/// A stage-5 **recovery stake** (DSL v0.10, spec-0032): what a death leaves
6448/// behind, and the one chance to get it back.
6449///
6450/// # A mechanism, not a genre
6451///
6452/// "Souls" is one setting of this declaration and nothing here knows the word.
6453/// The mechanism is *a death forfeits some of a declared datum into a collectable
6454/// marker at a computed place, and collecting it returns exactly what was taken*.
6455/// A campaign with no death cost, a campaign with a permanent memorial at every
6456/// death site, and a campaign with the souls loop are three configurations, not
6457/// three engines.
6458///
6459/// # Where it lands is a compile-time answer
6460///
6461/// The owner's rule: *the anchor is the point, on the walkable path
6462/// from the respawn point in force at the moment of death to the death point
6463/// under the quest state in force at that moment, that minimises distance to the
6464/// death point.* Every term already has an owner in the compiler — walkability is
6465/// the navigation world the completability proof runs on, quest-state passability
6466/// is the DAG-indexed sealing `close-gate` established, and the respawn point in
6467/// force is engine state. So it is a table computed at build time and a lookup at
6468/// run time: **no runtime search, no nondeterminism.**
6469///
6470/// # It is hardware, so it inherits the hardware rules
6471///
6472/// The marker is an invisible `minecraft:interaction` for the hitbox plus a
6473/// glowing `minecraft:item_display` for the rendering — the same pair every other
6474/// affordance in the engine uses, and therefore the same invisible-affordance
6475/// (`DW0420`) and hardware-erasure (`DW0421`) proofs. It is deliberately **not**
6476/// a dropped item entity: an item despawns after 6000 ticks, burns in lava, sinks
6477/// in the void, and can be picked up by anyone.
6478#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6479#[serde(deny_unknown_fields)]
6480pub struct Stake {
6481    /// Unique stake id (`stake/<kebab>`).
6482    pub id: StakeId,
6483    /// The datum a death forfeits into this stake, and a collection returns.
6484    /// Must be declared in the stage-5 `state` list, and must be `player`-scoped:
6485    /// a stake is a personal wager, and a party-shared purse would turn one
6486    /// player's death into everyone's penalty.
6487    pub state: StateId,
6488    /// How much of [`Self::state`] a death takes. Default: the whole balance.
6489    #[serde(default, skip_serializing_if = "Option::is_none")]
6490    pub forfeit: Option<Forfeit>,
6491    /// How many live stakes one player may hold at once. Default 1; `0` means a
6492    /// death never places one (and never forfeits anything), which is the
6493    /// "no death cost" configuration.
6494    #[serde(default, skip_serializing_if = "Option::is_none")]
6495    pub max_live: Option<u32>,
6496    /// What a death does when the player is already at [`Self::max_live`].
6497    /// Default [`OnFull::Replace`].
6498    #[serde(default, skip_serializing_if = "Option::is_none")]
6499    pub on_full: Option<OnFull>,
6500    /// Who may collect. Default [`CollectBy::Owner`].
6501    #[serde(default, skip_serializing_if = "Option::is_none")]
6502    pub collect_by: Option<CollectBy>,
6503    /// The line a collection says, on the action bar. Player-visible, inventoried
6504    /// under `stake.<id>.collected`.
6505    pub collected_message: String,
6506    /// The item the glowing marker renders as (default `minecraft:soul_lantern`).
6507    #[serde(default, skip_serializing_if = "Option::is_none")]
6508    pub marker_item: Option<String>,
6509}
6510
6511impl Stake {
6512    /// The forfeit rule, with the documented default applied.
6513    pub fn forfeit(&self) -> Forfeit {
6514        self.forfeit.unwrap_or(Forfeit::All)
6515    }
6516
6517    /// How many live stakes one player may hold, with the documented default.
6518    pub fn max_live(&self) -> u32 {
6519        self.max_live.unwrap_or(1)
6520    }
6521
6522    /// The at-capacity rule, with the documented default.
6523    pub fn on_full(&self) -> OnFull {
6524        self.on_full.unwrap_or(OnFull::Replace)
6525    }
6526
6527    /// The collection rule, with the documented default.
6528    pub fn collect_by(&self) -> CollectBy {
6529        self.collect_by.unwrap_or(CollectBy::Owner)
6530    }
6531
6532    /// The item the marker renders as, with the documented default.
6533    pub fn marker_item(&self) -> &str {
6534        self.marker_item
6535            .as_deref()
6536            .unwrap_or("minecraft:soul_lantern")
6537    }
6538}
6539
6540/// The presentation channel for a [`Verb::Narrate`] (DSL v0.4).
6541#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6542#[serde(rename_all = "kebab-case")]
6543pub enum NarrateStyle {
6544    /// A chat line (default).
6545    Chat,
6546    /// A large on-screen title.
6547    Title,
6548    /// An on-screen subtitle.
6549    Subtitle,
6550    /// An "art" title rendered through the delve's custom resource-pack pixel-banner
6551    /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
6552    /// spec-0014). Text is checked at compile time against the font's glyph inventory
6553    /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
6554    /// renders in the vanilla title slot, so it is width-checked like any title
6555    /// (`DW0330`) — roughly 15 glyphs fit on screen.
6556    Art,
6557    /// The **actionbar** — the one-line strip above the hotbar (DSL v0.11).
6558    ///
6559    /// This is the channel a *reply* uses: it does not interrupt, it does not
6560    /// stack, and it is overwritten by the next one. Every reply the compiler
6561    /// itself writes has always used it — a sealed gate's answer, a checkpoint
6562    /// return, the lobby's party count — but `narrate` could not reach it, which
6563    /// is the mechanical reason `close-gate.sealed_hint` could not have been an
6564    /// ordinary `narrate` even had someone tried (capability-ownership audit
6565    /// finding 3b). A channel is a property of the message, not of the verb that
6566    /// first wanted it.
6567    ///
6568    /// Unlike a title it is never width-checked: vanilla truncates nothing and
6569    /// draws it at GUI width, and a reply is a fragment rather than a banner.
6570    Actionbar,
6571}
6572
6573impl NarrateStyle {
6574    /// The kebab tag (`chat` / `title` / `subtitle` / `art` / `actionbar`).
6575    pub fn token(self) -> &'static str {
6576        match self {
6577            NarrateStyle::Chat => "chat",
6578            NarrateStyle::Title => "title",
6579            NarrateStyle::Subtitle => "subtitle",
6580            NarrateStyle::Art => "art",
6581            NarrateStyle::Actionbar => "actionbar",
6582        }
6583    }
6584}
6585
6586/// **A mark** (spec-0066): an anchor and an integer block offset from it, the
6587/// one declaration of a point the campaign places relative to a piece.
6588///
6589/// Its cell is the anchor's resolved cell plus `offset`, in world axes after
6590/// the piece's placement. It is what a body stands on ([`Npc::offset`],
6591/// [`Actor::offset`]), where a walk ends ([`Verb::MoveNpc`], [`Verb::MoveActor`],
6592/// [`Verb::Teleport`]), where the cast ledger says a body is
6593/// ([`CastPlace::Mark`]), where a sound plays ([`SoundAt::Anchor`]), and every
6594/// camera position in a shot: a dolly waypoint (`path`), an aim target
6595/// (`look_at`) and an anchor subject (`subject`). The roles are fields; the
6596/// type is one.
6597///
6598/// A mark's cell lies inside the placed piece its anchor belongs to (`DW0897`):
6599/// an offset says *where beside this place*, never *which place*.
6600#[derive(
6601    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
6602)]
6603#[serde(deny_unknown_fields)]
6604pub struct Mark {
6605    /// The anchor the mark is relative to.
6606    pub anchor: AnchorId,
6607    /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
6608    #[serde(default, skip_serializing_if = "is_zero3")]
6609    pub offset: [i32; 3],
6610}
6611
6612impl Mark {
6613    /// A mark at `anchor` with no offset.
6614    pub fn at(anchor: AnchorId) -> Self {
6615        Mark {
6616            anchor,
6617            offset: [0, 0, 0],
6618        }
6619    }
6620
6621    /// Whether the offset is non-zero.
6622    pub fn is_offset(&self) -> bool {
6623        !is_zero3(&self.offset)
6624    }
6625
6626    /// The mark's cell, given the cell its anchor resolved to.
6627    pub fn cell(&self, anchor_cell: [i32; 3]) -> [i32; 3] {
6628        offset_cell(anchor_cell, self.offset)
6629    }
6630
6631    /// The mark as a diagnostic spells it: the anchor alone at a zero offset,
6632    /// else `anchor + [x, y, z]`.
6633    pub fn display(&self) -> String {
6634        if self.is_offset() {
6635            format!(
6636                "{} + [{}, {}, {}]",
6637                self.anchor, self.offset[0], self.offset[1], self.offset[2]
6638            )
6639        } else {
6640            self.anchor.as_str().to_string()
6641        }
6642    }
6643}
6644
6645/// `cell + offset`, componentwise: the one arithmetic a [`Mark`] adds.
6646pub fn offset_cell(cell: [i32; 3], offset: [i32; 3]) -> [i32; 3] {
6647    [
6648        cell[0] + offset[0],
6649        cell[1] + offset[1],
6650        cell[2] + offset[2],
6651    ]
6652}
6653
6654/// One shot of a [`Verb::Cutscene`] (DSL v0.6): a camera dolly with its
6655/// own duration and optional subject. A cutscene plays its shots back-to-back —
6656/// a hard cut between them — inside a single gamemode/position save-restore
6657/// bracket, so a wide establishing move can be followed by an interior close-up
6658/// without the players ever leaving the cinematic.
6659#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6660#[serde(deny_unknown_fields)]
6661pub struct CameraShot {
6662    /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
6663    /// path is a static shot. Required without `shot_style`; with one, optional —
6664    /// an explicit `path` always overrides the style's expanded dolly.
6665    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6666    pub path: Vec<Mark>,
6667    /// This shot's duration in seconds. Required without `shot_style`; with one,
6668    /// optional — the style's default duration applies (see
6669    /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
6670    #[serde(default, skip_serializing_if = "Option::is_none")]
6671    pub seconds: Option<u32>,
6672    /// Optional subject the camera keeps framed for this shot. Absent = face
6673    /// along the direction of travel (or, under a `shot_style`, the style's own
6674    /// aim at its `subject`). An explicit `look_at` always overrides a style's
6675    /// aim.
6676    #[serde(default, skip_serializing_if = "Option::is_none")]
6677    pub look_at: Option<Mark>,
6678    /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
6679    /// compiler expands the style deterministically into a camera dolly +
6680    /// per-keyframe aim from the `subject`'s resolved geometry. Requires
6681    /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
6682    /// the corresponding expanded part (`DW0348` polices the combinations).
6683    #[serde(default, skip_serializing_if = "Option::is_none")]
6684    pub shot_style: Option<ShotStyle>,
6685    /// The styled shot's subject — what the shot is *about*. Required with
6686    /// `shot_style`, rejected without one (`DW0348`).
6687    #[serde(default, skip_serializing_if = "Option::is_none")]
6688    pub subject: Option<CameraSubject>,
6689    /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
6690    #[serde(default, skip_serializing_if = "Option::is_none")]
6691    pub subject_b: Option<CameraSubject>,
6692    /// Styled shots only: the style's characteristic camera distance in blocks
6693    /// (its *start* distance for the dolly styles). Default per style — see the
6694    /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
6695    /// (`DW0348`).
6696    #[serde(default, skip_serializing_if = "Option::is_none")]
6697    pub dist: Option<f64>,
6698    /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
6699    /// default 90 (`DW0348` elsewhere or out of range).
6700    #[serde(default, skip_serializing_if = "Option::is_none")]
6701    pub degrees: Option<f64>,
6702    /// Styled shots only: placement bearing in degrees — where the camera sits
6703    /// (or starts) relative to the subject, measured like a Minecraft yaw
6704    /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
6705    /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
6706    /// `side-track` the bearing picks which side of the subject's travel the
6707    /// camera runs abeam (`0` = right of travel, `180` = left).
6708    #[serde(default, skip_serializing_if = "Option::is_none")]
6709    pub bearing: Option<f64>,
6710}
6711
6712/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
6713/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
6714/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
6715/// is **distance only** — vanilla has no in-game FOV control.
6716#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6717#[serde(rename_all = "kebab-case")]
6718pub enum ShotStyle {
6719    /// Fully static close framing of a prop/detail. The beat that always looks
6720    /// right.
6721    Insert,
6722    /// Static position; only the aim turns as the subject (ideally moving)
6723    /// passes. Rockstar's roadside "ground view".
6724    LockedOff,
6725    /// Straight dolly toward the subject along the view axis; medium → close.
6726    PushIn,
6727    /// Reverse of `push-in`: close → wide, revealing context.
6728    PullBackReveal,
6729    /// High and far, descending and closing on the subject. First sight of an
6730    /// area.
6731    EstablishingCrane,
6732    /// Constant-radius, constant-height arc around the subject.
6733    OrbitArc,
6734    /// Parallel dolly abeam a **moving** subject at constant offset — needs a
6735    /// compiler-known subject path (`DW0349`).
6736    SideTrack,
6737    /// Static placement solved so **both** subjects land on opposite thirds
6738    /// (Toric-space construction). Needs `subject_b`.
6739    TwoShot,
6740    /// Low, close, trailing a **moving** subject near ground level — needs a
6741    /// compiler-known subject path (`DW0349`).
6742    LowFollow,
6743}
6744
6745impl ShotStyle {
6746    /// The kebab token (diagnostics, digests).
6747    pub fn token(self) -> &'static str {
6748        match self {
6749            ShotStyle::Insert => "insert",
6750            ShotStyle::LockedOff => "locked-off",
6751            ShotStyle::PushIn => "push-in",
6752            ShotStyle::PullBackReveal => "pull-back-reveal",
6753            ShotStyle::EstablishingCrane => "establishing-crane",
6754            ShotStyle::OrbitArc => "orbit-arc",
6755            ShotStyle::SideTrack => "side-track",
6756            ShotStyle::TwoShot => "two-shot",
6757            ShotStyle::LowFollow => "low-follow",
6758        }
6759    }
6760
6761    /// Default shot duration in seconds, from the dossier's per-style duration
6762    /// ranges (§2, anchored on the film-editing ASL literature) — the value an
6763    /// omitted `seconds` resolves to.
6764    pub fn default_seconds(self) -> u32 {
6765        match self {
6766            ShotStyle::Insert => 2,
6767            ShotStyle::LockedOff => 6,
6768            ShotStyle::PushIn => 4,
6769            ShotStyle::PullBackReveal => 6,
6770            ShotStyle::EstablishingCrane => 8,
6771            ShotStyle::OrbitArc => 8,
6772            ShotStyle::SideTrack => 8,
6773            ShotStyle::TwoShot => 5,
6774            ShotStyle::LowFollow => 5,
6775        }
6776    }
6777
6778    /// `true` for the styles whose subject must be *moving* on a compiler-known
6779    /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
6780    /// `side-track` and `low-follow` (`DW0349` otherwise).
6781    pub fn needs_moving_subject(self) -> bool {
6782        matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
6783    }
6784}
6785
6786/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
6787/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
6788/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
6789/// **plus one block up** (torso height, so a close shot does not frame feet)
6790/// before `offset` is applied; an `anchor` subject aims at the block centre
6791/// exactly like a [`CameraTarget`].
6792/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
6793/// — serde has no variant-level `deny_unknown_fields`, so an untagged
6794/// enum with inline struct variants silently *ignores* any key it does not
6795/// recognise: `{"npc": …, "ofset": [0,1,0]}` would deserialize happily with the
6796/// offset dropped, and `{"anchor": …, "npc": …}` would quietly match `Anchor`
6797/// and discard the NPC. Lifting each variant into its own type keeps the repo-wide
6798/// deny-unknown rule for both serde and the published JSON Schema
6799/// (`additionalProperties: false`): a mistyped shot subject fails the schema
6800/// instead of rendering a shot pointed somewhere the author never asked for.
6801#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6802#[serde(untagged)]
6803pub enum CameraSubject {
6804    /// A fixed world point: prefab anchor + offset.
6805    Anchor(Mark),
6806    /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
6807    /// group / sequence, else static at its declared (or spawn) anchor.
6808    Npc(NpcSubject),
6809    /// A stage-5 actor — moving if a `move-actor` for it runs in the same
6810    /// effect group / sequence, else static at its declared anchor.
6811    Actor(ActorSubject),
6812}
6813
6814/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
6815#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6816#[serde(deny_unknown_fields)]
6817pub struct NpcSubject {
6818    /// The NPC (stage-2 ref).
6819    pub npc: NpcId,
6820    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
6821    #[serde(default, skip_serializing_if = "is_zero3")]
6822    pub offset: [i32; 3],
6823}
6824
6825/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
6826#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6827#[serde(deny_unknown_fields)]
6828pub struct ActorSubject {
6829    /// The actor (stage-5 `actors` ref).
6830    pub actor: ActorId,
6831    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
6832    #[serde(default, skip_serializing_if = "is_zero3")]
6833    pub offset: [i32; 3],
6834}
6835
6836impl CameraSubject {
6837    /// The subject's integer offset, whichever variant.
6838    pub fn offset(&self) -> [i32; 3] {
6839        match self {
6840            CameraSubject::Anchor(s) => s.offset,
6841            CameraSubject::Npc(s) => s.offset,
6842            CameraSubject::Actor(s) => s.offset,
6843        }
6844    }
6845
6846    /// A short canonical rendering for digests/diagnostics.
6847    pub fn canon(&self) -> String {
6848        let (kind, id, o) = match self {
6849            CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
6850            CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
6851            CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
6852        };
6853        format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
6854    }
6855}
6856
6857/// `Debug` is hand-written because it is a **stable content-key rendering**:
6858/// the compiler's `payload_verb_key` (FNV over a verb's own `{:?}`) names the
6859/// generated `volley_`, `collapse_` and `teleport_` functions from it, so a shot
6860/// that uses none of the v0.6 style fields must render byte-identically to the
6861/// pre-style struct (`seconds` prints its inner value; absent style fields print
6862/// nothing) — otherwise a purely additive schema change would silently churn the
6863/// content key of every payload that carries a cutscene.
6864impl std::fmt::Debug for CameraShot {
6865    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6866        let mut d = f.debug_struct("CameraShot");
6867        d.field("path", &self.path);
6868        match self.seconds {
6869            Some(v) => d.field("seconds", &v),
6870            None => d.field("seconds", &self.seconds),
6871        };
6872        d.field("look_at", &self.look_at);
6873        if self.shot_style.is_some() {
6874            d.field("shot_style", &self.shot_style);
6875        }
6876        if self.subject.is_some() {
6877            d.field("subject", &self.subject);
6878        }
6879        if self.subject_b.is_some() {
6880            d.field("subject_b", &self.subject_b);
6881        }
6882        if self.dist.is_some() {
6883            d.field("dist", &self.dist);
6884        }
6885        if self.degrees.is_some() {
6886            d.field("degrees", &self.degrees);
6887        }
6888        if self.bearing.is_some() {
6889            d.field("bearing", &self.bearing);
6890        }
6891        d.finish()
6892    }
6893}
6894
6895impl CameraShot {
6896    /// The shot's resolved duration in seconds: explicit `seconds`, else the
6897    /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
6898    /// fallback only keeps downstream passes total).
6899    pub fn resolved_seconds(&self) -> u32 {
6900        self.seconds
6901            .or(self.shot_style.map(ShotStyle::default_seconds))
6902            .unwrap_or(1)
6903    }
6904}
6905
6906/// serde `skip_serializing_if` helper: skip a `[0, 0, 0]` offset.
6907fn is_zero3(v: &[i32; 3]) -> bool {
6908    *v == [0, 0, 0]
6909}
6910
6911impl Verb {
6912    /// The kebab-case `type` tag this verb serializes as — the verb a
6913    /// diagnostic should name so the author can find it in the JSON.
6914    pub fn tag(&self) -> &'static str {
6915        match self {
6916            Verb::OpenGate { .. } => "open-gate",
6917            Verb::CloseGate { .. } => "close-gate",
6918            Verb::CampaignComplete { .. } => "campaign-complete",
6919            Verb::GiveItem { .. } => "give-item",
6920            Verb::SetFlag { .. } => "set-flag",
6921            Verb::SetState { .. } => "set-state",
6922            Verb::AddState { .. } => "add-state",
6923            Verb::ClearState { .. } => "clear-state",
6924            Verb::DropStake { .. } => "drop-stake",
6925            Verb::SpawnWave { .. } => "spawn-wave",
6926            Verb::Narrate { .. } => "narrate",
6927            Verb::SetBlock { .. } => "set-block",
6928            Verb::FillRegion { .. } => "fill-region",
6929            Verb::ClearRegion { .. } => "clear-region",
6930            Verb::SetAtmosphere { .. } => "set-atmosphere",
6931            Verb::OpenWay { .. } => "open-way",
6932            Verb::DespawnNpc { .. } => "despawn-npc",
6933            Verb::MoveNpc { .. } => "move-npc",
6934            Verb::Cutscene { .. } => "cutscene",
6935            Verb::SetTime { .. } => "set-time",
6936            Verb::SetWeather { .. } => "set-weather",
6937            Verb::PlaySound { .. } => "play-sound",
6938            Verb::DamagePlayers { .. } => "damage-players",
6939            Verb::SetCheckpoint { .. } => "set-checkpoint",
6940            Verb::Bonfire { .. } => "bonfire",
6941            Verb::BeginStealth { .. } => "begin-stealth",
6942            Verb::EndStealth => "end-stealth",
6943            Verb::SpawnNpc { .. } => "spawn-npc",
6944            Verb::SpawnActor { .. } => "spawn-actor",
6945            Verb::DespawnActor { .. } => "despawn-actor",
6946            Verb::MoveActor { .. } => "move-actor",
6947            Verb::UnleashActor { .. } => "unleash-actor",
6948            Verb::Sequence { .. } => "sequence",
6949            Verb::Volley { .. } => "volley",
6950            Verb::Collapse { .. } => "collapse",
6951            Verb::GiveEffect { .. } => "give-effect",
6952            Verb::ClearEffect { .. } => "clear-effect",
6953            Verb::Teleport { .. } => "teleport",
6954            Verb::Firework { .. } => "firework",
6955            Verb::SpawnAssembly { .. } => "spawn-assembly",
6956            Verb::DespawnAssembly { .. } => "despawn-assembly",
6957            Verb::PlayClip { .. } => "play-clip",
6958            Verb::Particle { .. } => "particle",
6959        }
6960    }
6961
6962    /// **Whether the emitter addresses this verb to players** (spec-0085 §3.3) —
6963    /// whether its emitted commands name the effect's audience selector at all.
6964    ///
6965    /// A verb that answers `false` is a **party fact**: it fires once for the
6966    /// world (a flag, a gate, a block, a region, a wave, an actor, an NPC, a
6967    /// camera, the time, a checkpoint, a stealth beat, a timeline, a teleported
6968    /// volume, a rocket), so the envelope's `audience` and `in` have nothing to
6969    /// narrow and are refused on it (`DW0942`). A `player`-scoped state write
6970    /// answers `false` too: its holder is the acting player by declaration, never
6971    /// the audience.
6972    ///
6973    /// Exhaustive, so a new verb cannot be added without answering it; and
6974    /// `emit`'s own test binds this answer to the emitted bytes in both
6975    /// directions — every verb is emitted under two audiences, and its commands
6976    /// differ exactly when this says `true`.
6977    pub fn addresses_players(&self) -> bool {
6978        match self {
6979            Verb::GiveItem { .. }
6980            | Verb::Narrate { .. }
6981            | Verb::PlaySound { .. }
6982            | Verb::DamagePlayers { .. }
6983            | Verb::GiveEffect { .. }
6984            | Verb::ClearEffect { .. }
6985            | Verb::Particle { .. } => true,
6986            Verb::OpenGate { .. }
6987            | Verb::CloseGate { .. }
6988            | Verb::CampaignComplete { .. }
6989            | Verb::SetFlag { .. }
6990            | Verb::SetState { .. }
6991            | Verb::AddState { .. }
6992            | Verb::ClearState { .. }
6993            | Verb::DropStake { .. }
6994            | Verb::SpawnWave { .. }
6995            | Verb::SetBlock { .. }
6996            | Verb::FillRegion { .. }
6997            | Verb::ClearRegion { .. }
6998            | Verb::OpenWay { .. }
6999            | Verb::DespawnNpc { .. }
7000            | Verb::MoveNpc { .. }
7001            | Verb::Cutscene { .. }
7002            | Verb::SetTime { .. }
7003            | Verb::SetWeather { .. }
7004            | Verb::SetCheckpoint { .. }
7005            | Verb::Bonfire { .. }
7006            | Verb::BeginStealth { .. }
7007            | Verb::EndStealth
7008            | Verb::SpawnActor { .. }
7009            | Verb::DespawnActor { .. }
7010            | Verb::MoveActor { .. }
7011            | Verb::UnleashActor { .. }
7012            | Verb::SpawnNpc { .. }
7013            | Verb::Sequence { .. }
7014            | Verb::Volley { .. }
7015            | Verb::Collapse { .. }
7016            | Verb::Teleport { .. }
7017            | Verb::Firework { .. }
7018            // spec-0080: a biome repaint is a world fact (`fillbiome`).
7019            | Verb::SetAtmosphere { .. }
7020            // spec-0082: an assembly is a world object.
7021            | Verb::SpawnAssembly { .. }
7022            | Verb::DespawnAssembly { .. }
7023            | Verb::PlayClip { .. } => false,
7024        }
7025    }
7026}
7027
7028/// **How a nested effect list is dispatched, relative to the bundle it sits in**
7029/// (spec-0085 §3.2) — the one statement of which command source each nesting
7030/// site runs under, read by `DW0357`/`DW0503` and by the emitter's timeline
7031/// keying alike.
7032#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7033pub enum NestedDispatch {
7034    /// Under the parent's own source: a `sequence` step. A timeline started
7035    /// where there is an acting player carries that player across its
7036    /// `schedule`s by a compiler-owned tag, and a timeline started from the
7037    /// server source has nobody to carry.
7038    Inherit,
7039    /// As one player, whatever the parent was: a `set-checkpoint`'s
7040    /// `on_respawn` (the respawning player) and a `begin-stealth`'s `on_caught`
7041    /// (the spotted player).
7042    Player,
7043    /// From the server command source, whatever the parent was: a
7044    /// `move-npc`/`move-actor` `on_arrive` (the driver's scheduled tick) and a
7045    /// `bonfire`'s `on_rest` (the party-wide rest, dispatched from the tick).
7046    Server,
7047}
7048
7049impl NestedDispatch {
7050    /// Whether a list dispatched this way, inside a bundle that does (or does
7051    /// not) have an acting player, has one.
7052    pub fn has_actor(self, parent_has_actor: bool) -> bool {
7053        match self {
7054            NestedDispatch::Inherit => parent_has_actor,
7055            NestedDispatch::Player => true,
7056            NestedDispatch::Server => false,
7057        }
7058    }
7059}
7060
7061impl QuestEffect {
7062    /// Each nested effect list with how it is dispatched ([`NestedDispatch`]) —
7063    /// the same lists, in the same order, as [`Self::nested_effect_lists`].
7064    pub fn nested_effect_dispatch(&self) -> Vec<(&[QuestEffect], NestedDispatch)> {
7065        match &self.verb {
7066            Verb::Sequence { steps } => steps
7067                .iter()
7068                .map(|s| (s.effects.as_slice(), NestedDispatch::Inherit))
7069                .collect(),
7070            Verb::SetCheckpoint { on_respawn, .. } => {
7071                vec![(on_respawn.as_slice(), NestedDispatch::Player)]
7072            }
7073            Verb::Bonfire { on_rest, .. } => vec![(on_rest.as_slice(), NestedDispatch::Server)],
7074            Verb::BeginStealth { on_caught, .. } => {
7075                vec![(on_caught.as_slice(), NestedDispatch::Player)]
7076            }
7077            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
7078                vec![(on_arrive.as_slice(), NestedDispatch::Server)]
7079            }
7080            _ => Vec::new(),
7081        }
7082    }
7083
7084    /// Whether this effect addresses players at all — [`Verb::addresses_players`].
7085    pub fn addresses_players(&self) -> bool {
7086        self.verb.addresses_players()
7087    }
7088    /// The gate anchor if this is `open-gate`.
7089    pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
7090        match &self.verb {
7091            Verb::OpenGate { anchor, .. } => Some(anchor),
7092            _ => None,
7093        }
7094    }
7095
7096    /// The gate anchor if this is `close-gate` (DSL v0.6).
7097    pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
7098        match &self.verb {
7099            Verb::CloseGate { anchor, .. } => Some(anchor),
7100            _ => None,
7101        }
7102    }
7103
7104    /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
7105    /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
7106    /// the compiler's canonical English seal line.
7107    pub fn close_gate_sealed_hint(&self) -> Option<&str> {
7108        match &self.verb {
7109            Verb::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
7110            _ => None,
7111        }
7112    }
7113
7114    /// The wave id if this is `spawn-wave` (v0.3).
7115    pub fn spawn_wave(&self) -> Option<&WaveId> {
7116        match &self.verb {
7117            Verb::SpawnWave { wave, .. } => Some(wave),
7118            _ => None,
7119        }
7120    }
7121
7122    /// The flag id if this is `set-flag` (v0.3).
7123    pub fn set_flag(&self) -> Option<&FlagId> {
7124        match &self.verb {
7125            Verb::SetFlag { flag, .. } => Some(flag),
7126            _ => None,
7127        }
7128    }
7129
7130    /// The item id if this is `give-item` (v0.3).
7131    pub fn give_item(&self) -> Option<&str> {
7132        match &self.verb {
7133            Verb::GiveItem { item, .. } => Some(item),
7134            _ => None,
7135        }
7136    }
7137
7138    /// `true` if this is a `give-item` carrying a v0.4 display `name`.
7139    pub fn give_item_named(&self) -> bool {
7140        matches!(self.verb, Verb::GiveItem { name: Some(_), .. })
7141    }
7142
7143    /// The declared `carrier` if this is a `give-item` that states one (v0.6,
7144    /// spec-0018). `None` for any other effect **and** for a `give-item` that
7145    /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
7146    /// matters only for the pre-0.6 reserved-field gate.
7147    pub fn give_carrier(&self) -> Option<Carrier> {
7148        match &self.verb {
7149            Verb::GiveItem { carrier, .. } => *carrier,
7150            _ => None,
7151        }
7152    }
7153
7154    /// Does this `give-item` hand a single copy to the acting player (v0.6,
7155    /// spec-0018)? `false` for every other effect and for the party-wide default.
7156    pub fn gives_to_one(&self) -> bool {
7157        matches!(self.give_carrier(), Some(Carrier::One))
7158    }
7159
7160    /// The `set-block` block id if this is a v0.4 `set-block` effect.
7161    pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
7162        match &self.verb {
7163            Verb::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
7164            _ => None,
7165        }
7166    }
7167
7168    /// The NPC id if this is a v0.4 `despawn-npc` effect.
7169    pub fn despawn_npc(&self) -> Option<&NpcId> {
7170        match &self.verb {
7171            Verb::DespawnNpc { npc, .. } => Some(npc),
7172            _ => None,
7173        }
7174    }
7175
7176    /// `(npc, to)` if this is a v0.4 `move-npc` effect.
7177    pub fn move_npc(&self) -> Option<(&NpcId, &Mark)> {
7178        match &self.verb {
7179            Verb::MoveNpc { npc, to, .. } => Some((npc, to)),
7180            _ => None,
7181        }
7182    }
7183
7184    /// The v0.3 effect name if this effect is one introduced in DSL v0.3
7185    /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
7186    ///.
7187    pub fn v03_effect(&self) -> Option<&'static str> {
7188        match &self.verb {
7189            Verb::GiveItem { .. } => Some("give-item"),
7190            Verb::SetFlag { .. } => Some("set-flag"),
7191            Verb::SpawnWave { .. } => Some("spawn-wave"),
7192            Verb::OpenGate { .. }
7193            | Verb::CloseGate { .. }
7194            | Verb::CampaignComplete { .. } => None,
7195            // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
7196            // are not v0.3 verbs.
7197            Verb::Narrate { .. }
7198            | Verb::SetBlock { .. }
7199            | Verb::DespawnNpc { .. }
7200            | Verb::MoveNpc { .. }
7201            | Verb::Cutscene { .. }
7202            | Verb::SetTime { .. }
7203            | Verb::SetWeather { .. }
7204            | Verb::PlaySound { .. }
7205            | Verb::DamagePlayers { .. }
7206            | Verb::SetCheckpoint { .. }
7207            | Verb::Bonfire { .. }
7208            | Verb::BeginStealth { .. }
7209            | Verb::EndStealth
7210            | Verb::SpawnActor { .. }
7211            | Verb::DespawnActor { .. }
7212            | Verb::MoveActor { .. }
7213            | Verb::UnleashActor { .. }
7214            | Verb::SpawnNpc { .. }
7215            | Verb::Sequence { .. }
7216            // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
7217            | Verb::Volley { .. }
7218            | Verb::Collapse { .. }
7219            // spec-0031's state verbs, region writes, status effects and teleport,
7220            // and spec-0032's `drop-stake`, are all v0.10 — they report via
7221            // `v10_effect`.
7222            | Verb::SetState { .. }
7223            | Verb::AddState { .. }
7224            | Verb::ClearState { .. }
7225            | Verb::FillRegion { .. }
7226            | Verb::ClearRegion { .. }
7227            | Verb::SetAtmosphere { .. }
7228            // spec-0042's `open-way` is v0.12 — it reports via `v12_effect`.
7229            | Verb::OpenWay { .. }
7230            | Verb::GiveEffect { .. }
7231            | Verb::ClearEffect { .. }
7232            | Verb::Teleport { .. }
7233            // spec-0068's `firework` is v0.29.
7234            | Verb::Firework { .. }
7235            // spec-0082's assembly verbs.
7236            | Verb::SpawnAssembly { .. }
7237            | Verb::DespawnAssembly { .. }
7238            | Verb::PlayClip { .. }
7239            // spec-0085's `particle`.
7240            | Verb::Particle { .. }
7241            | Verb::DropStake { .. } => None,
7242        }
7243    }
7244
7245    /// The v0.4 effect name if this effect is one introduced in DSL v0.4
7246    /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
7247    /// in v0.4 campaigns.
7248    pub fn v04_effect(&self) -> Option<&'static str> {
7249        match &self.verb {
7250            Verb::Narrate { .. } => Some("narrate"),
7251            Verb::SetBlock { .. } => Some("set-block"),
7252            Verb::DespawnNpc { .. } => Some("despawn-npc"),
7253            Verb::MoveNpc { .. } => Some("move-npc"),
7254            Verb::Cutscene { .. } => Some("cutscene"),
7255            _ => None,
7256        }
7257    }
7258
7259    /// The v0.5 effect name if this effect is one introduced in DSL v0.5
7260    /// (`set-time`/`set-weather`, spec-0010).
7261    pub fn v05_effect(&self) -> Option<&'static str> {
7262        match &self.verb {
7263            Verb::SetTime { .. } => Some("set-time"),
7264            Verb::SetWeather { .. } => Some("set-weather"),
7265            _ => None,
7266        }
7267    }
7268
7269    /// The v0.6 effect name if this effect is one introduced in DSL v0.6
7270    /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
7271    /// `play-sound`, spec-0014; the scripted-actor staging verbs
7272    /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
7273    /// spec-0014). These validate in v0.6 campaigns
7274    /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
7275    /// — see [`QuestEffect::narrate_art`] — not a new effect.)
7276    pub fn v06_effect(&self) -> Option<&'static str> {
7277        match &self.verb {
7278            Verb::CloseGate { .. } => Some("close-gate"),
7279            Verb::SetCheckpoint { .. } => Some("set-checkpoint"),
7280            Verb::Bonfire { .. } => Some("bonfire"),
7281            Verb::BeginStealth { .. } => Some("begin-stealth"),
7282            Verb::EndStealth => Some("end-stealth"),
7283            Verb::PlaySound { .. } => Some("play-sound"),
7284            Verb::DamagePlayers { .. } => Some("damage-players"),
7285            Verb::SpawnActor { .. } => Some("spawn-actor"),
7286            Verb::DespawnActor { .. } => Some("despawn-actor"),
7287            Verb::MoveActor { .. } => Some("move-actor"),
7288            Verb::UnleashActor { .. } => Some("unleash-actor"),
7289            Verb::Sequence { .. } => Some("sequence"),
7290            Verb::SpawnNpc { .. } => Some("spawn-npc"),
7291            // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
7292            Verb::Volley { .. } => Some("volley"),
7293            Verb::Collapse { .. } => Some("collapse"),
7294            _ => None,
7295        }
7296    }
7297
7298    /// The v0.10 effect name if this effect is one introduced in DSL v0.10
7299    /// (`set-state`/`add-state`/`clear-state` and the region writes
7300    /// `fill-region`/`clear-region`, spec-0031). These validate in v0.10
7301    /// campaigns.
7302    pub fn v10_effect(&self) -> Option<&'static str> {
7303        match &self.verb {
7304            Verb::SetState { .. } => Some("set-state"),
7305            Verb::AddState { .. } => Some("add-state"),
7306            Verb::ClearState { .. } => Some("clear-state"),
7307            Verb::FillRegion { .. } => Some("fill-region"),
7308            Verb::ClearRegion { .. } => Some("clear-region"),
7309            Verb::GiveEffect { .. } => Some("give-effect"),
7310            Verb::ClearEffect { .. } => Some("clear-effect"),
7311            Verb::Teleport { .. } => Some("teleport"),
7312            Verb::DropStake { .. } => Some("drop-stake"),
7313            _ => None,
7314        }
7315    }
7316
7317    /// The v0.12 effect name if this effect is one introduced in DSL v0.12
7318    /// (`open-way`, spec-0042).
7319    pub fn v12_effect(&self) -> Option<&'static str> {
7320        match &self.verb {
7321            Verb::OpenWay { .. } => Some("open-way"),
7322            _ => None,
7323        }
7324    }
7325
7326    /// **The way this effect opens** (DSL v0.12, spec-0042): the placed piece and
7327    /// the name of one way that piece's spatial contract exports.
7328    ///
7329    /// The third spelling of the one region write, beside
7330    /// [`QuestEffect::region_write`] (the author's own box) and
7331    /// [`QuestEffect::gate_region_write`] (a gate anchor's box). It answers with a
7332    /// *reference* and never with geometry, because the geometry is not the
7333    /// campaign's to state: the cells, the block and the direction all live in the
7334    /// piece's metadata, and the compiler resolves them there
7335    /// (`compiler::ways`). A region-shaped accessor here would be the second
7336    /// authority this surface exists to avoid.
7337    pub fn way_write(&self) -> Option<(&PrefabId, &str)> {
7338        match &self.verb {
7339            Verb::OpenWay { piece, way, .. } => Some((piece, way.as_str())),
7340            _ => None,
7341        }
7342    }
7343
7344    /// **The one region write**, whichever verb spelled it (DSL v0.10,
7345    /// spec-0031): the box to write and the block to write it with — `None` for
7346    /// the block meaning *clear to air*.
7347    ///
7348    /// This is the accessor the capability belongs to. It answers for
7349    /// `fill-region` / `clear-region`, which name their own box; `open-gate` /
7350    /// `close-gate` are the same operation over a box a prefab gate anchor
7351    /// declares, so they answer through
7352    /// [`QuestEffect::gate_region_write`](Self::gate_region_write) — the anchor
7353    /// is theirs, the *operation* is shared.
7354    pub fn region_write(&self) -> Option<(&StealthZone, Option<&str>)> {
7355        match &self.verb {
7356            Verb::FillRegion { region, block, .. } => Some((region, Some(block.as_str()))),
7357            Verb::ClearRegion { region, .. } => Some((region, None)),
7358            _ => None,
7359        }
7360    }
7361
7362    /// The gate anchor this effect writes, and whether the write **fills** it:
7363    /// `Some((anchor, true))` for `close-gate`, `Some((anchor, false))` for
7364    /// `open-gate`, `None` for everything else.
7365    ///
7366    /// The gate half of [`QuestEffect::region_write`]: same operation, but the box
7367    /// and the fill block come from the prefab's gate anchor rather than from the
7368    /// author. Everything that reasons about runtime region writes reads both
7369    /// accessors and nothing else.
7370    pub fn gate_region_write(&self) -> Option<(&AnchorId, bool)> {
7371        match &self.verb {
7372            Verb::CloseGate { anchor, .. } => Some((anchor, true)),
7373            Verb::OpenGate { anchor, .. } => Some((anchor, false)),
7374            _ => None,
7375        }
7376    }
7377
7378    /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
7379    /// `volley` (spec-0022), with the documented defaults already applied.
7380    pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
7381        match &self.verb {
7382            Verb::Volley {
7383                projectile,
7384                from_anchor,
7385                kill_zone,
7386                salvos,
7387                interval,
7388                ..
7389            } => Some((
7390                projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
7391                from_anchor,
7392                kill_zone,
7393                salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
7394                interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
7395            )),
7396            _ => None,
7397        }
7398    }
7399
7400    /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
7401    /// (spec-0022), with the documented default already applied.
7402    pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
7403        match &self.verb {
7404            Verb::Collapse {
7405                region_anchor,
7406                falling_block,
7407                then_floor,
7408                ..
7409            } => Some((
7410                region_anchor,
7411                falling_block
7412                    .as_deref()
7413                    .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
7414                then_floor.as_deref(),
7415            )),
7416            _ => None,
7417        }
7418    }
7419
7420    /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
7421    /// [`QuestEffect::despawn_npc`]).
7422    pub fn spawn_npc(&self) -> Option<&NpcId> {
7423        match &self.verb {
7424            Verb::SpawnNpc { npc, .. } => Some(npc),
7425            _ => None,
7426        }
7427    }
7428
7429    /// **The body this effect puts into the world**, by id, for every body class
7430    /// alike ([`BodyRef`]).
7431    ///
7432    /// `spawn-npc` and `spawn-actor` are the two, and they are answered in one
7433    /// place so a rule about a body's lifetime quantifies over bodies rather
7434    /// than over the verb that first needed it. A body's OTHER entry — standing
7435    /// on its mark from world init — is not an effect at all and is
7436    /// [`BodyRef::at_world_init`].
7437    ///
7438    /// `unleash-actor` is deliberately not an entry: it puts no new body on the
7439    /// mark, it replaces the one already standing there (see [`Self::body_exit`]).
7440    pub fn body_entry(&self) -> Option<&str> {
7441        match &self.verb {
7442            Verb::SpawnNpc { npc, .. } => Some(npc.as_str()),
7443            Verb::SpawnActor { actor, .. } => Some(actor.as_str()),
7444            _ => None,
7445        }
7446    }
7447
7448    /// **The body this effect takes out of the world**, by id, for every body
7449    /// class alike.
7450    ///
7451    /// `despawn-npc` and `despawn-actor` remove the body outright.
7452    /// `unleash-actor` is the third: it kills the staged puppet and stands a
7453    /// real-AI twin in its place, and from that moment the compiler makes no
7454    /// claim about where that body is or whether it is still alive — the twin
7455    /// walks, fights and dies under vanilla AI. Answering all three here is what
7456    /// keeps "can this body still be standing?" from being decided one verb at a
7457    /// time.
7458    ///
7459    /// Deliberately NOT an exit: `move-npc` / `move-actor`. A walked body is
7460    /// still in the world, and its declared mark is still the cell the engine
7461    /// summoned it onto — a mark two live bodies share is shared whether or not
7462    /// one of them has since walked off it.
7463    pub fn body_exit(&self) -> Option<&str> {
7464        match &self.verb {
7465            Verb::DespawnNpc { npc, .. } => Some(npc.as_str()),
7466            Verb::DespawnActor { actor, .. } | Verb::UnleashActor { actor, .. } => {
7467                Some(actor.as_str())
7468            }
7469            _ => None,
7470        }
7471    }
7472
7473    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
7474    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
7475        match &self.verb {
7476            Verb::SetCheckpoint { anchor, on_respawn } => Some((anchor, on_respawn.as_slice())),
7477            _ => None,
7478        }
7479    }
7480
7481    /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
7482    pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
7483        match &self.verb {
7484            Verb::Bonfire {
7485                anchor, on_rest, ..
7486            } => Some((anchor, on_rest.as_slice())),
7487            _ => None,
7488        }
7489    }
7490
7491    /// The bonfire's authored rest-dialog strings, each `None` when unauthored
7492    /// (the compiler then bakes its canonical English). `None` for every other
7493    /// effect (spec-0016 §1).
7494    pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
7495        match &self.verb {
7496            Verb::Bonfire {
7497                prompt,
7498                rest_label,
7499                save_label,
7500                rest_tooltip,
7501                save_tooltip,
7502                ..
7503            } => Some(BonfireLabels {
7504                prompt: prompt.as_deref(),
7505                rest_label: rest_label.as_deref(),
7506                save_label: save_label.as_deref(),
7507                rest_tooltip: rest_tooltip.as_deref(),
7508                save_tooltip: save_tooltip.as_deref(),
7509            }),
7510            _ => None,
7511        }
7512    }
7513
7514    /// The `within` filter zone if this is a v0.6 `damage-players` effect that
7515    /// declares one (the `in` spatial scope). `None` for an unscoped
7516    /// `damage-players` and for every other effect.
7517    pub fn damage_within(&self) -> Option<&StealthZone> {
7518        match &self.verb {
7519            Verb::DamagePlayers { .. } => self.within.as_ref(),
7520            _ => None,
7521        }
7522    }
7523
7524    /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
7525    pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
7526        match &self.verb {
7527            Verb::BeginStealth {
7528                zones,
7529                on_caught,
7530                grace_ticks,
7531            } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
7532            _ => None,
7533        }
7534    }
7535
7536    /// The target time if this is a v0.5 `set-time` effect.
7537    pub fn set_time(&self) -> Option<WorldTime> {
7538        match &self.verb {
7539            Verb::SetTime { time, .. } => Some(*time),
7540            _ => None,
7541        }
7542    }
7543
7544    /// The target weather if this is a v0.5 `set-weather` effect.
7545    pub fn set_weather(&self) -> Option<WorldWeather> {
7546        match &self.verb {
7547            Verb::SetWeather { weather, .. } => Some(*weather),
7548            _ => None,
7549        }
7550    }
7551
7552    /// The effect lists nested one level inside this effect (DSL v0.6): a
7553    /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
7554    /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
7555    /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
7556    ///
7557    /// This is the **single authority** on effect nesting. Every deep traversal —
7558    /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
7559    /// string inventory, and emission — walks the tree through it (see
7560    /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
7561    /// once and no walker can silently miss a list (the class of bug where a
7562    /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
7563    pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
7564        match &self.verb {
7565            Verb::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
7566            Verb::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
7567            Verb::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
7568            Verb::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
7569            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
7570                vec![on_arrive.as_slice()]
7571            }
7572            _ => Vec::new(),
7573        }
7574    }
7575
7576    /// Visit `self` and every transitively nested effect (depth-first, pre-order),
7577    /// descending through [`Self::nested_effect_lists`].
7578    pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
7579        f(self);
7580        for list in self.nested_effect_lists() {
7581            for e in list {
7582                e.visit_deep(f);
7583            }
7584        }
7585    }
7586
7587    /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
7588    /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
7589    /// exposed mutably so the localization pass can rewrite nested player-visible
7590    /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
7591    /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
7592    /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
7593    /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
7594    /// derived segments make every derived key deterministic and stable across
7595    /// builds (ADR-0006 byte-identity).
7596    pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
7597        match &mut self.verb {
7598            Verb::Sequence { steps } => steps
7599                .iter_mut()
7600                .enumerate()
7601                .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
7602                .collect(),
7603            Verb::SetCheckpoint { on_respawn, .. } => {
7604                vec![("respawn".to_string(), on_respawn.as_mut_slice())]
7605            }
7606            Verb::Bonfire { on_rest, .. } => {
7607                vec![("rest".to_string(), on_rest.as_mut_slice())]
7608            }
7609            Verb::BeginStealth { on_caught, .. } => {
7610                vec![("caught".to_string(), on_caught.as_mut_slice())]
7611            }
7612            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
7613                vec![("arrive".to_string(), on_arrive.as_mut_slice())]
7614            }
7615            _ => Vec::new(),
7616        }
7617    }
7618
7619    /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
7620    /// exposes the **JSON-pointer path segment** for each nested list, so a deep
7621    /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
7622    /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
7623    /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
7624    /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
7625    /// (same lists, same order): the l10n key segments match
7626    /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
7627    /// `arrive`), and the path segments name the real fields
7628    /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
7629    pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
7630        match &self.verb {
7631            Verb::Sequence { steps } => steps
7632                .iter()
7633                .enumerate()
7634                .map(|(s, st)| {
7635                    (
7636                        format!("steps/{s}/effects"),
7637                        format!("seq.{s}"),
7638                        st.effects.as_slice(),
7639                    )
7640                })
7641                .collect(),
7642            Verb::SetCheckpoint { on_respawn, .. } => vec![(
7643                "on_respawn".to_string(),
7644                "respawn".to_string(),
7645                on_respawn.as_slice(),
7646            )],
7647            Verb::Bonfire { on_rest, .. } => vec![(
7648                "on_rest".to_string(),
7649                "rest".to_string(),
7650                on_rest.as_slice(),
7651            )],
7652            Verb::BeginStealth { on_caught, .. } => vec![(
7653                "on_caught".to_string(),
7654                "caught".to_string(),
7655                on_caught.as_slice(),
7656            )],
7657            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
7658                vec![(
7659                    "on_arrive".to_string(),
7660                    "arrive".to_string(),
7661                    on_arrive.as_slice(),
7662                )]
7663            }
7664            _ => Vec::new(),
7665        }
7666    }
7667
7668    /// Every world anchor this effect names **at this node** — the single
7669    /// authority on the anchor-bearing effect surface, the referential sibling of
7670    /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
7671    /// where the suffix is appended to the effect's own JSON pointer
7672    /// (`anchor`, `to/anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
7673    /// `shots/<i>/path/<j>/anchor`, …).
7674    ///
7675    /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
7676    /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
7677    /// reference scan, the compiler's build-time resolution seal (`DW0360`) —
7678    /// goes through here, so a new anchor-bearing variant (or a new anchor field
7679    /// on an existing one) is picked up by all of them at once. That closes the
7680    /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
7681    /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
7682    /// found no match) while every shallow validator looked straight past it.
7683    ///
7684    /// # The third element is the SHAPE the site demands
7685    ///
7686    /// A reference is not only a name: `close-gate` fills and clears a region and
7687    /// `bonfire` seats a body, and until spec-0052 those two sat in one match arm
7688    /// with nothing recording the difference. The demand rides with the reference
7689    /// so that a new anchor-bearing variant cannot be added without stating what
7690    /// it does with the anchor — the same reason this is one authority and not
7691    /// three walks.
7692    ///
7693    /// **A demand is stated only where the shape is structurally required**, and
7694    /// the line is drawn where this engine's own `DW0845` already draws it — *a
7695    /// region is not a place to stand*:
7696    ///
7697    /// * [`StationKind::Gate`] where the verb cannot function without a region
7698    ///   that seals and clears: the two gate verbs. A point is not a gate, and
7699    ///   `gate_region_block_any` finds nothing for one.
7700    /// * [`StationKind::Point`] where a **body is put**: a checkpoint seat, a
7701    ///   bonfire, a `move-npc`/`move-actor` destination, a `teleport`
7702    ///   destination. A body cannot stand inside bars.
7703    /// * `None` wherever a reference merely names a **location** — every
7704    ///   anchor-centred volume's centre, every camera field, a `set-block`, a
7705    ///   `play-sound`. A gate region's own corner is a perfectly good answer
7706    ///   there, and refusing it would refuse correct content.
7707    ///
7708    /// That last case is not a gap left for later. An earlier draft of this rule
7709    /// demanded a point at every non-gate site, and the gallery refused to
7710    /// compile: `trigger/east-door-wrong-side` is a `use` trigger sitting on the
7711    /// very `anchor/seam-…` of a barred door so that it can say "the east door
7712    /// does not open from this side". A check that resolves against a smaller
7713    /// world than the campaign has refuses CONTENT, which is the lesson `DW0343`
7714    /// carries three files away.
7715    pub fn anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
7716        // The envelope's `in` box (spec-0085 §3.2) is one capability of every
7717        // player-facing verb, so it registers once, here, before the verb's own.
7718        let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = Vec::new();
7719        if let Some(zone) = &self.within {
7720            out.push(("in/anchor".to_string(), &zone.anchor, None));
7721        }
7722        out.extend(self.verb_anchor_refs());
7723        out
7724    }
7725
7726    /// The anchors the VERB names at this node — [`Self::anchor_refs`] without the
7727    /// envelope.
7728    fn verb_anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
7729        // Every camera field is a point: a shot flies through cells and looks at
7730        // one.
7731        /// `(suffix, anchor, kind)` for a shot's own anchor-bearing fields, under `base`.
7732        fn shot_refs<'a>(
7733            base: &str,
7734            shot: &'a CameraShot,
7735        ) -> Vec<(String, &'a AnchorId, Option<StationKind>)> {
7736            let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shot
7737                .path
7738                .iter()
7739                .enumerate()
7740                .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor, None))
7741                .collect();
7742            if let Some(t) = &shot.look_at {
7743                out.push((format!("{base}look_at/anchor"), &t.anchor, None));
7744            }
7745            for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
7746                if let Some(CameraSubject::Anchor(s)) = subject {
7747                    out.push((format!("{base}{field}/anchor"), &s.anchor, None));
7748                }
7749            }
7750            out
7751        }
7752        match &self.verb {
7753            // The two gate verbs address a REGION that seals and clears; the
7754            // three below them seat or write at a cell. They shared an arm until
7755            // the demand had somewhere to be written down.
7756            Verb::OpenGate { anchor, .. } | Verb::CloseGate { anchor, .. } => {
7757                vec![("anchor".to_string(), anchor, Some(StationKind::Gate))]
7758            }
7759            // A checkpoint and a bonfire are **respawn seats** — a body is put
7760            // there, so a region is not one. `set-block` writes a block at a
7761            // cell, which names a location and seats nothing.
7762            Verb::SetCheckpoint { anchor, .. } | Verb::Bonfire { anchor, .. } => {
7763                vec![("anchor".to_string(), anchor, Some(StationKind::Point))]
7764            }
7765            Verb::SetBlock { anchor, .. } => {
7766                vec![("anchor".to_string(), anchor, None)]
7767            }
7768            Verb::MoveNpc { to, .. } | Verb::MoveActor { to, .. } => {
7769                vec![(
7770                    "to/anchor".to_string(),
7771                    &to.anchor,
7772                    Some(StationKind::Point),
7773                )]
7774            }
7775            // Both of a `teleport`'s anchors are load-bearing — the source volume
7776            // decides WHAT moves and the destination decides WHERE — so a typo in
7777            // either is a dangling reference (`DW0142`), never a silently
7778            // zero-cell volume or a dropped command.
7779            Verb::Teleport { from, to, .. } => vec![
7780                ("from/anchor".to_string(), &from.anchor, None),
7781                (
7782                    "to/anchor".to_string(),
7783                    &to.anchor,
7784                    Some(StationKind::Point),
7785                ),
7786            ],
7787            Verb::BeginStealth { zones, .. } => zones
7788                .iter()
7789                .enumerate()
7790                .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor, None))
7791                .collect(),
7792            Verb::PlaySound {
7793                at: Some(SoundAt::Anchor { anchor, .. }),
7794                ..
7795            } => vec![("at/anchor".to_string(), anchor, None)],
7796            // A firework is launched from a point and seats nothing, so it names
7797            // a location in the same shape `play-sound` does.
7798            Verb::Firework { at, .. } => vec![("at/anchor".to_string(), &at.anchor, None)],
7799            // A particle at a mark names a location the same way; at `players`
7800            // it names none.
7801            Verb::Particle {
7802                at: ParticleAt::Mark(m),
7803                ..
7804            } => vec![("at/anchor".to_string(), &m.anchor, None)],
7805            // spec-0022 trap-payload verbs. Both anchors of a `volley` are
7806            // load-bearing for the coverage proof, so both register here — a
7807            // typo'd `kill_zone` must be a dangling-reference error, never a
7808            // silently zero-cell (and therefore vacuously "covered") volley.
7809            Verb::Volley {
7810                from_anchor,
7811                kill_zone,
7812                ..
7813            } => vec![
7814                ("from_anchor".to_string(), from_anchor, None),
7815                ("kill_zone/anchor".to_string(), &kill_zone.anchor, None),
7816            ],
7817            Verb::Collapse { region_anchor, .. } => {
7818                vec![(
7819                    "region_anchor/anchor".to_string(),
7820                    &region_anchor.anchor,
7821                    None,
7822                )]
7823            }
7824            // The v0.10 region writes: the box's anchor is load-bearing for both
7825            // the emission and the completability model, so a typo'd one must be a
7826            // dangling-reference error (`DW0142`/`DW0360`), never a silently
7827            // unwritten — and therefore vacuously proven — region.
7828            Verb::FillRegion { region, .. } | Verb::ClearRegion { region, .. } => {
7829                vec![("region/anchor".to_string(), &region.anchor, None)]
7830            }
7831            // A repaint's box centre names a location, exactly as a region
7832            // write's does.
7833            Verb::SetAtmosphere {
7834                region: Some(region),
7835                ..
7836            } => vec![("region/anchor".to_string(), &region.anchor, None)],
7837            // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
7838            // multi-shot list, or the v0.4 single-shot fields flattened at the
7839            // effect's own level.
7840            Verb::Cutscene {
7841                shots,
7842                path,
7843                look_at,
7844                ..
7845            } => {
7846                let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shots
7847                    .iter()
7848                    .enumerate()
7849                    .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
7850                    .collect();
7851                out.extend(
7852                    path.iter()
7853                        .enumerate()
7854                        .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor, None)),
7855                );
7856                if let Some(t) = look_at {
7857                    out.push(("look_at/anchor".to_string(), &t.anchor, None));
7858                }
7859                out
7860            }
7861            _ => Vec::new(),
7862        }
7863    }
7864
7865    /// **Every object of a subject kind this effect names at this node** — the
7866    /// `npc/`, `actor/`, `wave/` and `anchor/` ids [`Happening::subject`] may
7867    /// name, in a fixed order, deduplicated (spec-0071 §3).
7868    ///
7869    /// The bodies come from [`Self::body_entry`] / [`Self::body_exit`] and the
7870    /// move verbs; the anchors come from [`Self::anchor_refs`], the single
7871    /// authority on the anchor-bearing surface, so a verb that gains an anchor
7872    /// gains it here too. Not recursive: a `sequence`'s steps each answer for
7873    /// themselves.
7874    ///
7875    /// This is a question about the **object class** a beat can be about, not
7876    /// about a list of verbs somebody maintains — which is why `unleash-actor`
7877    /// (one actor), `set-block` (one anchor) and `fill-region` (one anchor)
7878    /// answer it without being named anywhere, and why `move-actor` (an actor
7879    /// AND a destination anchor) and `teleport` (two anchors) answer with two.
7880    pub fn subject_objects(&self) -> Vec<&str> {
7881        fn add<'a>(id: &'a str, out: &mut Vec<&'a str>) {
7882            if !out.contains(&id) {
7883                out.push(id);
7884            }
7885        }
7886        let mut out: Vec<&str> = Vec::new();
7887        match &self.verb {
7888            Verb::SpawnNpc { npc, .. }
7889            | Verb::DespawnNpc { npc, .. }
7890            | Verb::MoveNpc { npc, .. } => add(npc.as_str(), &mut out),
7891            Verb::SpawnWave { wave, .. } => add(wave.as_str(), &mut out),
7892            _ => {}
7893        }
7894        if let Some(actor) = self.actor_ref() {
7895            add(actor.as_str(), &mut out);
7896        }
7897        for (_, anchor, _) in self.anchor_refs() {
7898            add(anchor.as_str(), &mut out);
7899        }
7900        out
7901    }
7902
7903    /// **What this effect's `happening` is about**: the subject the branch
7904    /// chronicle records and the contradiction proof (`DW0485`) reasons over
7905    /// (spec-0071 §3).
7906    ///
7907    /// A stated [`Happening::subject`] always wins — the caller knows more. An
7908    /// absent one resolves to the effect's own object when it has exactly one
7909    /// ([`Self::subject_objects`]), because the beat that opens a gate is about
7910    /// that gate and the id is otherwise typed twice, two keys apart. An effect
7911    /// with several objects, or none, resolves nothing: naming one of them would
7912    /// be the compiler guessing which.
7913    ///
7914    /// **The one derivation.** The namespace check (`dsl::validate`) and the
7915    /// chronicle writer (`delvec::compiler::branch`) both read the subject
7916    /// through here, so a beat cannot be about one thing for the proof and
7917    /// another for the account a reader is handed.
7918    pub fn happening_subject(&self) -> Option<HappeningSubject<'_>> {
7919        let h = self.happening.as_ref()?;
7920        if let Some(stated) = h.subject.as_deref() {
7921            return Some(HappeningSubject {
7922                id: stated,
7923                derived: false,
7924            });
7925        }
7926        let objects = self.subject_objects();
7927        match objects.as_slice() {
7928            [only] => Some(HappeningSubject {
7929                id: only,
7930                derived: true,
7931            }),
7932            _ => None,
7933        }
7934    }
7935
7936    /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
7937    /// the v0.6 `look_at` field.
7938    pub fn cutscene_look_at(&self) -> Option<&Mark> {
7939        match &self.verb {
7940            Verb::Cutscene { look_at, .. } => look_at.as_ref(),
7941            _ => None,
7942        }
7943    }
7944
7945    /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
7946    ///.
7947    pub fn cutscene_multi_shot(&self) -> bool {
7948        matches!(&self.verb, Verb::Cutscene { shots, .. } if !shots.is_empty())
7949    }
7950
7951    /// The normalized shot list of a `cutscene`, whichever spelling was used: the
7952    /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
7953    /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
7954    /// whose shape is invalid (`DW0199` reports that).
7955    pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
7956        match &self.verb {
7957            Verb::Cutscene {
7958                shots,
7959                path,
7960                seconds,
7961                look_at,
7962                ..
7963            } => {
7964                if !shots.is_empty() {
7965                    return Some(shots.clone());
7966                }
7967                match seconds {
7968                    Some(secs) => Some(vec![CameraShot {
7969                        path: path.clone(),
7970                        seconds: Some(*secs),
7971                        look_at: look_at.clone(),
7972                        shot_style: None,
7973                        subject: None,
7974                        subject_b: None,
7975                        dist: None,
7976                        degrees: None,
7977                        bearing: None,
7978                    }]),
7979                    None => Some(Vec::new()),
7980                }
7981            }
7982            _ => None,
7983        }
7984    }
7985
7986    /// `true` if this is a `narrate` carrying the `art` style (glyph-checked
7987    /// `DW0328`).
7988    pub fn narrate_art(&self) -> bool {
7989        matches!(
7990            &self.verb,
7991            Verb::Narrate {
7992                style: Some(NarrateStyle::Art),
7993                ..
7994            }
7995        )
7996    }
7997
7998    /// The `narrate` line's text if this is a `narrate` with the `art` style.
7999    pub fn narrate_art_text(&self) -> Option<&str> {
8000        match &self.verb {
8001            Verb::Narrate {
8002                text,
8003                style: Some(NarrateStyle::Art),
8004                ..
8005            } => Some(text.as_str()),
8006            _ => None,
8007        }
8008    }
8009
8010    /// The `narrate` line's style and text if this is a `narrate` rendered **on
8011    /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
8012    /// styles vanilla draws centred and unwrapped, so their rendered width is
8013    /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
8014    /// is exempt.
8015    pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
8016        match &self.verb {
8017            Verb::Narrate {
8018                text,
8019                style: Some(s),
8020                ..
8021            } if matches!(
8022                s,
8023                NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
8024            ) =>
8025            {
8026                Some((*s, text.as_str()))
8027            }
8028            _ => None,
8029        }
8030    }
8031
8032    /// Every vanilla sound-event id this effect references, for registry
8033    /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
8034    /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
8035    /// within the effect (e.g. `sound`).
8036    pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
8037        match &self.verb {
8038            Verb::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
8039            Verb::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
8040            _ => Vec::new(),
8041        }
8042    }
8043
8044    /// The `play-sound` `at: actor` id, if this effect is a `play-sound`
8045    /// targeting an actor (rejected `DW0335`).
8046    pub fn play_sound_actor(&self) -> Option<&str> {
8047        match &self.verb {
8048            Verb::PlaySound {
8049                at: Some(SoundAt::Actor { actor }),
8050                ..
8051            } => Some(actor.as_str()),
8052            _ => None,
8053        }
8054    }
8055
8056    /// The per-effect flag gate: flags that must ALL be set (per party) for this
8057    /// effect to fire. Empty for an ungated effect. Read off the one [`Guard`], so
8058    /// **every** verb answers it — the staging and souls vocabulary included.
8059    pub fn requires_flags(&self) -> &[FlagId] {
8060        self.when.as_ref().map_or(&[][..], |g| &g.requires_flags)
8061    }
8062
8063    /// The per-effect **negative** flag gate: flags whose being set suppresses this
8064    /// effect — the dual of [`QuestEffect::requires_flags`], on the same guard.
8065    pub fn forbids_flags(&self) -> &[FlagId] {
8066        self.when.as_ref().map_or(&[][..], |g| &g.forbids_flags)
8067    }
8068
8069    /// The numeric gate terms (spec-0031) — the third axis of the same guard, so
8070    /// "which verbs are gatable" has exactly one answer.
8071    pub fn requires_state(&self) -> &[StateCompare] {
8072        self.when.as_ref().map_or(&[][..], |g| &g.requires_state)
8073    }
8074
8075    /// The datum this effect writes and how, if it is one of the DSL v0.10 state
8076    /// verbs (`set-state` / `add-state` / `clear-state`).
8077    pub fn writes_state(&self) -> Option<(&StateId, StateWrite)> {
8078        match &self.verb {
8079            Verb::SetState { state, value, .. } => Some((state, StateWrite::Set(*value))),
8080            Verb::AddState { state, amount, .. } => Some((state, StateWrite::Add(*amount))),
8081            Verb::ClearState { state, .. } => Some((state, StateWrite::Clear)),
8082            _ => None,
8083        }
8084    }
8085
8086    /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
8087    /// parity with `move-actor`). `None` for a bare `move-npc` and every other
8088    /// effect.
8089    pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
8090        match &self.verb {
8091            Verb::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => Some(on_arrive.as_slice()),
8092            _ => None,
8093        }
8094    }
8095
8096    /// The actor id this effect targets, if it is one of the actor staging effects
8097    /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
8098    /// no single actor (its nested effects each carry their own).
8099    pub fn actor_ref(&self) -> Option<&ActorId> {
8100        match &self.verb {
8101            Verb::SpawnActor { actor, .. }
8102            | Verb::DespawnActor { actor, .. }
8103            | Verb::MoveActor { actor, .. }
8104            | Verb::UnleashActor { actor, .. } => Some(actor),
8105            _ => None,
8106        }
8107    }
8108
8109    /// Every vanilla **status-effect** id this effect names, for registry
8110    /// validation (`DW0192`) — the sibling of [`Self::sound_refs`] and the single
8111    /// authority on the status-effect-bearing verb surface. `(subpath, id)`
8112    /// pairs; empty for a `clear-effect` that names none (which clears all).
8113    pub fn status_effect_refs(&self) -> Vec<(&'static str, &str)> {
8114        match &self.verb {
8115            Verb::GiveEffect { effect, .. } => vec![("effect", effect.as_str())],
8116            Verb::ClearEffect {
8117                effect: Some(e), ..
8118            } => vec![("effect", e.as_str())],
8119            _ => Vec::new(),
8120        }
8121    }
8122
8123    /// `(effect, seconds, amplifier, hide_particles, in)` if this is a
8124    /// `give-effect` (DSL v0.10), with the documented defaults already applied.
8125    pub fn give_effect(&self) -> Option<(&str, u32, u32, bool, Option<&StealthZone>)> {
8126        match &self.verb {
8127            Verb::GiveEffect {
8128                effect,
8129                seconds,
8130                amplifier,
8131                hide_particles,
8132                ..
8133            } => Some((
8134                effect.as_str(),
8135                *seconds,
8136                amplifier.unwrap_or(0),
8137                hide_particles.unwrap_or(false),
8138                self.within.as_ref(),
8139            )),
8140            _ => None,
8141        }
8142    }
8143
8144    /// `(effect, in)` if this is a `clear-effect` (DSL v0.10). The effect is
8145    /// `None` for the clear-everything form, exactly as vanilla spells it.
8146    pub fn clear_effect(&self) -> Option<(Option<&str>, Option<&StealthZone>)> {
8147        match &self.verb {
8148            Verb::ClearEffect { effect, .. } => Some((effect.as_deref(), self.within.as_ref())),
8149            _ => None,
8150        }
8151    }
8152
8153    /// `(from, to)` if this is a `teleport` (DSL v0.10): the source volume and
8154    /// the destination mark.
8155    pub fn teleport(&self) -> Option<(&StealthZone, &Mark)> {
8156        match &self.verb {
8157            Verb::Teleport { from, to, .. } => Some((from, to)),
8158            _ => None,
8159        }
8160    }
8161}
8162
8163// ---------------------------------------------------------------------------
8164// Stage 7 — world-edits (the map editor edit script, DSL v0.6, spec-0017)
8165// ---------------------------------------------------------------------------
8166
8167/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
8168///
8169/// The artifact of record for L3 world detailing: an ordered list of edit
8170/// batches the compiler replays deterministically **after** world assembly.
8171/// The world files are never truth — same DSL + same edits + same seed →
8172/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
8173/// builds byte-identically to one from before this stage existed.
8174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8175#[serde(deny_unknown_fields)]
8176pub struct WorldEditsContent {
8177    /// Ordered edit batches, replayed in order. After every batch the post-edit
8178    /// invariants re-prove (walkability, sealing + relight, boundary safety),
8179    /// so each batch is a valid, snapshot-reviewable world state.
8180    pub batches: Vec<EditBatch>,
8181}
8182
8183/// One edit batch: an ordered group of edit verbs applied to a single area,
8184/// checked and snapshot-rendered as a unit.
8185#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8186#[serde(deny_unknown_fields)]
8187pub struct EditBatch {
8188    /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
8189    /// snapshot name and seed-stream label — renaming a batch deliberately
8190    /// reseeds its noise.
8191    pub id: EditBatchId,
8192    /// The stage-1 area this batch edits. Frames and regions resolve against
8193    /// this area's placed pieces and anchors.
8194    pub area: AreaId,
8195    /// Authoring context (why this batch exists). Machine-ignored; **excluded**
8196    /// from l10n like `theme`/`premise`.
8197    #[serde(default, skip_serializing_if = "Option::is_none")]
8198    pub note: Option<String>,
8199    /// Ordered edit verbs. `select` defines named regions; later verbs in the
8200    /// same batch refer back to them (strictly backward, like every DSL ref).
8201    pub edits: Vec<WorldEdit>,
8202}
8203
8204/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
8205/// seeded verb derives its noise stream from the campaign seed + its script
8206/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
8207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8208#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
8209pub enum WorldEdit {
8210    /// Define a named region (`region/<kebab>`) for later verbs in this batch.
8211    Select {
8212        /// The region name being defined (unique within the batch).
8213        name: RegionId,
8214        /// The region's shape (box, surface band, palette match, or a
8215        /// composition of earlier regions).
8216        shape: RegionShape,
8217    },
8218    /// Fill every cell of a region from a seeded palette recipe (value-noise
8219    /// keyed, so picks cluster into patches — never a uniform fill).
8220    Fill {
8221        /// The region (an earlier `select` in this batch) to fill.
8222        region: RegionId,
8223        /// The palette recipe to fill with.
8224        recipe: PaletteRecipe,
8225    },
8226    /// Like `fill`, but only rewrites cells whose current block matches one of
8227    /// `matching` (base ids; blockstate suffixes are ignored when matching).
8228    Replace {
8229        /// The region (an earlier `select` in this batch) to edit.
8230        region: RegionId,
8231        /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
8232        matching: Vec<String>,
8233        /// The palette recipe to rewrite them with.
8234        recipe: PaletteRecipe,
8235    },
8236    /// Clear a region to air. Sealing-aware: the carved region re-enters the
8237    /// sealing + relight passes and every walkability invariant re-proves.
8238    Carve {
8239        /// The region (an earlier `select` in this batch) to clear.
8240        region: RegionId,
8241    },
8242    /// Reshape terrain surface within a region (raise / lower / smooth).
8243    Morph {
8244        /// The region (an earlier `select` in this batch) whose columns to
8245        /// reshape. The region defines the footprint and where each column's
8246        /// surface is read (the highest occupied cell in the region's y-range);
8247        /// `raise`/`smooth` may add cells above the region's top — reshaping
8248        /// upward is the point — while removal only touches region cells.
8249        region: RegionId,
8250        /// The surface operation.
8251        op: MorphOp,
8252    },
8253    /// Seeded dressing scatter (spec-0017): drop weighted single-block
8254    /// dressing (flora, rocks, props) onto standable cells of a region —
8255    /// air cells with an occupied cell directly below — honoring keep-clear
8256    /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
8257    /// speckle, not the fill verbs' clustered patches), deterministic from the
8258    /// campaign seed + script position.
8259    Scatter {
8260        /// The region (an earlier `select` in this batch) to dress.
8261        region: RegionId,
8262        /// Weighted dressing blocks (blockstate suffixes allowed).
8263        items: Vec<PaletteBlock>,
8264        /// Per-candidate placement probability in `(0, 1]`.
8265        density: f64,
8266        /// Keep-clear envelopes: earlier regions whose cells (and columns —
8267        /// matched by `(x, z)`) never receive dressing.
8268        #[serde(default, skip_serializing_if = "Vec::is_empty")]
8269        avoid: Vec<RegionId>,
8270        /// Optional minimum spacing: when set, candidates are taken in
8271        /// descending noise order and one is rejected while another accepted
8272        /// candidate is closer than this on **both** horizontal axes (the
8273        /// generators' spread rule).
8274        #[serde(default, skip_serializing_if = "Option::is_none")]
8275        spacing: Option<u32>,
8276        /// Optional cap on how many items are placed (highest-noise first).
8277        #[serde(default, skip_serializing_if = "Option::is_none")]
8278        limit: Option<u32>,
8279    },
8280    /// Structural flora (spec-0017): plant hand-shaped trees via the
8281    /// lean-or-grow canopy rules — a canopy that would reach a
8282    /// keep-clear (`avoid`) column first leans one block away from it; if that
8283    /// still covers the corridor the tree grows tall instead, arching its
8284    /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
8285    Plant {
8286        /// The region (an earlier `select` in this batch) to plant in. Trunk
8287        /// cells are standable region cells (air over an occupied cell).
8288        region: RegionId,
8289        /// The tree species (canopy shape rules are per-species).
8290        tree: TreeKind,
8291        /// How many trees to plant (≥ 1; highest-noise candidates first).
8292        count: u32,
8293        /// Keep-clear envelopes: trunks never stand in these columns and
8294        /// canopies lean/grow to clear them.
8295        #[serde(default, skip_serializing_if = "Vec::is_empty")]
8296        avoid: Vec<RegionId>,
8297        /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
8298        #[serde(default, skip_serializing_if = "Option::is_none")]
8299        spacing: Option<u32>,
8300    },
8301    /// Stamp a prefab fragment (spec-0017): copy a library prefab's
8302    /// non-air cells into the world at a frame-resolved position. The fragment
8303    /// is a first-class library prefab — its provenance/license metadata is
8304    /// recorded and validated exactly like any placed prefab (ADR-0013);
8305    /// nothing outside the library can be stamped.
8306    Fragment {
8307        /// The library prefab to stamp.
8308        prefab: PrefabId,
8309        /// The frame `at` resolves in.
8310        frame: EditFrame,
8311        /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
8312        at: [i32; 3],
8313        /// Placement rotation (default `none`), the same quarter-turn set as
8314        /// `/place template`.
8315        #[serde(default, skip_serializing_if = "Option::is_none")]
8316        rotation: Option<FragmentRotation>,
8317    },
8318    /// Explicit region relight (spec-0017, spec-0010 machinery): run the
8319    /// deterministic fixture-placement pass over ONE region and bake the
8320    /// resulting fixtures into the edit script's writes — authorial control of
8321    /// where fixtures land, instead of the whole-area pass's greedy siting.
8322    /// (The whole-area relight still re-proves after every batch either way.)
8323    Relight {
8324        /// The region (an earlier `select` in this batch) to relight: its
8325        /// reachable walkable cells are brought to `min_light`.
8326        region: RegionId,
8327        /// Fixture override; default = the area's declared `lighting.fixture`.
8328        #[serde(default, skip_serializing_if = "Option::is_none")]
8329        fixture: Option<Fixture>,
8330        /// Target light override (1..=14); default = the area's declared
8331        /// `lighting.min_light`. Required (with `fixture`) when the area
8332        /// declares no `lighting`.
8333        #[serde(default, skip_serializing_if = "Option::is_none")]
8334        min_light: Option<u8>,
8335    },
8336    /// L2 massing (spec-0017): replace a placed piece with another
8337    /// library prefab that re-mates every currently-mated socket at its exact
8338    /// world pose (any rotation; overlap-checked). Applied at **plan** time —
8339    /// the whole downstream ladder (anchors, gate reachability, assembly,
8340    /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
8341    /// verbs live in massing-only batches, ordered before every detailing
8342    /// batch.
8343    SwapPiece {
8344        /// The piece's placement index in its area's solved layout (0-based,
8345        /// entry first).
8346        piece: u32,
8347        /// The prefab the indexed piece must currently be (drift guard).
8348        prefab: PrefabId,
8349        /// The library prefab to swap in.
8350        with: PrefabId,
8351    },
8352    /// L2 massing (spec-0017): attach a new piece at a specific **open**
8353    /// (unmated) socket of an existing piece — the targeted form of the
8354    /// solver's frontier attach. The socket opens (its seal becomes a
8355    /// passage); the new piece's other sockets seal.
8356    InsertPiece {
8357        /// The host piece's placement index.
8358        at_piece: u32,
8359        /// The prefab the host piece must currently be (drift guard).
8360        prefab: PrefabId,
8361        /// The host's connector index (prefab metadata `connectors` order).
8362        socket: u32,
8363        /// The library prefab to attach.
8364        insert: PrefabId,
8365    },
8366    /// L2 massing (spec-0017): remove a **leaf** piece (exactly one
8367    /// mated socket; never the entry piece). The neighbour's socket unmates
8368    /// and re-seals. Removal shifts later placement indices — order removals
8369    /// before other index-referencing massing verbs.
8370    RemovePiece {
8371        /// The piece's placement index.
8372        piece: u32,
8373        /// The prefab the indexed piece must currently be (drift guard).
8374        prefab: PrefabId,
8375    },
8376    /// L2 massing (spec-0017): override one socket's seal — `open`
8377    /// clears the opening to a passage, `sealed` walls it up — independent of
8378    /// its mated state (sealing a mated doorway makes a wall between joined
8379    /// pieces; opening an unmated exterior socket exposes the outside, which
8380    /// the boundary-safety proof then judges).
8381    RewireSocket {
8382        /// The piece's placement index.
8383        piece: u32,
8384        /// The prefab the indexed piece must currently be (drift guard).
8385        prefab: PrefabId,
8386        /// The connector index (prefab metadata `connectors` order).
8387        socket: u32,
8388        /// The socket's new state.
8389        state: SocketState,
8390    },
8391    /// L2 massing (spec-0017): re-pick this piece from its area pool's
8392    /// compatible members (weighted, seeded from the campaign seed + this
8393    /// verb's script position — moving the verb deliberately re-rolls). The
8394    /// current prefab is excluded, so a reseed always changes the piece or
8395    /// errors loudly.
8396    ReseedPiece {
8397        /// The piece's placement index.
8398        piece: u32,
8399        /// The prefab the indexed piece must currently be (drift guard).
8400        prefab: PrefabId,
8401    },
8402}
8403
8404/// A socket seal state for `rewire-socket` (spec-0017).
8405#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8406#[serde(rename_all = "kebab-case")]
8407pub enum SocketState {
8408    /// The opening is cleared to a passage.
8409    Open,
8410    /// The opening is walled up.
8411    Sealed,
8412}
8413
8414/// A tree species for the `plant` verb (spec-0017). One species per
8415/// canopy-rule implementation; the shipped rule set is the lean-or-grow oak.
8416#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8417#[serde(rename_all = "kebab-case")]
8418pub enum TreeKind {
8419    /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
8420    /// lean-or-grow corridor rules.
8421    Oak,
8422}
8423
8424/// A `fragment` stamp rotation (spec-0017) — the `/place template`
8425/// quarter-turn set.
8426#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8427#[serde(rename_all = "kebab-case")]
8428pub enum FragmentRotation {
8429    /// No rotation.
8430    None,
8431    /// 90° clockwise.
8432    Clockwise90,
8433    /// 180°.
8434    Clockwise180,
8435    /// 90° counterclockwise.
8436    Counterclockwise90,
8437}
8438
8439/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
8440/// frame; compositions combine earlier regions of the same batch.
8441#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8442#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8443pub enum RegionShape {
8444    /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
8445    Box {
8446        /// The coordinate frame `min`/`max` resolve in.
8447        frame: EditFrame,
8448        /// Inclusive minimum corner (frame coordinates).
8449        min: [i32; 3],
8450        /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
8451        max: [i32; 3],
8452    },
8453    /// The band of cells at `from..=to` blocks relative to each column's
8454    /// terrain surface (the highest non-air cell of the column) within an
8455    /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
8456    /// surface; `from: 0, to: 0` is the surface cells themselves; negative
8457    /// offsets reach below the surface.
8458    SurfaceBand {
8459        /// The earlier region whose columns are scanned.
8460        over: RegionId,
8461        /// Inclusive band start, relative to each column's surface y.
8462        from: i32,
8463        /// Inclusive band end (≥ `from`), relative to each column's surface y.
8464        to: i32,
8465    },
8466    /// The cells of an earlier region whose current block matches one of
8467    /// `blocks` (base ids; blockstate suffixes ignored when matching).
8468    PaletteMatch {
8469        /// The earlier region to filter.
8470        within: RegionId,
8471        /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
8472        blocks: Vec<String>,
8473    },
8474    /// The union of earlier regions.
8475    Union {
8476        /// The earlier regions to unite (≥ 2).
8477        of: Vec<RegionId>,
8478    },
8479    /// The intersection of earlier regions.
8480    Intersect {
8481        /// The earlier regions to intersect (≥ 2).
8482        of: Vec<RegionId>,
8483    },
8484    /// An earlier region minus other earlier regions.
8485    Subtract {
8486        /// The earlier region to start from.
8487        base: RegionId,
8488        /// The earlier regions to remove from it (≥ 1).
8489        remove: Vec<RegionId>,
8490    },
8491}
8492
8493/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
8494/// piece-local or anchor-relative — never raw world coordinates, so an edit
8495/// script survives a layout's world placement moving.
8496#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8497#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8498pub enum EditFrame {
8499    /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
8500    /// the piece's structure origin, axes as authored (the compiler applies
8501    /// the piece's placed rotation).
8502    PieceLocal {
8503        /// The piece's placement index in the area's solved layout (0-based,
8504        /// entry piece first — the order `delvec snapshot`'s manifest lists).
8505        piece: u32,
8506        /// The prefab the indexed piece must be (a drift guard: if a re-solve
8507        /// changed the layout, the mismatch is a loud compile error, never a
8508        /// silently misplaced edit).
8509        prefab: PrefabId,
8510    },
8511    /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
8512    /// anchor cell, axes world-aligned.
8513    AnchorRelative {
8514        /// The anchor (prefab metadata, resolved by the compiler).
8515        anchor: AnchorId,
8516    },
8517}
8518
8519/// A surface operation for the `morph` verb (spec-0017).
8520#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8521#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8522pub enum MorphOp {
8523    /// Raise each column's surface by up to `by` blocks, drawing the added
8524    /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
8525    /// as natural strata, not an extruded slab).
8526    Raise {
8527        /// How many blocks to raise each column (≥ 1).
8528        by: u32,
8529        /// The palette recipe for the added cells.
8530        recipe: PaletteRecipe,
8531    },
8532    /// Lower each column's surface by up to `by` blocks (carving the topmost
8533    /// solid cells to air).
8534    Lower {
8535        /// How many blocks to lower each column (≥ 1).
8536        by: u32,
8537    },
8538    /// Relax each column's surface toward the mean of its cardinal neighbours
8539    /// (one block per pass), turning steps into slopes. Added cells draw from
8540    /// `recipe`; removed cells carve to air. Deterministic double-buffered
8541    /// passes in fixed scan order.
8542    Smooth {
8543        /// Relaxation passes (≥ 1).
8544        passes: u32,
8545        /// The palette recipe for cells a pass adds.
8546        recipe: PaletteRecipe,
8547    },
8548}
8549
8550/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
8551/// smooth value-noise sample, the island/cave generators' proven primitive —
8552/// picks cluster into strata/patches instead of per-cell speckle, and a
8553/// single-entry recipe is the degenerate (discouraged) uniform case.
8554#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8555#[serde(deny_unknown_fields)]
8556pub struct PaletteRecipe {
8557    /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
8558    pub blocks: Vec<PaletteBlock>,
8559    /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
8560    /// across). Larger = smaller patches. Must be finite and > 0.
8561    #[serde(default, skip_serializing_if = "Option::is_none")]
8562    pub scale: Option<f64>,
8563}
8564
8565/// One weighted entry of a [`PaletteRecipe`].
8566#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8567#[serde(deny_unknown_fields)]
8568pub struct PaletteBlock {
8569    /// Vanilla block id (validated against the pinned 1.21.11 registry), with
8570    /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
8571    pub block: String,
8572    /// Relative weight (finite, > 0).
8573    pub weight: f64,
8574}
8575
8576/// Where in the campaign one effect sits — the attribution every per-branch
8577/// proof and the branch chronicle need (spec-0025).
8578#[derive(Clone, Debug, PartialEq, Eq)]
8579pub enum EffectSite {
8580    /// A quest's `on_objective_complete[<objective>]` bundle.
8581    Objective {
8582        /// The owning quest.
8583        quest: String,
8584        /// The objective whose completion fires the bundle.
8585        objective: String,
8586    },
8587    /// A quest's `on_complete` bundle.
8588    QuestComplete {
8589        /// The owning quest.
8590        quest: String,
8591    },
8592    /// An environment trigger's `effects` bundle — ambient, no DAG position.
8593    Trigger {
8594        /// The trigger id.
8595        trigger: String,
8596    },
8597    /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
8598    Trap {
8599        /// The trap id.
8600        trap: String,
8601    },
8602    /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
8603    /// DAG position, and the only site that does not live in the quests stage.
8604    ///
8605    /// This variant did not exist until the effect-root sweep, and its absence was
8606    /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
8607    /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
8608    /// (`combat::actor_beats`, `validate::difficulty_checks`,
8609    /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
8610    /// even if their authors had thought of it. Widening the type is what let the
8611    /// walk widen.
8612    DialogueRespawn {
8613        /// The NPC whose dialogue tree hosts the option.
8614        npc: String,
8615        /// The node the option sits under.
8616        node: String,
8617    },
8618    /// A `shortcuts[].on_unlock` bundle (spec-0016 §2) — ambient, no DAG
8619    /// position, and the sixth root: representable here only since spec-0031, for
8620    /// exactly the reason [`EffectSite::DialogueRespawn`] records above.
8621    ShortcutUnlock {
8622        /// The shortcut id.
8623        shortcut: String,
8624    },
8625    /// A `shops[].offers[].effects` bundle (DSL v0.10, spec-0032) — ambient, no
8626    /// DAG position: nobody is forced to buy anything.
8627    ShopOffer {
8628        /// The shop id.
8629        shop: String,
8630        /// The offer's index within that shop, which is also its button order.
8631        offer: usize,
8632    },
8633    /// The campaign's `on_death` bundle (spec-0031) — ambient, no DAG position,
8634    /// and no owning object: there is one per campaign.
8635    OnDeath,
8636    /// A wave's or an actor's `on_kill` bundle (spec-0074) — ambient, no DAG
8637    /// position: nobody is forced to be credited with a kill.
8638    OnKill {
8639        /// The fight's id (`wave/<kebab>` or `actor/<kebab>`).
8640        fight: String,
8641    },
8642    /// An assembly strike step's `on_land` bundle (spec-0082) — ambient, no
8643    /// DAG position: nobody is forced to stand where a blow lands.
8644    AssemblyLand {
8645        /// The assembly id.
8646        assembly: String,
8647        /// The step's index within the pattern.
8648        step: usize,
8649    },
8650    /// A loop's `on_cross` bundle (spec-0086) — no DAG position of its own: it
8651    /// runs when a body crosses the holding slab.
8652    LoopCross {
8653        /// The loop id (`loop/<kebab>`).
8654        r#loop: String,
8655    },
8656}
8657
8658impl EffectSite {
8659    /// The quest this site belongs to, if it has a DAG position at all.
8660    ///
8661    /// **This `Option` is the capability, and it is on the enum rather than on the
8662    /// variants that happen to have a quest.** Only two of the eight sites name a
8663    /// quest, because only two of the eight roots HAVE a DAG position: an ambient
8664    /// root — a trigger, a trap payload, a dialogue `on_respawn`, a shortcut's
8665    /// `on_unlock`, the campaign's `on_death`, a shop offer — fires at a moment no
8666    /// static model can order, and inventing a quest for one would be exactly the
8667    /// over-attribution the completability model must not make. Asking the question
8668    /// of every variant and getting an honest `None` is the lift;
8669    /// `tools/ci/check-capability-ownership.py` check D asked for it while the field
8670    /// was still cross-cutting, and spec-0032's eighth site took it below that
8671    /// threshold, so the reasoning lives here now rather than in an exemption.
8672    pub fn quest(&self) -> Option<&str> {
8673        match self {
8674            EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
8675                Some(quest)
8676            }
8677            EffectSite::Trigger { .. }
8678            | EffectSite::Trap { .. }
8679            | EffectSite::DialogueRespawn { .. }
8680            | EffectSite::ShortcutUnlock { .. }
8681            | EffectSite::ShopOffer { .. }
8682            | EffectSite::OnDeath
8683            | EffectSite::OnKill { .. }
8684            | EffectSite::AssemblyLand { .. }
8685            | EffectSite::LoopCross { .. } => None,
8686        }
8687    }
8688}
8689
8690/// Visit **every** effect the compiler can lower — at every one of the five
8691/// effect roots, top-level and transitively nested — in a fixed deterministic
8692/// order, invoking `f(json_pointer, site, effect)`.
8693///
8694/// The roots come from [`crate::effects::for_each_effect_root`], the single
8695/// enumeration; nesting is descended through the single
8696/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
8697/// enumerated here, which is the point: this walk used to hand-list four of the
8698/// five roots (it had no `EffectSite` variant for the fifth), so every proof
8699/// defined in terms of it inherited that blind spot.
8700pub fn for_each_campaign_effect<'a>(
8701    c: &'a crate::envelope::Campaign,
8702    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
8703) {
8704    crate::effects::for_each_effect_root(c, &mut |root, list| {
8705        let site = match root.owner {
8706            crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
8707                EffectSite::Objective {
8708                    quest: quest.id.as_str().to_string(),
8709                    objective: objective.to_string(),
8710                }
8711            }
8712            crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
8713                quest: quest.id.as_str().to_string(),
8714            },
8715            crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
8716                trigger: t.id.as_str().to_string(),
8717            },
8718            crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
8719                trap: t.id.as_str().to_string(),
8720            },
8721            crate::effects::EffectRootOwner::DialogueRespawn => {
8722                // The npc and node are in the root's path; parse them back rather
8723                // than widening the root walk's owner for one consumer.
8724                let seg = |n: usize| -> String {
8725                    root.path.split('/').nth(n).unwrap_or_default().to_string()
8726                };
8727                EffectSite::DialogueRespawn {
8728                    npc: seg(3),
8729                    node: seg(5),
8730                }
8731            }
8732            crate::effects::EffectRootOwner::ShortcutUnlock(s) => EffectSite::ShortcutUnlock {
8733                shortcut: s.id.as_str().to_string(),
8734            },
8735            crate::effects::EffectRootOwner::OnDeath => EffectSite::OnDeath,
8736            crate::effects::EffectRootOwner::ShopOffer(h) => EffectSite::ShopOffer {
8737                shop: h.id.as_str().to_string(),
8738                // The offer index is in the root's path
8739                // (`/content/shops/<h>/offers/<i>/effects`), parsed back rather
8740                // than widening the owner for one consumer — the same call the
8741                // dialogue arm above makes. Segment 5 is the index: segment 4 is
8742                // the literal `offers`, which parses as nothing and reported
8743                // every offer as the shop's first.
8744                offer: root
8745                    .path
8746                    .split('/')
8747                    .nth(5)
8748                    .and_then(|n| n.parse().ok())
8749                    .unwrap_or(0),
8750            },
8751            crate::effects::EffectRootOwner::OnKill(f) => EffectSite::OnKill {
8752                fight: f.id().to_string(),
8753            },
8754            crate::effects::EffectRootOwner::AssemblyLand(m) => EffectSite::AssemblyLand {
8755                assembly: m.id.as_str().to_string(),
8756                // `/content/assemblies/<m>/strikes/pattern/<s>/on_land`: segment
8757                // 6 is the step index, parsed back as the shop arm does.
8758                step: root
8759                    .path
8760                    .split('/')
8761                    .nth(6)
8762                    .and_then(|n| n.parse().ok())
8763                    .unwrap_or(0),
8764            },
8765            crate::effects::EffectRootOwner::LoopCross(l) => EffectSite::LoopCross {
8766                r#loop: l.id.as_str().to_string(),
8767            },
8768        };
8769        for (i, eff) in list.iter().enumerate() {
8770            campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
8771        }
8772    });
8773}
8774
8775fn campaign_effect_deep<'a>(
8776    eff: &'a QuestEffect,
8777    path: &str,
8778    site: &EffectSite,
8779    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
8780) {
8781    f(path, site, eff);
8782    for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
8783        for (j, inner) in list.iter().enumerate() {
8784            campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
8785        }
8786    }
8787}
8788
8789// ---------------------------------------------------------------------------
8790// The spine authority (`QuestPlanContent::spine`)
8791// ---------------------------------------------------------------------------
8792
8793#[cfg(test)]
8794mod spine_tests {
8795    use super::QuestPlanContent;
8796
8797    /// Build a plan from `(id, deps)` pairs plus a finale. JSON rather than a
8798    /// struct literal on purpose: a field added to `PlannedQuest` later must not
8799    /// red these tests for a reason that has nothing to do with the spine.
8800    fn plan(finale: &str, quests: &[(&str, &[&str])]) -> QuestPlanContent {
8801        let quests: Vec<serde_json::Value> = quests
8802            .iter()
8803            .map(|(id, deps)| {
8804                serde_json::json!({
8805                    "id": id,
8806                    "goal": "g",
8807                    "area": "area/keep",
8808                    "npcs": [],
8809                    "depends_on": deps,
8810                    "mandatory": true,
8811                    "act": 1,
8812                })
8813            })
8814            .collect();
8815        serde_json::from_value(serde_json::json!({
8816            "finale": finale,
8817            "quests": quests,
8818        }))
8819        .expect("plan fixture parses")
8820    }
8821
8822    fn sorted(p: &QuestPlanContent) -> Vec<String> {
8823        p.spine().into_iter().map(str::to_owned).collect()
8824    }
8825
8826    #[test]
8827    fn a_chain_is_wholly_spine() {
8828        let p = plan(
8829            "quest/c",
8830            &[
8831                ("quest/a", &[]),
8832                ("quest/b", &["quest/a"]),
8833                ("quest/c", &["quest/b"]),
8834            ],
8835        );
8836        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c"]);
8837    }
8838
8839    #[test]
8840    fn a_quest_the_finale_does_not_depend_on_is_off_the_spine() {
8841        // Exactly the `DW0132` shape: the plan does not converge, and the spine
8842        // is the half that does. The authority answers, it does not refuse — the
8843        // refusal is `validate`'s, built on this answer.
8844        let p = plan("quest/end", &[("quest/end", &[]), ("quest/side-trip", &[])]);
8845        assert_eq!(sorted(&p), ["quest/end"]);
8846    }
8847
8848    #[test]
8849    fn a_diamond_counts_the_join_once() {
8850        let p = plan(
8851            "quest/d",
8852            &[
8853                ("quest/a", &[]),
8854                ("quest/b", &["quest/a"]),
8855                ("quest/c", &["quest/a"]),
8856                ("quest/d", &["quest/b", "quest/c"]),
8857            ],
8858        );
8859        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c", "quest/d"]);
8860    }
8861
8862    #[test]
8863    fn a_cycle_terminates_and_yields_a_set() {
8864        // `DW0130` refuses this plan, but the authority is asked before that
8865        // verdict is known (the layout binding prints on an erroring campaign),
8866        // so it must terminate rather than hang.
8867        let p = plan(
8868            "quest/b",
8869            &[("quest/a", &["quest/b"]), ("quest/b", &["quest/a"])],
8870        );
8871        assert_eq!(sorted(&p), ["quest/a", "quest/b"]);
8872    }
8873
8874    #[test]
8875    fn a_dangling_dependency_is_reported_and_expands_no_further() {
8876        // The deliberate difference between the two derivations this function
8877        // replaced. `validate` pruned undeclared ids before walking; the
8878        // authority does not, because pruning them would make the set disagree
8879        // with the document, and naming an id the plan does not declare is
8880        // `DW0112`'s finding rather than the spine's.
8881        let p = plan("quest/end", &[("quest/end", &["quest/ghost"])]);
8882        assert_eq!(sorted(&p), ["quest/end", "quest/ghost"]);
8883    }
8884
8885    #[test]
8886    fn an_undeclared_finale_is_the_whole_spine() {
8887        // `DW0131`'s shape. The answer is honest about the document: nothing the
8888        // plan declares is on the spine of a finale it never declared.
8889        let p = plan(
8890            "quest/ghost",
8891            &[("quest/a", &[]), ("quest/b", &["quest/a"])],
8892        );
8893        assert_eq!(sorted(&p), ["quest/ghost"]);
8894    }
8895}
8896
8897// ---------------------------------------------------------------------------
8898// The derived happening subject (spec-0071 §3)
8899// ---------------------------------------------------------------------------
8900
8901#[cfg(test)]
8902mod happening_subject_tests {
8903    use super::QuestEffect;
8904
8905    /// An effect with a `happening` that states no subject.
8906    fn beat(verb: serde_json::Value) -> QuestEffect {
8907        let mut v = verb;
8908        v["happening"] = serde_json::json!({ "verb": "opens", "text": "It gives." });
8909        serde_json::from_value(v).expect("effect fixture parses")
8910    }
8911
8912    fn subject(v: serde_json::Value) -> Option<String> {
8913        beat(v).happening_subject().map(|s| {
8914            assert!(s.derived, "the fixture states no subject");
8915            s.id.to_string()
8916        })
8917    }
8918
8919    /// **The census, over every verb the DSL declares.** Which verbs resolve a
8920    /// subject is a fact about the objects an effect names, so the answer is
8921    /// computed here rather than listed anywhere: the table below is held to the
8922    /// variant set of the exported schema, and a thirty-ninth verb reds this
8923    /// test until somebody answers for it.
8924    ///
8925    /// Run with `--nocapture` to print the census a document quotes.
8926    #[test]
8927    fn every_verb_answers_the_census() {
8928        // One minimal instance per verb, in the schema's own order.
8929        let table: Vec<(&str, serde_json::Value, Option<&str>)> = vec![
8930            (
8931                "open-gate",
8932                serde_json::json!({"type":"open-gate","anchor":"anchor/door"}),
8933                Some("anchor/door"),
8934            ),
8935            (
8936                "close-gate",
8937                serde_json::json!({"type":"close-gate","anchor":"anchor/door"}),
8938                Some("anchor/door"),
8939            ),
8940            (
8941                "campaign-complete",
8942                serde_json::json!({"type":"campaign-complete"}),
8943                None,
8944            ),
8945            (
8946                "give-item",
8947                serde_json::json!({"type":"give-item","item":"minecraft:bread","count":1}),
8948                None,
8949            ),
8950            (
8951                "set-flag",
8952                serde_json::json!({"type":"set-flag","flag":"flag/heard"}),
8953                None,
8954            ),
8955            (
8956                "set-state",
8957                serde_json::json!({"type":"set-state","state":"state/purse","value":1}),
8958                None,
8959            ),
8960            (
8961                "add-state",
8962                serde_json::json!({"type":"add-state","state":"state/purse","amount":-1}),
8963                None,
8964            ),
8965            (
8966                "drop-stake",
8967                serde_json::json!({"type":"drop-stake","stake":"stake/purse"}),
8968                None,
8969            ),
8970            (
8971                "clear-state",
8972                serde_json::json!({"type":"clear-state","state":"state/purse"}),
8973                None,
8974            ),
8975            (
8976                "spawn-wave",
8977                serde_json::json!({"type":"spawn-wave","wave":"wave/muster"}),
8978                Some("wave/muster"),
8979            ),
8980            (
8981                "narrate",
8982                serde_json::json!({"type":"narrate","text":"The hall answers."}),
8983                None,
8984            ),
8985            (
8986                "set-block",
8987                serde_json::json!({"type":"set-block","anchor":"anchor/altar","block":"minecraft:stone"}),
8988                Some("anchor/altar"),
8989            ),
8990            (
8991                "fill-region",
8992                serde_json::json!({"type":"fill-region","region":{"anchor":"anchor/pit","extent":[2,2,2]},"block":"minecraft:water"}),
8993                Some("anchor/pit"),
8994            ),
8995            (
8996                "set-atmosphere",
8997                serde_json::json!({"type":"set-atmosphere","atmosphere":"atmosphere/wrong","region":{"anchor":"anchor/hall","extent":[4,2,4]}}),
8998                Some("anchor/hall"),
8999            ),
9000            (
9001                "clear-region",
9002                serde_json::json!({"type":"clear-region","region":{"anchor":"anchor/pit","extent":[2,2,2]}}),
9003                Some("anchor/pit"),
9004            ),
9005            (
9006                "open-way",
9007                serde_json::json!({"type":"open-way","piece":"prefab/span","way":"gap"}),
9008                None,
9009            ),
9010            (
9011                "despawn-npc",
9012                serde_json::json!({"type":"despawn-npc","npc":"npc/keeper"}),
9013                Some("npc/keeper"),
9014            ),
9015            (
9016                "move-npc",
9017                serde_json::json!({"type":"move-npc","npc":"npc/keeper","to":{"anchor":"anchor/door"}}),
9018                None,
9019            ),
9020            (
9021                "cutscene",
9022                serde_json::json!({"type":"cutscene","path":[{"anchor":"anchor/hall"},{"anchor":"anchor/door"}],"seconds":4}),
9023                None,
9024            ),
9025            (
9026                "set-time",
9027                serde_json::json!({"type":"set-time","time":"noon"}),
9028                None,
9029            ),
9030            (
9031                "set-weather",
9032                serde_json::json!({"type":"set-weather","weather":"clear"}),
9033                None,
9034            ),
9035            (
9036                "play-sound",
9037                serde_json::json!({"type":"play-sound","sound":"minecraft:block.bell.use","at":{"at":"anchor","anchor":"anchor/bell"}}),
9038                Some("anchor/bell"),
9039            ),
9040            (
9041                "damage-players",
9042                serde_json::json!({"type":"damage-players","amount":2}),
9043                None,
9044            ),
9045            (
9046                "set-checkpoint",
9047                serde_json::json!({"type":"set-checkpoint","anchor":"anchor/seat"}),
9048                Some("anchor/seat"),
9049            ),
9050            (
9051                "bonfire",
9052                serde_json::json!({"type":"bonfire","anchor":"anchor/seat"}),
9053                Some("anchor/seat"),
9054            ),
9055            (
9056                "begin-stealth",
9057                serde_json::json!({"type":"begin-stealth","zones":[{"anchor":"anchor/yard","extent":[4,2,4]}]}),
9058                Some("anchor/yard"),
9059            ),
9060            (
9061                "end-stealth",
9062                serde_json::json!({"type":"end-stealth"}),
9063                None,
9064            ),
9065            (
9066                "spawn-npc",
9067                serde_json::json!({"type":"spawn-npc","npc":"npc/keeper"}),
9068                Some("npc/keeper"),
9069            ),
9070            (
9071                "spawn-actor",
9072                serde_json::json!({"type":"spawn-actor","actor":"actor/giant"}),
9073                Some("actor/giant"),
9074            ),
9075            (
9076                "despawn-actor",
9077                serde_json::json!({"type":"despawn-actor","actor":"actor/giant","style":"vanish"}),
9078                Some("actor/giant"),
9079            ),
9080            (
9081                "move-actor",
9082                serde_json::json!({"type":"move-actor","actor":"actor/giant","to":{"anchor":"anchor/door"}}),
9083                None,
9084            ),
9085            (
9086                "unleash-actor",
9087                serde_json::json!({"type":"unleash-actor","actor":"actor/giant"}),
9088                Some("actor/giant"),
9089            ),
9090            (
9091                "sequence",
9092                serde_json::json!({"type":"sequence","steps":[]}),
9093                None,
9094            ),
9095            (
9096                "volley",
9097                serde_json::json!({"type":"volley","from_anchor":"anchor/slot","kill_zone":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9098                None,
9099            ),
9100            (
9101                "collapse",
9102                serde_json::json!({"type":"collapse","region_anchor":{"anchor":"anchor/roof","extent":[3,1,3]}}),
9103                Some("anchor/roof"),
9104            ),
9105            (
9106                "give-effect",
9107                serde_json::json!({"type":"give-effect","effect":"minecraft:speed","seconds":10}),
9108                None,
9109            ),
9110            (
9111                "clear-effect",
9112                serde_json::json!({"type":"clear-effect"}),
9113                None,
9114            ),
9115            (
9116                "teleport",
9117                serde_json::json!({"type":"teleport","from":{"anchor":"anchor/hall","extent":[4,2,4]},"to":{"anchor":"anchor/door"}}),
9118                None,
9119            ),
9120            (
9121                "firework",
9122                serde_json::json!({"type":"firework","at":{"anchor":"anchor/court"},"explosions":[{"shape":"star","colors":["#ffd700"]}]}),
9123                Some("anchor/court"),
9124            ),
9125            // spec-0082: an assembly is not a story subject (no `happening` is
9126            // demanded of its verbs), so none of the three resolves one.
9127            (
9128                "spawn-assembly",
9129                serde_json::json!({"type":"spawn-assembly","assembly":"assembly/limb"}),
9130                None,
9131            ),
9132            (
9133                "despawn-assembly",
9134                serde_json::json!({"type":"despawn-assembly","assembly":"assembly/limb"}),
9135                None,
9136            ),
9137            (
9138                "play-clip",
9139                serde_json::json!({"type":"play-clip","assembly":"assembly/limb","clip":"idle"}),
9140                None,
9141            ),
9142            (
9143                "particle",
9144                serde_json::json!({"type":"particle","particle":"minecraft:soul","at":{"anchor":"anchor/well"}}),
9145                Some("anchor/well"),
9146            ),
9147        ];
9148        // The binding: the table answers for every verb the schema declares, and
9149        // for no name the schema does not.
9150        let schema = crate::schema::stage_schema(crate::envelope::Stage::Quests);
9151        let declared: std::collections::BTreeSet<String> = schema["$defs"]["QuestEffect"]["oneOf"]
9152            .as_array()
9153            .expect("QuestEffect is a tagged union")
9154            .iter()
9155            .map(|b| {
9156                b["properties"]["type"]["const"]
9157                    .as_str()
9158                    .expect("a tag")
9159                    .to_string()
9160            })
9161            .collect();
9162        let answered: std::collections::BTreeSet<String> =
9163            table.iter().map(|(t, _, _)| (*t).to_string()).collect();
9164        assert_eq!(
9165            answered, declared,
9166            "the census answers for exactly the verbs the DSL declares"
9167        );
9168        for (tag, effect, want) in &table {
9169            let got = subject(effect.clone());
9170            assert_eq!(
9171                got.as_deref(),
9172                *want,
9173                "`{tag}` resolves {want:?}; it names {:?}",
9174                beat(effect.clone()).subject_objects()
9175            );
9176            println!(
9177                "| `{tag}` | {} | {} |",
9178                want.unwrap_or("—"),
9179                beat(effect.clone()).subject_objects().join(", ")
9180            );
9181        }
9182    }
9183
9184    /// **Some verbs answer per instance, not per verb**, because the object is
9185    /// in an optional field: an `in` filter, a second stealth zone, a camera
9186    /// path of one waypoint. The rule is over the objects an effect names, so
9187    /// the same verb resolves in one document and not in another — and that is
9188    /// the answer, not a gap in it.
9189    #[test]
9190    fn a_verb_whose_object_is_optional_answers_per_instance() {
9191        for (what, effect, want) in [
9192            (
9193                "damage-players with an `in` filter",
9194                serde_json::json!({"type":"damage-players","amount":2,"in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9195                Some("anchor/lane"),
9196            ),
9197            (
9198                "give-effect with an `in` filter",
9199                serde_json::json!({"type":"give-effect","effect":"minecraft:speed","seconds":10,"in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9200                Some("anchor/lane"),
9201            ),
9202            (
9203                "clear-effect with an `in` filter",
9204                serde_json::json!({"type":"clear-effect","in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9205                Some("anchor/lane"),
9206            ),
9207            (
9208                "play-sound with no `at`",
9209                serde_json::json!({"type":"play-sound","sound":"minecraft:block.bell.use"}),
9210                None,
9211            ),
9212            (
9213                "begin-stealth over two zones",
9214                serde_json::json!({"type":"begin-stealth","zones":[
9215                    {"anchor":"anchor/yard","extent":[4,2,4]},
9216                    {"anchor":"anchor/lane","extent":[4,2,4]}]}),
9217                None,
9218            ),
9219            (
9220                "a cutscene whose path names one cell",
9221                serde_json::json!({"type":"cutscene","path":[{"anchor":"anchor/hall"}],"seconds":4}),
9222                Some("anchor/hall"),
9223            ),
9224        ] {
9225            assert_eq!(subject(effect.clone()).as_deref(), want, "{what}");
9226        }
9227    }
9228
9229    /// The four the spec illustrates, beside the ones the rule covers without
9230    /// naming them.
9231    #[test]
9232    fn an_effect_with_one_object_is_about_that_object() {
9233        for (effect, want) in [
9234            (
9235                serde_json::json!({ "type": "open-gate", "anchor": "anchor/door" }),
9236                "anchor/door",
9237            ),
9238            (
9239                serde_json::json!({ "type": "close-gate", "anchor": "anchor/door" }),
9240                "anchor/door",
9241            ),
9242            (
9243                serde_json::json!({ "type": "spawn-actor", "actor": "actor/giant" }),
9244                "actor/giant",
9245            ),
9246            (
9247                serde_json::json!({ "type": "despawn-actor", "actor": "actor/giant", "style": "vanish" }),
9248                "actor/giant",
9249            ),
9250            (
9251                serde_json::json!({ "type": "unleash-actor", "actor": "actor/giant" }),
9252                "actor/giant",
9253            ),
9254            (
9255                serde_json::json!({ "type": "spawn-wave", "wave": "wave/muster" }),
9256                "wave/muster",
9257            ),
9258            (
9259                serde_json::json!({ "type": "spawn-npc", "npc": "npc/keeper" }),
9260                "npc/keeper",
9261            ),
9262            (
9263                serde_json::json!({ "type": "despawn-npc", "npc": "npc/keeper" }),
9264                "npc/keeper",
9265            ),
9266            (
9267                serde_json::json!({ "type": "set-block", "anchor": "anchor/altar", "block": "minecraft:stone" }),
9268                "anchor/altar",
9269            ),
9270        ] {
9271            assert_eq!(subject(effect.clone()).as_deref(), Some(want), "{effect}");
9272        }
9273    }
9274
9275    /// Several objects, or none, resolve nothing: naming one of them would be
9276    /// the compiler guessing which the beat is about. A `move-actor` walks an
9277    /// actor to an anchor and names both.
9278    #[test]
9279    fn an_effect_with_no_single_object_resolves_nothing() {
9280        for effect in [
9281            serde_json::json!({ "type": "move-actor", "actor": "actor/giant", "to": { "anchor": "anchor/door" } }),
9282            serde_json::json!({ "type": "move-npc", "npc": "npc/keeper", "to": { "anchor": "anchor/door" } }),
9283            serde_json::json!({ "type": "teleport", "from": { "anchor": "anchor/hall", "extent": [2, 2, 2] }, "to": { "anchor": "anchor/door" } }),
9284            serde_json::json!({ "type": "narrate", "text": "The hall answers." }),
9285            serde_json::json!({ "type": "set-flag", "flag": "flag/heard" }),
9286        ] {
9287            assert_eq!(subject(effect.clone()), None, "{effect}");
9288        }
9289    }
9290
9291    /// A stated subject wins, and says so.
9292    #[test]
9293    fn a_stated_subject_is_not_derived() {
9294        let e: QuestEffect = serde_json::from_value(serde_json::json!({
9295            "type": "open-gate",
9296            "anchor": "anchor/door",
9297            "happening": { "verb": "opens", "text": "He lifts it.", "subject": "npc/keeper" }
9298        }))
9299        .unwrap();
9300        let s = e.happening_subject().expect("a stated subject");
9301        assert_eq!((s.id, s.derived), ("npc/keeper", false));
9302    }
9303
9304    /// No `happening`, no subject: the derivation is about a beat, not about an
9305    /// effect.
9306    #[test]
9307    fn an_effect_with_no_happening_has_no_subject() {
9308        let e: QuestEffect = serde_json::from_value(
9309            serde_json::json!({ "type": "open-gate", "anchor": "anchor/door" }),
9310        )
9311        .unwrap();
9312        assert!(e.happening_subject().is_none());
9313        assert_eq!(e.subject_objects(), vec!["anchor/door"]);
9314    }
9315}