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    /// **How far a player must be able to see**, in chunks (spec-0091): the
118    /// server's `view-distance`, declared by the campaign whose far views need
119    /// it. A thing farther from a body than the served radius is never sent to
120    /// that body's client, so a landmark meant to be seen from across the map
121    /// is a declaration here, not a hope. Absent = the engine's floor
122    /// ([`crate::viewdistance::FLOOR`], 10 chunks = 160 blocks), which every
123    /// proof in the engine is written against; declared in
124    /// `FLOOR..=CEILING` (vanilla serves at most 32). A camera, a sightline, a
125    /// view or a cutscene shot aimed past the served radius is refused
126    /// (`DW0956`); the build states the heap the declared distance costs the
127    /// host at the player cap, and the hosting side meets it.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub view_distance: Option<u8>,
130    /// **The skies a place can stand under** (spec-0080). Each one is declared
131    /// once, here, beside `time`, `weather` and `horizon` — the other
132    /// statements about the sky the party stands under — and ships as a
133    /// datapack biome (`<ns>:atmosphere/<kebab>`) built from the pinned game's
134    /// environment attributes. A place carries one from the first tick
135    /// ([`Area::atmosphere`], `boxes[].atmosphere`), and a beat repaints a
136    /// volume with another ([`Verb::SetAtmosphere`]). Absent or empty: every
137    /// cell stands in the horizon's biome. One no place carries and no beat
138    /// paints is refused (`DW0930`).
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub atmospheres: Vec<Atmosphere>,
141    /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
142    /// derives a region from the placed geometry and a per-second clock returns any
143    /// player who leaves it to the last checkpoint. Required when `horizon` is
144    /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub boundary: Option<Boundary>,
147    /// The closing line on the campaign-completion advancement — the last
148    /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
149    /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
150    /// finale quest's `goal`, which is already both campaign-derived and
151    /// inventoried; the description was previously the hardcoded English
152    /// "You left the keep." on *every* delve, whatever its theme or language.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub outro: Option<String>,
155    /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
156    /// party of one is always legal and every pre-0.6 campaign keeps that reading.
157    /// A design whose beats genuinely need `n` players — two rooms whose switches
158    /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
159    /// lobby then refuses to start below it: the class-selection dialog stays shut
160    /// and the waiting players get a party-count actionbar instead.
161    ///
162    /// Progression is party state either way (spec-0018), so this is a *declaration
163    /// of intent*, not a mechanism: it makes a mandatory-n design first-class
164    /// and turns on the analyzer's n-agent division
165    /// proof. Out of `1..=4` is `DW0370`.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub min_players: Option<u8>,
168    /// **The vanilla textures this delve replaces** (spec-0084). Each row names
169    /// one texture the pinned client ships and the campaign's own image for it,
170    /// at `textures/<id>.png` in the campaign directory; the build bakes it into
171    /// the resource pack at the vanilla path, so it is drawn wherever the client
172    /// draws that texture — every mob of that kind, the moon over every area —
173    /// for every player who accepted the pack. Absent or empty = vanilla's look.
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub textures: Vec<TextureOverride>,
176    /// **Whether a player must accept this delve's resource pack to play it**
177    /// (spec-0084 §11). `true` is emitted as `require-resource-pack=true`: a
178    /// player who declines is disconnected by the server. Absent or `false` =
179    /// the pack is offered and may be declined, in which case the player reads
180    /// English and sees vanilla's textures. A host may still set the server's own
181    /// flip (itzg's `RESOURCE_PACK_ENFORCE`), which is obeyed as given.
182    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183    pub require_resource_pack: bool,
184    /// **A fallen player waits before rejoining** (spec-0077). After clicking
185    /// *Respawn*, a player whose party still has somebody in play watches a
186    /// teammate as a spectator for `seconds`, and counts as down for the party
187    /// wipe while they wait. Absent = no wait, and emission is byte-identical to
188    /// a campaign that never had the field. Needs a checkpoint or bonfire to come
189    /// back to; `seconds` outside `1..=120`, or no checkpoint, is `DW0925`.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub respawn_wait: Option<RespawnWait>,
192}
193
194/// One vanilla texture a campaign replaces (spec-0084 §3.1). The row is a
195/// judgement and nothing more: width, height and frame count are read off the
196/// file and the pinned client's census, and the namespace is fixed.
197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
198#[serde(deny_unknown_fields)]
199pub struct TextureOverride {
200    /// A bare kebab token, unique among the campaign's textures (`DW0190`). The
201    /// image is `textures/<id>.png` in the campaign directory (`DW0309`), and a
202    /// `textures/<id>.png.mcmeta` beside it ships with it as the animation.
203    pub id: String,
204    /// The texture replaced, as a resource location in the `minecraft`
205    /// namespace without `textures/` and without `.png` — the path vanilla's own
206    /// models and atlases use (e.g. `minecraft:entity/zombie/drowned`,
207    /// `minecraft:environment/celestial/moon/full_moon`). It must name a texture
208    /// the pinned client ships (`DW0939`).
209    pub replaces: String,
210    /// Where the image came from and under what licence (ADR-0013): original
211    /// work (`spdx` and `source` both `original`), or an allowlisted third-party
212    /// image with its `url`, and its `attribution` for CC BY (`DW0741`).
213    pub license: crate::license::LicenseEvidence,
214}
215
216/// One declared sky (spec-0080 §3.1): what the party sees and hears while it
217/// stands in a cell painted with this atmosphere's biome.
218///
219/// The biome is vanilla's one channel for sky colour, fog, clouds, sky-light
220/// tint, stars, ambient particles, music, ambience, grass, foliage and water
221/// tint, and precipitation. In the overworld the day cycle stacks over it: it
222/// multiplies the colours (a biome's value survives, darkened at night), takes
223/// the maximum of `star_brightness`, and replaces the sun, moon and star
224/// angles, the sunrise colour and the moon phase outright — which is why those
225/// five ids are refused (`DW0928`) and the sun cannot be moved from a place.
226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
227#[serde(deny_unknown_fields)]
228pub struct Atmosphere {
229    /// `atmosphere/<kebab>`, unique.
230    pub id: AtmosphereId,
231    /// Environment attributes, keyed by id (`visual/sky_color`; the
232    /// `minecraft:` prefix is optional). Which ids a campaign may set, the
233    /// shape of each value and the range the pinned codec accepts are vendored
234    /// data (`crates/delvec/data/environment-attributes-1.21.11.json`), not
235    /// DSL surface: a value outside them is `DW0928`. A float attribute may
236    /// also be written in vanilla's modifier form, `{"argument": 0.85,
237    /// "modifier": "multiply"}`.
238    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
239    pub attributes: BTreeMap<String, serde_json::Value>,
240    /// Grass, foliage, dry-foliage and water tint (`#rrggbb`), each optional.
241    /// Absent, vanilla derives grass and foliage from the climate and water is
242    /// the void biome's `#3f76e4`.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub tint: Option<AtmosphereTint>,
245    /// What falls here when the world's weather is rain or thunder. The
246    /// compiler derives the three vanilla fields that must agree from it
247    /// (`has_precipitation`, `temperature`, `downfall`), and it is the fact
248    /// `DW0496` reads at a cell: `none` under a rainy world means the undead
249    /// burn here.
250    pub precipitation: Precipitation,
251    /// Overrides the derived `temperature` / `downfall`, for vanilla's own
252    /// grass colormap at a named point. Must agree with `precipitation`
253    /// (`DW0930`): snow below 0.15, rain at or above it.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub climate: Option<Climate>,
256}
257
258/// An atmosphere's tint (spec-0080 §3.1.3): the biome `effects` colours the
259/// pinned data writes, each `#rrggbb`.
260#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
261#[serde(deny_unknown_fields)]
262pub struct AtmosphereTint {
263    /// `grass_color`.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub grass: Option<String>,
266    /// `foliage_color`.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub foliage: Option<String>,
269    /// `dry_foliage_color`.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub dry_foliage: Option<String>,
272    /// `water_color`.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub water: Option<String>,
275}
276
277/// What an atmosphere's biome lets fall (spec-0080 §3.1.4).
278#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
279#[serde(rename_all = "kebab-case")]
280pub enum Precipitation {
281    /// Nothing falls: `has_precipitation: false`.
282    None,
283    /// Rain falls: `has_precipitation: true`, temperature 0.5.
284    Rain,
285    /// Snow falls: `has_precipitation: true`, temperature 0.0.
286    Snow,
287}
288
289/// A biome's climate pair (spec-0080 §3.1.4).
290#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
291#[serde(deny_unknown_fields)]
292pub struct Climate {
293    /// `temperature`: vanilla snows below 0.15.
294    pub temperature: f64,
295    /// `downfall`.
296    pub downfall: f64,
297}
298
299/// How long a fallen player waits before rejoining the party (spec-0077 §3).
300#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301#[serde(deny_unknown_fields)]
302pub struct RespawnWait {
303    /// Seconds a fallen player waits after clicking *Respawn*, `1..=120`
304    /// (`DW0925`). Counted on the server only while the player is online.
305    pub seconds: u16,
306    /// Whether a player who comes back with nobody else present also waits.
307    /// Default `false`: a party of one never waits.
308    #[serde(default)]
309    pub alone: bool,
310}
311
312/// Who a granted item goes to (DSL v0.6, spec-0018).
313///
314/// Progression is a fact about the party, so the default for every `give-item` and
315/// every class-kit entry is **all**: a quest beat that arms the party arms all of
316/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
317/// the stake): exactly one copy enters the party, handed to the player whose action
318/// earned it, and the party passes it around physically.
319#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
320#[serde(rename_all = "kebab-case")]
321pub enum Carrier {
322    /// Every party member receives the item (the default).
323    #[default]
324    All,
325    /// Exactly one copy, to the player whose action fired the effect.
326    One,
327}
328
329/// A declared world time state (DSL v0.5, spec-0010; the celestial spelling
330/// spec-0081). The sole difference from vanilla is that the daylight cycle is
331/// frozen (`advance_time false`), so a set state persists for the whole delve
332/// until a `set-time` effect cuts to another.
333///
334/// **Two spellings of one clock.** A keyword — `day`, `noon`, `dusk`, `night`,
335/// `midnight`, `dawn` — states vanilla's word with vanilla's meaning: the hour,
336/// on day 0, so a keyword night shows a full moon. A celestial statement
337/// ([`CelestialTime`], `{"moon": "just-risen", "phase": "new-moon"}`) names one
338/// body, where it stands and, where the moon shows, its phase, and the engine
339/// computes the tick count, day included. Both resolve to one [`Clock`]
340/// ([`WorldTime::clock`]); two values are equal when their clocks are.
341///
342/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
343/// tick count, and the tick form is the general one. `dusk` and `dawn` are the
344/// tick form exposed first-class, per the no-hack rule; a celestial statement is
345/// the same primitive reached from a designer's sentence. A keyword on day 0
346/// still emits its keyword verbatim ([`WorldTime::token`]), so existing
347/// campaigns are byte-identical.
348///
349/// **There is no `Default`** (spec-0061 §4). A default hour is a design decision
350/// wearing a mechanism's clothes, and `#[default] Noon` is what let a delve whose
351/// whole approved look was night build, light-check and render under a blue noon
352/// sky. Removing the impl is what makes that unwritable rather than merely
353/// discouraged: `WorldContent::time` is required, and nothing can supply an hour
354/// the author did not.
355#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
356pub enum WorldTime {
357    /// Morning daylight (`/time set day`, 1000 ticks).
358    Day,
359    /// Midday, brightest (`/time set noon`, 6000 ticks).
360    Noon,
361    /// Sunset onset (`/time set 12000`).
362    Dusk,
363    /// Night, sun fully down (`/time set night`, 13000 ticks).
364    Night,
365    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
366    Midnight,
367    /// First light, just before sunrise (`/time set 23000`).
368    Dawn,
369    /// A sky stated in a designer's words (spec-0081).
370    Celestial(CelestialTime),
371}
372
373/// The six keyword spellings of a [`WorldTime`] — the wire and schema form of
374/// its keyword half.
375#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
376#[serde(rename_all = "kebab-case")]
377pub enum TimeKeyword {
378    /// Morning daylight (`/time set day`, 1000 ticks).
379    Day,
380    /// Midday, brightest (`/time set noon`, 6000 ticks).
381    Noon,
382    /// Sunset — the sky visibly going orange and the day ending
383    /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
384    /// has finished setting, which is what the `night` keyword already sets, so
385    /// 13000 would make `dusk` a synonym rather than its own beat.
386    Dusk,
387    /// Night, sun fully down (`/time set night`, 13000 ticks).
388    Night,
389    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
390    Midnight,
391    /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
392    /// `sunrise` is accepted as a synonym on input.
393    #[serde(alias = "sunrise")]
394    Dawn,
395}
396
397impl From<TimeKeyword> for WorldTime {
398    fn from(k: TimeKeyword) -> WorldTime {
399        match k {
400            TimeKeyword::Day => WorldTime::Day,
401            TimeKeyword::Noon => WorldTime::Noon,
402            TimeKeyword::Dusk => WorldTime::Dusk,
403            TimeKeyword::Night => WorldTime::Night,
404            TimeKeyword::Midnight => WorldTime::Midnight,
405            TimeKeyword::Dawn => WorldTime::Dawn,
406        }
407    }
408}
409
410impl Serialize for WorldTime {
411    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
412        match self {
413            WorldTime::Celestial(c) => c.serialize(s),
414            kw => s.serialize_str(kw.keyword_str().expect("a keyword")),
415        }
416    }
417}
418
419impl<'de> Deserialize<'de> for WorldTime {
420    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<WorldTime, D::Error> {
421        struct V;
422        impl<'de> serde::de::Visitor<'de> for V {
423            type Value = WorldTime;
424            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
425                f.write_str(
426                    "a time: a keyword (day, noon, dusk, night, midnight, dawn) or an object \
427                     naming one body, where it stands and its phase \
428                     ({\"moon\": \"just-risen\", \"phase\": \"new-moon\"})",
429                )
430            }
431            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WorldTime, E> {
432                use serde::de::IntoDeserializer;
433                TimeKeyword::deserialize(v.into_deserializer()).map(WorldTime::from)
434            }
435            fn visit_map<A: serde::de::MapAccess<'de>>(
436                self,
437                map: A,
438            ) -> Result<WorldTime, A::Error> {
439                CelestialTime::deserialize(serde::de::value::MapAccessDeserializer::new(map))
440                    .map(WorldTime::Celestial)
441            }
442        }
443        de.deserialize_any(V)
444    }
445}
446
447impl JsonSchema for WorldTime {
448    fn schema_name() -> std::borrow::Cow<'static, str> {
449        "WorldTime".into()
450    }
451
452    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
453        schemars::json_schema!({
454            "description": "A time: a keyword, vanilla's hour on day 0 (a keyword night is a full \
455                            moon), or a celestial statement naming one body, where it stands and, \
456                            where the moon shows, its phase (spec-0081).",
457            "anyOf": [g.subschema_for::<TimeKeyword>(), g.subschema_for::<CelestialTime>()],
458        })
459    }
460}
461
462impl WorldTime {
463    /// The keyword table: `(the /time set argument, daytime ticks)`, or `None`
464    /// for a celestial statement.
465    ///
466    /// A state vanilla names keeps its keyword — the argument the compiler has
467    /// always emitted — so no shipped campaign's bytes move. A state vanilla does
468    /// not name emits the equivalent tick count, which is the same primitive.
469    const fn spec(self) -> Option<(&'static str, i64)> {
470        match self {
471            WorldTime::Day => Some(("day", 1000)),
472            WorldTime::Noon => Some(("noon", 6000)),
473            WorldTime::Dusk => Some(("12000", 12000)),
474            WorldTime::Night => Some(("night", 13000)),
475            WorldTime::Midnight => Some(("midnight", 18000)),
476            WorldTime::Dawn => Some(("23000", 23000)),
477            WorldTime::Celestial(_) => None,
478        }
479    }
480
481    /// Whether this is one of the six keywords.
482    pub fn is_keyword(self) -> bool {
483        !matches!(self, WorldTime::Celestial(_))
484    }
485
486    /// The celestial statement, if this is one.
487    pub fn celestial(self) -> Option<CelestialTime> {
488        match self {
489            WorldTime::Celestial(c) => Some(c),
490            _ => None,
491        }
492    }
493
494    /// The phase this value states, if any.
495    pub fn stated_phase(self) -> Option<MoonPhase> {
496        self.celestial().and_then(|c| c.phase)
497    }
498
499    /// The `daytime` tick value this state sets (the `time query daytime`
500    /// read-back). Keywords: day=1000, noon=6000, dusk=12000 (sunset onset),
501    /// night=13000, midnight=18000, dawn=23000; a celestial statement its
502    /// position's tick ([`crate::celestial::position_tick`]).
503    pub fn daytime_ticks(self) -> i64 {
504        match self.spec() {
505            Some((_, t)) => t,
506            None => self.celestial().expect("celestial").daytime(),
507        }
508    }
509
510    /// The day this value names as **the world's own time**: a keyword is day 0;
511    /// a celestial statement the day its `phase` names, or day 0 where it names
512    /// none (the moon is below the horizon, so no phase is stated).
513    pub fn world_day(self) -> i64 {
514        self.stated_phase().map(MoonPhase::index).unwrap_or(0)
515    }
516
517    /// **The clock this value sets**, at a site whose world declares `world`
518    /// (spec-0081 §3.3).
519    ///
520    /// - A celestial statement is its position's tick on the day its `phase`
521    ///   names, or the world's day where it states none — a cut changes the
522    ///   hour, and the moon keeps the phase the world declared.
523    /// - A keyword is its table row on day 0 where it states a sky — the
524    ///   world's own time, a design row, a camera ([`TimeSite::Sky`]) — and on
525    ///   the world's day where it is a `set-time` cut ([`TimeSite::Cut`]), which
526    ///   changes the hour and keeps the moon.
527    pub fn clock(self, site: TimeSite, world: WorldTime) -> Clock {
528        let daytime = self.daytime_ticks();
529        let day = match (self, site) {
530            (WorldTime::Celestial(c), _) => c
531                .phase
532                .map(MoonPhase::index)
533                .unwrap_or_else(|| world.world_day()),
534            (_, TimeSite::Cut) => world.world_day(),
535            (_, TimeSite::Sky) => 0,
536        };
537        Clock { day, daytime }
538    }
539
540    /// The world's own clock: [`WorldTime::clock`] at the world's site.
541    pub fn world_clock(self) -> Clock {
542        self.clock(TimeSite::Sky, self)
543    }
544
545    /// **The vanilla `/time set` argument for `clock`**, the one token every
546    /// `time set` the engine emits goes through: a keyword on day 0 emits its
547    /// table argument verbatim (`night`, `12000`), so no keyword campaign's bytes
548    /// move; any other clock emits the integer `day × 24000 + daytime`.
549    pub fn token(self, clock: Clock) -> String {
550        match self.spec() {
551            Some((tok, t)) if clock.day == 0 && clock.daytime == t => tok.to_string(),
552            _ => clock.absolute().to_string(),
553        }
554    }
555
556    /// The keyword spelling, if this is a keyword.
557    fn keyword_str(self) -> Option<&'static str> {
558        match self {
559            WorldTime::Day => Some("day"),
560            WorldTime::Noon => Some("noon"),
561            WorldTime::Dusk => Some("dusk"),
562            WorldTime::Night => Some("night"),
563            WorldTime::Midnight => Some("midnight"),
564            WorldTime::Dawn => Some("dawn"),
565            WorldTime::Celestial(_) => None,
566        }
567    }
568
569    /// **What an author writes** — this state's spelling in a document: the
570    /// keyword, or the canonical JSON of a celestial statement
571    /// (`{"moon":"high","phase":"new-moon"}`).
572    ///
573    /// Not [`WorldTime::token`], which is the `/time set` argument and is a raw
574    /// tick count for every state vanilla does not name. A diagnostic that asks
575    /// an author to declare an hour has to say `dusk`, not `12000`.
576    pub fn keyword(self) -> String {
577        match self {
578            WorldTime::Celestial(c) => c.spelling(),
579            kw => kw.keyword_str().expect("a keyword").to_string(),
580        }
581    }
582}
583
584/// Where a time value is written, which decides the day a keyword names
585/// ([`WorldTime::clock`]).
586#[derive(Clone, Copy, Debug, PartialEq, Eq)]
587pub enum TimeSite {
588    /// A statement of a sky: the world's own time, a design row, a camera.
589    Sky,
590    /// A `set-time` effect, quest or dialogue.
591    Cut,
592}
593
594/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
595/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
596///
597/// **No `Default`**, for the reason [`WorldTime`] gives.
598#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
599#[serde(rename_all = "kebab-case")]
600pub enum WorldWeather {
601    /// Clear sky (`/weather clear`). Vanilla's own state, so emission writes no
602    /// `/weather` command for it.
603    Clear,
604    /// Rain (`/weather rain`).
605    Rain,
606    /// Thunderstorm (`/weather thunder`).
607    Thunder,
608}
609
610impl WorldWeather {
611    /// The word an author writes — identical to [`WorldWeather::token`] for
612    /// every state, and stated separately so a message that names a document's
613    /// vocabulary reads the document's vocabulary. See [`WorldTime::keyword`],
614    /// where the two differ.
615    pub fn keyword(self) -> &'static str {
616        self.token()
617    }
618
619    /// The vanilla `/weather` keyword.
620    pub fn token(self) -> &'static str {
621        match self {
622            WorldWeather::Clear => "clear",
623            WorldWeather::Rain => "rain",
624            WorldWeather::Thunder => "thunder",
625        }
626    }
627}
628
629/// The declared combat difficulty of the delve (DSL v0.6). Values are the
630/// vanilla `/difficulty` keywords.
631///
632/// Difficulty is the single largest lever on how hard a delve *feels*, so the
633/// campaign declares it rather than letting the compiler choose. Easy **halves
634/// incoming player damage** — `min(dmg / 2 + 1, dmg)` — so a campaign tuned
635/// under `easy` is tuned against a halved world. A campaign that
636/// raises this must redo that arithmetic.
637///
638/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
639/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
640/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
641/// campaign would silently vanish. It is a variant only so the compiler can say
642/// that in a diagnostic instead of a serde "unknown variant" parse error.
643#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
644#[serde(rename_all = "kebab-case")]
645pub enum WorldDifficulty {
646    /// `/difficulty easy` — the compiler's historical choice for a wave
647    /// campaign, and the default reading of an absent field. Incoming player
648    /// damage is halved (`min(dmg / 2 + 1, dmg)`).
649    #[default]
650    Easy,
651    /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
652    Normal,
653    /// `/difficulty hard` — amplified damage, and zombies reinforce.
654    Hard,
655    /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
656    /// the rejection can be a diagnostic with a rationale.
657    Peaceful,
658}
659
660impl WorldDifficulty {
661    /// The vanilla `/difficulty` keyword.
662    pub fn token(self) -> &'static str {
663        match self {
664            WorldDifficulty::Peaceful => "peaceful",
665            WorldDifficulty::Easy => "easy",
666            WorldDifficulty::Normal => "normal",
667            WorldDifficulty::Hard => "hard",
668        }
669    }
670
671    /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
672    /// `/difficulty` query command returns — the only vanilla read-back path for
673    /// the setting, and so what the generated PackTest asserts on.
674    pub fn id(self) -> i32 {
675        match self {
676            WorldDifficulty::Peaceful => 0,
677            WorldDifficulty::Easy => 1,
678            WorldDifficulty::Normal => 2,
679            WorldDifficulty::Hard => 3,
680        }
681    }
682}
683
684/// A scenic horizon (spec-0026). A horizon is a **composition of orthogonal
685/// axes**, not an enum of monoliths: a **base** — what surrounds the map — and
686/// that base's params. The field accepts a plain string shorthand
687/// ([`HorizonName`]) or the object form [`HorizonSpec`] `{base, …params}`.
688///
689/// Consumers never match this wire enum. [`Horizon::resolved`] desugars both
690/// forms into one [`ResolvedHorizon`] with the pinned defaults applied, and
691/// [`horizon_base`] answers the one question most callers have.
692#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
693#[serde(untagged)]
694pub enum Horizon {
695    /// A bare base name — `"ocean"` is exactly `{base: "ocean"}`. The two
696    /// bases that predate the horizon library were spelled this way and still
697    /// are, byte-identically; a base added since is spellable this way too,
698    /// because a base with every param at its default has nothing else to say.
699    Name(HorizonBase),
700    /// The object form `{base, …params}`.
701    Spec(HorizonSpec),
702}
703
704/// What surrounds the map.
705///
706/// **One enumeration of bases, reachable two ways.** The shorthand
707/// `horizon: "ocean"` and the object form `horizon: {base: "ocean"}` name this
708/// same variant, which is what stops a base from existing in one spelling and
709/// not the other. A separate list of "names" beside this one would be two
710/// enumerations of the same thing, and the second base added would land in
711/// whichever of them its author was looking at.
712///
713/// What is deliberately NOT here is a name that stands for a base plus a set of
714/// params. Such a name reads as a thing, and the whole claim of this design is
715/// that it is not one — it is a base with params set. A spelling that hides
716/// which params it sets makes that claim unverifiable by looking at the
717/// document, and buys a few saved keystrokes for it. Each base carries its own params on
718/// [`HorizonSpec`], and a param foreign to the declared base is refused rather
719/// than ignored.
720#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
721#[serde(rename_all = "kebab-case")]
722pub enum HorizonBase {
723    /// Void superflat; no surround. The default.
724    #[default]
725    Void,
726    /// Pinned water superflat, sea level 62; no surround.
727    Ocean,
728    /// A mountain annulus around a flat gap floor, with void ambient below the
729    /// tile skirt. The one base that generates terrain.
730    Valley,
731}
732
733impl HorizonBase {
734    /// The kebab wire name.
735    pub fn token(self) -> &'static str {
736        match self {
737            HorizonBase::Void => "void",
738            HorizonBase::Ocean => "ocean",
739            HorizonBase::Valley => "valley",
740        }
741    }
742
743    /// Whether this base generates a surround — compiler-built terrain outside
744    /// the map's own extent. `void` and `ocean` are pure ambient and build
745    /// nothing.
746    pub fn has_surround(self) -> bool {
747        matches!(self, HorizonBase::Valley)
748    }
749}
750
751/// The `horizon` object form: a `base` plus that base's params, all optional
752/// with pinned defaults ([`horizon_defaults`]).
753///
754/// The `valley` surround generator carries a second flora and a second surface
755/// palette (a cherry grove over `minecraft:cherry_grove`) and **this struct
756/// does not expose them.** Every engine surface owes a gallery element in the
757/// change that lands it; the element a second flora needs is a valley overlay,
758/// and one is writable now that a one-area campaign's single prefab states an
759/// extent ([`crate::placement::Extent`], `DW0855`) — the reason recorded here
760/// was that no two-file overlay could ring a map, and that reason is spent.
761/// What is left is that nothing has written the element, and a surface lands
762/// with its element or it does not land. The shape is flat rather than
763/// per-base tagged, and a param foreign to the declared base is refused
764/// (`DW0853`) — so an `ocean` cannot quietly carry a `rim_height` that nothing
765/// reads.
766#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
767#[serde(deny_unknown_fields)]
768pub struct HorizonSpec {
769    /// The base — what surrounds the map.
770    pub base: HorizonBase,
771    /// `valley`: the surround's total footprint as a multiple of the map's, on
772    /// each axis (`2.0..=3.0`, default 2.5).
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub ratio: Option<f64>,
775    /// `valley`: crest height of the rim over the gap floor (`16..=128`,
776    /// default 48).
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub rim_height: Option<i32>,
779}
780
781/// Pinned horizon param defaults. One table, so the doc comments, the resolver
782/// and the diagnostics cannot drift.
783pub mod horizon_defaults {
784    /// `valley.ratio`.
785    pub const RATIO: f64 = 2.5;
786    /// `valley.ratio` lower bound — below 2.0 the annulus has no room for a
787    /// gap floor and a slope run both.
788    pub const RATIO_MIN: f64 = 2.0;
789    /// `valley.ratio` upper bound — above 3.0 the surround is mostly terrain a
790    /// body never reaches, at a cost that is all shipped bytes.
791    pub const RATIO_MAX: f64 = 3.0;
792    /// `valley.rim_height`.
793    pub const RIM_HEIGHT: i32 = 48;
794    /// `valley.rim_height` lower bound — a rim under 16 does not close the
795    /// horizon from a body standing on the gap floor.
796    pub const RIM_HEIGHT_MIN: i32 = 16;
797    /// `valley.rim_height` upper bound — the build range is 384 blocks tall and
798    /// the surround has to fit under whatever the map puts above it.
799    pub const RIM_HEIGHT_MAX: i32 = 128;
800}
801
802/// A horizon with both wire forms desugared and every default applied — the
803/// only view downstream code reads.
804#[derive(Clone, Copy, Debug, PartialEq)]
805pub struct ResolvedHorizon {
806    /// The base.
807    pub base: HorizonBase,
808    /// `valley.ratio`.
809    pub ratio: f64,
810    /// `valley.rim_height`.
811    pub rim_height: i32,
812}
813
814impl Default for ResolvedHorizon {
815    fn default() -> Self {
816        ResolvedHorizon {
817            base: HorizonBase::Void,
818            ratio: horizon_defaults::RATIO,
819            rim_height: horizon_defaults::RIM_HEIGHT,
820        }
821    }
822}
823
824impl ResolvedHorizon {
825    /// The resolved horizon of `base` with every param at its pinned default.
826    pub fn of_base(base: HorizonBase) -> Self {
827        ResolvedHorizon {
828            base,
829            ..Default::default()
830        }
831    }
832}
833
834impl Horizon {
835    /// Desugar either wire form to the one resolved view, defaults applied.
836    pub fn resolved(&self) -> ResolvedHorizon {
837        match self {
838            Horizon::Name(base) => ResolvedHorizon::of_base(*base),
839            Horizon::Spec(s) => ResolvedHorizon {
840                base: s.base,
841                ratio: s.ratio.unwrap_or(horizon_defaults::RATIO),
842                rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT),
843            },
844        }
845    }
846
847    /// The resolved base.
848    pub fn base(&self) -> HorizonBase {
849        self.resolved().base
850    }
851
852    /// True when this declaration needs the horizon-library surface: the object
853    /// form, or a bare name for a base that did not exist before it.
854    ///
855    /// The two bases that predate the library stay writable as bare names at
856    /// the version that introduced them, and their emission does not move —
857    /// which is what makes this a widening rather than a break. What is fenced
858    /// is saying something the old surface had no spelling for.
859    pub fn needs_horizon_library(&self) -> bool {
860        match self {
861            Horizon::Spec(_) => true,
862            Horizon::Name(base) => match base {
863                HorizonBase::Void | HorizonBase::Ocean => false,
864                HorizonBase::Valley => true,
865            },
866        }
867    }
868}
869
870/// The resolved base of an optional stage-1 `horizon` field — `Void` when
871/// absent. The one helper every downstream consumer (placement, the ambient
872/// model, emission) goes through, so that a new base cannot be forgotten at one
873/// of them.
874pub fn horizon_base(horizon: &Option<Horizon>) -> HorizonBase {
875    horizon.as_ref().map(|h| h.base()).unwrap_or_default()
876}
877
878/// The resolved view of an optional stage-1 `horizon` field, with defaults
879/// applied for an absent one.
880pub fn resolved_horizon(horizon: &Option<Horizon>) -> ResolvedHorizon {
881    horizon.as_ref().map(|h| h.resolved()).unwrap_or_default()
882}
883
884/// The default boundary `margin` (blocks of horizontal breathing room added
885/// around the derived region). Separate function so `serde(default = …)` and the
886/// documented literal cannot drift.
887fn default_margin() -> u16 {
888    16
889}
890
891/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
892/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
893/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
894/// placed block − 8) — never authored — so "every anchor is inside" is structural.
895/// Enforcement is a per-second clock that returns any player outside the region to
896/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
897/// damage, no items lost.
898#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
899#[serde(deny_unknown_fields)]
900pub struct Boundary {
901    /// Horizontal breathing room in blocks added around the derived region on
902    /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
903    #[serde(default = "default_margin")]
904    pub margin: u16,
905    /// Actionbar message shown on return. Absent = the compiler's English default.
906    /// When set, it is inventoried under l10n key `world.boundary.message` and is
907    /// translated like every other player-facing string.
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub message: Option<String>,
910    /// **Whether the boundary returns a player who leaves it** (spec-0092 §10).
911    /// Default `true`: the per-second clock returns any player outside the region
912    /// to the last checkpoint. `false` keeps the region — every proof that reads
913    /// it reads the same box — and emits no clock: the creator's switch for a
914    /// world nobody can leave, where a return only fights a creator flying out
915    /// to look at a far view. Legal only where the build proves no body can walk
916    /// or swim out of the region (`DW0960`).
917    #[serde(default = "default_true", skip_serializing_if = "is_true")]
918    pub returns: bool,
919}
920
921#[allow(clippy::trivially_copy_pass_by_ref)] // serde's `skip_serializing_if` hands a reference
922fn is_true(b: &bool) -> bool {
923    *b
924}
925
926/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
927/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
928/// compiler owns the placement rule and block-light emission.
929#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
930#[serde(rename_all = "kebab-case")]
931pub enum Fixture {
932    /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
933    Torch,
934    /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
935    Lantern,
936    /// Floor campfire (block light 15); never on or adjacent to a required path
937    /// cell (it is a damage source).
938    Campfire,
939    /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
940    Shroomlight,
941}
942
943impl Fixture {
944    /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
945    pub fn token(self) -> &'static str {
946        match self {
947            Fixture::Torch => "torch",
948            Fixture::Lantern => "lantern",
949            Fixture::Campfire => "campfire",
950            Fixture::Shroomlight => "shroomlight",
951        }
952    }
953}
954
955/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
956/// presence puts the area on the relight path: the compiler guarantees every
957/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
958/// with `DW0211` if it cannot.
959#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
960#[serde(deny_unknown_fields)]
961pub struct AreaLighting {
962    /// The fixture the relight pass places.
963    pub fixture: Fixture,
964    /// The minimum block+sky light guaranteed on reachable walkable cells
965    /// (1..=14, default 7). Range-checked (`DW0196`).
966    #[serde(default = "default_min_light")]
967    pub min_light: u8,
968}
969
970/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
971fn default_min_light() -> u8 {
972    7
973}
974
975/// A per-area **darkness mitigation** declaration (DSL v0.6).
976///
977/// The first-class answer to "this area is meant to be dark, and the players are
978/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
979/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
980/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
981/// gap between the check and the feature.
982///
983/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
984/// for `night vision`: that accepted a renamed water bottle, so the gate passed
985/// while nothing in the world granted night vision (owner, island QA).
986#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
987#[serde(rename_all = "kebab-case")]
988pub enum AreaMitigation {
989    /// Every player inside the area's placed bounds is kept under
990    /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
991    NightVision,
992}
993
994/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
995///
996/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
997/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
998/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
999/// full jigsaw layout semantics are spec-0002's.
1000#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1001#[serde(deny_unknown_fields)]
1002pub struct Area {
1003    /// Unique area id.
1004    pub id: AreaId,
1005    /// Player-facing area name.
1006    pub name: String,
1007    /// The single prefab bound to this area (mutually exclusive with
1008    /// `prefab_pool`).
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub prefab: Option<PrefabId>,
1011    /// The jigsaw prefab pool bound to this area (mutually exclusive with
1012    /// `prefab`); requires `pieces`.
1013    #[serde(default, skip_serializing_if = "Option::is_none")]
1014    pub prefab_pool: Option<PoolId>,
1015    /// Jigsaw piece-count bounds (only with `prefab_pool`).
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub pieces: Option<Pieces>,
1018    /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
1019    /// present, the compiler's relight pass guarantees `min_light` on every
1020    /// reachable walkable cell of this area by placing the declared fixture, or
1021    /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
1022    /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
1023    #[serde(default, skip_serializing_if = "Option::is_none")]
1024    pub lighting: Option<AreaLighting>,
1025    /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
1026    /// the compiler emit a clocked `effect give` over this area's placed bounds and
1027    /// is the (only) declaration that satisfies `DW0210` without `lighting`.
1028    /// Independent of `lighting`: an area may declare both (fixtures *and* the
1029    /// effect), either, or neither.
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub mitigation: Option<AreaMitigation>,
1032    /// **The sky this place stands under from the first tick** (spec-0080
1033    /// §3.2): one of `world.atmospheres[]`, painted at world setup over the
1034    /// area's placed bounds grown up and down as far as the client's biome
1035    /// blend reads. The volume is the placement's, never typed.
1036    /// Absent: the horizon's biome.
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub atmosphere: Option<AtmosphereId>,
1039}
1040
1041/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
1042#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1043#[serde(deny_unknown_fields)]
1044pub struct Pieces {
1045    /// Minimum number of pieces to assemble.
1046    pub min: u32,
1047    /// Maximum number of pieces to assemble.
1048    pub max: u32,
1049}
1050
1051// ---------------------------------------------------------------------------
1052// Body traversal — the declaration every body that moves carries (DSL v0.11,
1053// spec-0034)
1054// ---------------------------------------------------------------------------
1055
1056/// How a body gets around.
1057///
1058/// The compiler DERIVES this from the entity id for every body (spiders climb,
1059/// ghasts fly, `#minecraft:aquatic` swims, and everything else — including every
1060/// id the table has never heard of — is [`Locomotion::Ground`], the checked
1061/// class). [`BodyTraversal`] is the author's side of the same vocabulary: one
1062/// enum, so a declaration and a derivation can never mean different things.
1063///
1064/// The vocabulary lives in this crate rather than in the compiler because it is
1065/// now DSL surface; the compiler re-exports it and owns the derivation table
1066/// (`compiler::traversal`).
1067#[derive(
1068    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1069)]
1070#[serde(rename_all = "kebab-case")]
1071pub enum Locomotion {
1072    /// Walks, steps and jumps — the default and the CHECKED class.
1073    Ground,
1074    /// Climbs sheer vertical surfaces (vanilla's `Spider` class).
1075    Climber,
1076    /// Leaves the ground under its own power.
1077    Flier,
1078    /// A member of vanilla's `#minecraft:aquatic` tag. A ledger classification
1079    /// that exempts nothing, which is why it may not be **declared**
1080    /// (`DW0455`) — see [`BodyTraversal`].
1081    Aquatic,
1082}
1083
1084impl Locomotion {
1085    /// The stable kebab token this class is written and reported under.
1086    pub fn token(self) -> &'static str {
1087        match self {
1088            Locomotion::Ground => "ground",
1089            Locomotion::Climber => "climber",
1090            Locomotion::Flier => "flier",
1091            Locomotion::Aquatic => "aquatic",
1092        }
1093    }
1094
1095    /// Every class, in ledger order — so a report can never silently drop a row
1096    /// when a class is added.
1097    pub const ALL: [Locomotion; 4] = [
1098        Locomotion::Ground,
1099        Locomotion::Climber,
1100        Locomotion::Flier,
1101        Locomotion::Aquatic,
1102    ];
1103}
1104
1105/// What a body can do when it moves, **declared by the author** (DSL v0.11,
1106/// spec-0034).
1107///
1108/// Carried by every object class in the DSL that has a body and a position and
1109/// is walked by a compiler-emitted route — the stage-2 [`Npc`] and the stage-5
1110/// [`Actor`]. It is deliberately one shared type on both rather than a field
1111/// per consumer: traversal is a property of a body that moves, not of the verb
1112/// that first needed it (CLAUDE.md), and a second bespoke field would be the
1113/// defect rather than the fix.
1114///
1115/// **A declaration is a claim the build holds you to, never an opt-out.** The
1116/// compiler compares the verdicts this body earns under the declared class
1117/// against the ones it earns under its species' derived class; a declaration
1118/// that changes no verdict is inert and is `DW0454`. So declaring `climber` on
1119/// a sheep is only accepted where that sheep's route really does go over a
1120/// barrier line — the exception is authored and proven, instead of happening by
1121/// accident and merely rendering.
1122///
1123/// **What is deliberately NOT here: `opens_gates`.** Passing a closed fence gate
1124/// is a right-click, a scripted walk is a compiler-emitted `tp` polyline whose
1125/// puppet performs no interaction at all, and no runtime verb changes a fence
1126/// gate's block state. Declaring it would not make it true, so the error tier
1127/// (`DW0452`) has no authorable exemption and a declaration can never reach it.
1128#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1129#[serde(deny_unknown_fields)]
1130pub struct BodyTraversal {
1131    /// How this body gets around, overriding what its entity id implies.
1132    pub locomotion: Locomotion,
1133}
1134
1135/// One object class that has a **body the compiler stages**: a declared
1136/// position, a declared species, and a compiler-emitted route.
1137///
1138/// A sum type rather than a flattened tuple, so **adding a body class is a
1139/// compile error at every consumer** until each one says what it does with it.
1140/// The alternative — each rule walking the classes it happens to remember — is
1141/// the defect class CLAUDE.md names: a hand-rolled walk that
1142/// enumerated three of five effect roots.
1143///
1144/// **Keyed to the object, not to the verb that first needed it.** This type was
1145/// introduced for [`BodyTraversal`] and read, for a while, as "one consumer of
1146/// `BodyTraversal`" — which is why its only enumeration
1147/// ([`body_traversal_sites`]) was filtered to bodies declaring a traversal, and
1148/// why the second property a body carries, its [`NpcSkin`], had no enumeration
1149/// at all: the bake walked the stage-2 npc list by hand, and an actor's skin was
1150/// emitted into the summon, never baked into the pack, and never refused.
1151/// A body's properties belong to the body. [`body_sites`] is the unfiltered
1152/// walk; a per-property enumeration is a *filter* over it, never a second walk.
1153///
1154/// Deliberately NOT a member: [`WaveMob`]. A wave mob has a body and a position,
1155/// but it is driven by **native vanilla AI**, never by a compiler-emitted route,
1156/// so the compiler makes no claim about the moves it makes and a locomotion
1157/// declaration on it could change no verdict. It declares no `skin` either — the
1158/// exclusion holds for both properties, and the schema says so
1159/// (`crates/dsl/tests/body_skin_sites.rs`). It becomes a member the day the lane
1160/// proof reasons about how its bodies move — and it joins here, through this
1161/// same type, rather than through a field of its own.
1162#[derive(Clone, Copy, Debug)]
1163pub enum BodyRef<'a> {
1164    /// A stage-2 NPC, walked by `move-npc`.
1165    Npc(&'a Npc),
1166    /// A stage-5 scripted actor, walked by `move-actor`.
1167    Actor(&'a Actor),
1168}
1169
1170impl<'a> BodyRef<'a> {
1171    /// The declaring stage's wire name (`npcs` / `quests`) — also the stage
1172    /// whose `dsl_version` fences this body's declaration.
1173    pub fn stage(self) -> &'static str {
1174        match self {
1175            BodyRef::Npc(_) => "npcs",
1176            BodyRef::Actor(_) => "quests",
1177        }
1178    }
1179
1180    /// The body's declared id.
1181    pub fn id(self) -> &'a str {
1182        match self {
1183            BodyRef::Npc(n) => n.id.as_str(),
1184            BodyRef::Actor(a) => a.id.as_str(),
1185        }
1186    }
1187
1188    /// The entity id written on the body. **Not necessarily the body that
1189    /// ships**: a `skin` re-dresses it as a `minecraft:mannequin` — see
1190    /// [`Self::worn_entity`].
1191    pub fn declared_entity(self) -> &'a str {
1192        match self {
1193            BodyRef::Npc(n) => n.base_entity.as_str(),
1194            BodyRef::Actor(a) => a.entity.as_str(),
1195        }
1196    }
1197
1198    /// **The entity id the body ships as**: `minecraft:mannequin` when it
1199    /// declares a `skin`, else the declared entity. The one authority for that
1200    /// rule — the compiler's geometric proofs (`nav::npc_body_entity`,
1201    /// `nav::actor_body_entity`) and the equipment fit rule (`DW0898`) all read
1202    /// it.
1203    pub fn worn_entity(self) -> &'a str {
1204        match self.skin() {
1205            Some(_) => "minecraft:mannequin",
1206            None => self.declared_entity(),
1207        }
1208    }
1209
1210    /// **The mark this body is placed on** — the anchor and offset the engine
1211    /// summons it at, for every class alike (spec-0066).
1212    ///
1213    /// A body's placement is a property of the body, not of the stage list that
1214    /// happens to declare it: a mark is a cell, and a cell holds one body. The
1215    /// rule that reads this ([`crate::compiler`]'s `DW0896`, via
1216    /// [`body_sites`]) therefore quantifies over npcs and actors in one pass
1217    /// rather than over `actors[]`, which is where the seven-men-one-anchor
1218    /// muster came from.
1219    pub fn mark(self) -> Mark {
1220        let (anchor, offset) = match self {
1221            BodyRef::Npc(n) => (&n.anchor, n.offset),
1222            BodyRef::Actor(a) => (&a.anchor, a.offset),
1223        };
1224        Mark {
1225            anchor: anchor.clone(),
1226            offset,
1227        }
1228    }
1229
1230    /// The area whose anchor table resolves [`Self::mark`]'s anchor first, when this
1231    /// class declares one.
1232    ///
1233    /// A stage-2 npc names its area and is resolved inside it; a stage-5 actor
1234    /// names none and is resolved across every placed piece, exactly as an
1235    /// `open-gate` / `move-actor` destination is. Stated here so the resolution
1236    /// rule is one rule over both classes and not a per-call-site habit.
1237    pub fn area(self) -> Option<&'a AreaId> {
1238        match self {
1239            BodyRef::Npc(n) => Some(&n.area),
1240            BodyRef::Actor(_) => None,
1241        }
1242    }
1243
1244    /// Whether this body stands on its mark from **world init**, with no effect
1245    /// having to fire.
1246    ///
1247    /// A stage-2 npc does unless it is `deferred`; a stage-5 actor never does —
1248    /// a puppet exists only from the `spawn-actor` that summons it, which is why
1249    /// an actor no `spawn-actor` names never exists at all.
1250    pub fn at_world_init(self) -> bool {
1251        match self {
1252            BodyRef::Npc(n) => !n.deferred,
1253            BodyRef::Actor(_) => false,
1254        }
1255    }
1256
1257    /// Whether a **player** can end this body's life.
1258    ///
1259    /// An npc body is emitted `Invulnerable:1b` unconditionally, so nothing a
1260    /// player does removes it; an actor's puppet is `Invulnerable` unless it
1261    /// declares [`Actor::vulnerable`]. A body a player can kill is one whose
1262    /// lifetime the compiler cannot bound, which is the whole of what this
1263    /// answers.
1264    pub fn killable_by_players(self) -> bool {
1265        match self {
1266            BodyRef::Npc(_) => false,
1267            BodyRef::Actor(a) => a.vulnerable,
1268        }
1269    }
1270
1271    /// This body's traversal declaration, if it carries one.
1272    pub fn traversal(self) -> Option<&'a BodyTraversal> {
1273        match self {
1274            BodyRef::Npc(n) => n.traversal.as_ref(),
1275            BodyRef::Actor(a) => a.traversal.as_ref(),
1276        }
1277    }
1278
1279    /// This body's skin declaration, if it carries one.
1280    ///
1281    /// A skinned body of **either** class ships as a `minecraft:mannequin`
1282    /// whose `profile.texture` resolves to `delvewright:npc/<campaign_id>/<texture_id>`,
1283    /// so either one owes the same `skins/<texture_id>.png` under the same refusal
1284    /// (`DW0309`). Answering it here is what stops the bake from being a
1285    /// property of one class.
1286    pub fn skin(self) -> Option<&'a NpcSkin> {
1287        match self {
1288            BodyRef::Npc(n) => n.skin.as_ref(),
1289            BodyRef::Actor(a) => a.skin.as_ref(),
1290        }
1291    }
1292
1293    /// This class's name in the JSON Schema export (`delvec schema --stage all`).
1294    ///
1295    /// The join between the closed Rust set and the schema, which is the only
1296    /// authority on *which object classes declare what*. `body_skin_sites.rs`
1297    /// compares the two.
1298    pub fn class(self) -> &'static str {
1299        match self {
1300            BodyRef::Npc(_) => "Npc",
1301            BodyRef::Actor(_) => "Actor",
1302        }
1303    }
1304
1305    /// Every body class, by schema name. The closed set, stated once.
1306    pub const ALL_CLASSES: [&'static str; 2] = ["Npc", "Actor"];
1307}
1308
1309/// A staged body declaration, with the JSON pointer at the declaration itself.
1310///
1311/// The pointer is at the OBJECT (`/content/npcs/3`), not at any one of its
1312/// fields: a per-property site appends its own field name. A pointer built per
1313/// property is how two walks of one population start disagreeing about where a
1314/// thing was declared.
1315#[derive(Clone, Debug)]
1316pub struct BodySite<'a> {
1317    /// Which object class declared it, and the object itself.
1318    pub body: BodyRef<'a>,
1319    /// JSON pointer at the declaration, for a diagnostic path.
1320    pub path: String,
1321}
1322
1323/// **Every** body the campaign declares, in stage order: stage-2 npcs in
1324/// declaration order, then stage-5 actors in declaration order.
1325///
1326/// The one walk of the campaign's bodies. A rule about a property a body carries
1327/// is a *filter* over this ([`body_traversal_sites`], [`body_skin_sites`]) — never
1328/// a second traversal, and never a hand-written loop over one stage's list,
1329/// which is exactly how an actor's skin came to be emitted but never baked.
1330pub fn body_sites(c: &crate::envelope::Campaign) -> Vec<BodySite<'_>> {
1331    let mut out: Vec<BodySite<'_>> = Vec::new();
1332    for (i, n) in c.npcs.content.npcs.iter().enumerate() {
1333        out.push(BodySite {
1334            body: BodyRef::Npc(n),
1335            path: format!("/content/npcs/{i}"),
1336        });
1337    }
1338    for (i, a) in c.quests.content.actors.iter().enumerate() {
1339        out.push(BodySite {
1340            body: BodyRef::Actor(a),
1341            path: format!("/content/actors/{i}"),
1342        });
1343    }
1344    out
1345}
1346
1347/// A body that carries a [`BodyTraversal`] declaration, with the JSON pointer at
1348/// it.
1349#[derive(Clone, Debug)]
1350pub struct BodyTraversalSite<'a> {
1351    /// Which object class declared it, and the object itself.
1352    pub body: BodyRef<'a>,
1353    /// JSON pointer at the `traversal` field, for a diagnostic path.
1354    pub path: String,
1355    /// The declaration.
1356    pub traversal: &'a BodyTraversal,
1357}
1358
1359/// Every body in the campaign that DECLARES a traversal, in stage order.
1360///
1361/// The one enumeration of the declaration's consumers, shared by the DSL's value
1362/// check (`DW0455`) and by the compiler's proof (`DW0454`), so "which object
1363/// classes carry this" is answered in exactly one place.
1364pub fn body_traversal_sites(c: &crate::envelope::Campaign) -> Vec<BodyTraversalSite<'_>> {
1365    body_sites(c)
1366        .into_iter()
1367        .filter_map(|s| {
1368            s.body.traversal().map(|t| BodyTraversalSite {
1369                body: s.body,
1370                path: format!("{}/traversal", s.path),
1371                traversal: t,
1372            })
1373        })
1374        .collect()
1375}
1376
1377/// A body that carries an [`NpcSkin`] declaration, with the JSON pointer at it.
1378#[derive(Clone, Debug)]
1379pub struct BodySkinSite<'a> {
1380    /// Which object class declared it, and the object itself.
1381    pub body: BodyRef<'a>,
1382    /// JSON pointer at the `skin` field, for a diagnostic path.
1383    pub path: String,
1384    /// The declaration.
1385    pub skin: &'a NpcSkin,
1386}
1387
1388/// Every body in the campaign that DECLARES a skin, in stage order.
1389///
1390/// The one enumeration of what the resource-pack bake must serve and what
1391/// `DW0309` must refuse, for every class alike — a skinned npc and a skinned
1392/// actor are the same fact about two bodies.
1393///
1394/// **Declarations, not textures.** Two bodies may name one `texture_id` (an npc
1395/// and the puppet that plays it), and the caller decides what that means; the
1396/// bake reads each file once.
1397pub fn body_skin_sites(c: &crate::envelope::Campaign) -> Vec<BodySkinSite<'_>> {
1398    body_sites(c)
1399        .into_iter()
1400        .filter_map(|s| {
1401            s.body.skin().map(|k| BodySkinSite {
1402                body: s.body,
1403                path: format!("{}/skin", s.path),
1404                skin: k,
1405            })
1406        })
1407        .collect()
1408}
1409
1410/// The **mutable mirror** of [`body_skin_sites`]: every skin declaration in the
1411/// campaign, in the identical order, exposed mutably so one pass can rewrite what
1412/// every emitter will read ([`crate::l10n::namespace_skin_textures`]).
1413///
1414/// It carries no [`BodyRef`] and no pointer, because a rewrite needs neither and a
1415/// borrow of the whole body would forbid the field it is there to change. What it
1416/// does owe is the **same population**: a body class that declares a skin and is
1417/// missing here would keep an un-namespaced texture and collide with every other
1418/// delve, silently. `body_skin_sites_mut_is_the_same_walk`
1419/// (`crates/dsl/tests/body_skin_sites.rs`) pins that over a campaign carrying one
1420/// body of every class in [`BodyRef::ALL_CLASSES`] — the closed set the schema
1421/// export is compared against in the same file, so a new body class turns that
1422/// coverage red and both walks are visited together.
1423pub fn body_skins_mut(c: &mut crate::envelope::Campaign) -> Vec<&mut NpcSkin> {
1424    let mut out: Vec<&mut NpcSkin> = Vec::new();
1425    out.extend(
1426        c.npcs
1427            .content
1428            .npcs
1429            .iter_mut()
1430            .filter_map(|n| n.skin.as_mut()),
1431    );
1432    out.extend(
1433        c.quests
1434            .content
1435            .actors
1436            .iter_mut()
1437            .filter_map(|a| a.skin.as_mut()),
1438    );
1439    out
1440}
1441
1442// ---------------------------------------------------------------------------
1443// Stage 2 — npcs
1444// ---------------------------------------------------------------------------
1445
1446/// Stage 2 payload: the campaign's NPCs (casting sheets).
1447#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1448#[serde(deny_unknown_fields)]
1449pub struct NpcsContent {
1450    /// All NPCs in the campaign.
1451    pub npcs: Vec<Npc>,
1452}
1453
1454/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
1455///
1456/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
1457/// contract the stage-6 `dialogue` tree must honor.
1458#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1459#[serde(deny_unknown_fields)]
1460pub struct Npc {
1461    /// Unique NPC id.
1462    pub id: NpcId,
1463    /// Player-facing name.
1464    pub name: String,
1465    /// NPC role.
1466    pub role: Role,
1467    /// The area this NPC stands in (stage-1 ref).
1468    pub area: AreaId,
1469    /// The prefab anchor this NPC stands on.
1470    pub anchor: AnchorId,
1471    /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
1472    /// `[0, 0, 0]`): the NPC stands at the [`Mark`] the two fields spell.
1473    #[serde(default, skip_serializing_if = "is_zero3")]
1474    pub offset: [i32; 3],
1475    /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
1476    pub base_entity: String,
1477    /// The structured persona (character contract for stage 6).
1478    pub persona: Persona,
1479    /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
1480    /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
1481    /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
1482    /// Non-skinned NPCs are byte-identical to v0.3.
1483    #[serde(default, skip_serializing_if = "Option::is_none")]
1484    pub skin: Option<NpcSkin>,
1485    /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
1486    /// world init — its body and interaction hitbox only appear when a
1487    /// [`Verb::SpawnNpc`] fires, at this same `anchor`. The dual of
1488    /// `despawn-npc`: a character with a scripted entrance must not stand at its
1489    /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
1490    /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
1491    /// init, byte-identical to pre-0.6.
1492    #[serde(default, skip_serializing_if = "is_false")]
1493    pub deferred: bool,
1494    /// What this body can do when it moves (DSL v0.11, spec-0034). Absent = the
1495    /// class the compiler derives from `base_entity` (or from `minecraft:mannequin`
1496    /// when `skin` is set — the body that actually ships). See [`BodyTraversal`]:
1497    /// the declaration must change a verdict or it is `DW0454`, and it can never
1498    /// reach the error tier.
1499    #[serde(default, skip_serializing_if = "Option::is_none")]
1500    pub traversal: Option<BodyTraversal>,
1501}
1502
1503/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG is sourced from
1504/// the campaign dir's `skins/<texture_id>.png` and ships in the per-delve resource
1505/// pack at `assets/delvewright/textures/npc/<campaign_id>/<texture_id>.png`, which
1506/// is what the mannequin's `profile.texture` resolves to. The delve's own
1507/// directory is stamped on at emission ([`crate::l10n::namespace_skin_textures`])
1508/// — a client merges every applied pack's textures into ONE space, so two delves
1509/// that both cast a `keeper` would otherwise wear each other's faces. Nothing a
1510/// creator writes or names on disk carries it.
1511#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1512#[serde(deny_unknown_fields)]
1513pub struct NpcSkin {
1514    /// Skin id: the PNG basename under `skins/`, and the last segment of the
1515    /// resource-pack texture path (a bare kebab token; validated by `DW0190`).
1516    pub texture_id: String,
1517    /// Player model. **Required** (spec-0009): an omitted model renders slim, so
1518    /// a wide skin on a slim model is distorted — the compiler always emits it.
1519    pub model: SkinModel,
1520}
1521
1522/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
1523/// Alex). Emitted verbatim into the mannequin `profile.model`.
1524#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1525#[serde(rename_all = "kebab-case")]
1526pub enum SkinModel {
1527    /// Classic 4-pixel arms (Steve).
1528    Wide,
1529    /// Slim 3-pixel arms (Alex).
1530    Slim,
1531}
1532
1533impl SkinModel {
1534    /// The vanilla `profile.model` token.
1535    pub fn token(self) -> &'static str {
1536        match self {
1537            SkinModel::Wide => "wide",
1538            SkinModel::Slim => "slim",
1539        }
1540    }
1541}
1542
1543/// A structured casting sheet. Structure lives in the
1544/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
1545/// are required; the rest are optional.
1546#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1547#[serde(deny_unknown_fields)]
1548pub struct Persona {
1549    /// One-line character archetype (required).
1550    pub archetype: String,
1551    /// How the NPC speaks — register, tics, formality (required).
1552    pub speech_style: String,
1553    /// Emotional bearing toward the player (optional).
1554    #[serde(default, skip_serializing_if = "Option::is_none")]
1555    pub demeanor: Option<String>,
1556    /// What the NPC wants (required).
1557    pub motivation: String,
1558    /// Something the NPC hides (optional).
1559    #[serde(default, skip_serializing_if = "Option::is_none")]
1560    pub secret: Option<String>,
1561    /// Backstory colour (optional).
1562    #[serde(default, skip_serializing_if = "Option::is_none")]
1563    pub backstory: Option<String>,
1564    /// Attitudes toward other same-stage NPCs (optional; refs validated).
1565    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1566    pub relationships: Vec<Relationship>,
1567}
1568
1569/// One persona relationship: an attitude toward another same-stage NPC.
1570#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1571#[serde(deny_unknown_fields)]
1572pub struct Relationship {
1573    /// The other NPC (stage-2 ref, validated within stage 2).
1574    pub npc: NpcId,
1575    /// Free-text attitude toward that NPC.
1576    pub attitude: String,
1577}
1578
1579/// What a speaking part does. A schema enum offers what the engine accepts,
1580/// so there are two of them: how hard a fight is billed is [`EncounterTier`] on
1581/// the body that fights (a `waves[]` entry or a stage-5 actor), not a role here.
1582#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1583#[serde(rename_all = "kebab-case")]
1584pub enum Role {
1585    /// Gives and advances quests.
1586    QuestGiver,
1587    /// Flavor only.
1588    Flavor,
1589}
1590
1591// ---------------------------------------------------------------------------
1592// Stage 6 — dialogue
1593// ---------------------------------------------------------------------------
1594
1595/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
1596#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1597#[serde(deny_unknown_fields)]
1598pub struct DialogueContent {
1599    /// One tree per NPC (1:1 with stage 2, both directions).
1600    pub dialogues: Vec<NpcDialogue>,
1601}
1602
1603impl DialogueContent {
1604    /// The dialogue tree for an NPC id, if present.
1605    pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
1606        self.dialogues.iter().find(|t| t.npc.as_str() == npc)
1607    }
1608}
1609
1610/// One NPC's dialogue tree: a root node plus a set of nodes.
1611#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1612#[serde(deny_unknown_fields)]
1613pub struct NpcDialogue {
1614    /// The NPC this tree belongs to (stage-2 ref).
1615    pub npc: NpcId,
1616    /// The entry node id; every node must be reachable from it.
1617    pub root: DialogueId,
1618    /// The dialogue nodes.
1619    pub nodes: Vec<DialogueNode>,
1620}
1621
1622impl NpcDialogue {
1623    /// The ids of the nodes reachable from `roots` by following option `next`
1624    /// edges, ignoring every option gate.
1625    ///
1626    /// **The one authority for "what can this tree show, entered here".** A
1627    /// dialogue tree has more than one entry point — the stage-6 `root`, and
1628    /// every node a quest's `cast` ledger names as a scene — so "reachable" is
1629    /// always relative to a root SET, and each consumer supplies the set its own
1630    /// question is about. `DW0120`/`DW0123` ask about every entry point at once;
1631    /// the cast ledger's `DW0858` asks about the scenes live during one
1632    /// objective. Gates are ignored on purpose: an option's flag gate is
1633    /// `DW0191`'s question, not this one's.
1634    ///
1635    /// Root ids that name no node of this tree contribute nothing (a dangling
1636    /// scene root is `DW0464`, and a dangling stage-6 `root` is `DW0121`).
1637    pub fn reachable_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1638        let by_id: BTreeMap<&'a str, &'a DialogueNode> =
1639            self.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
1640        let mut seen: BTreeSet<&'a str> = BTreeSet::new();
1641        let mut stack: Vec<&'a str> = roots
1642            .iter()
1643            .filter_map(|r| by_id.get_key_value(*r).map(|(k, _)| *k))
1644            .collect();
1645        while let Some(cur) = stack.pop() {
1646            if !seen.insert(cur) {
1647                continue;
1648            }
1649            let Some(node) = by_id.get(cur) else { continue };
1650            for opt in &node.options {
1651                if let Some(next) = &opt.next
1652                    && let Some((k, _)) = by_id.get_key_value(next.as_str())
1653                {
1654                    stack.push(k);
1655                }
1656            }
1657        }
1658        seen
1659    }
1660
1661    /// The objective ids some option reachable from `roots` completes — what a
1662    /// player entering this tree at those scenes can actually finish.
1663    pub fn completes_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1664        let seen = self.reachable_from(roots);
1665        let mut out: BTreeSet<&'a str> = BTreeSet::new();
1666        for node in &self.nodes {
1667            if !seen.contains(node.id.as_str()) {
1668                continue;
1669            }
1670            for opt in &node.options {
1671                for eff in &opt.effects {
1672                    if let DialogueEffect::CompleteObjective { objective } = eff {
1673                        out.insert(objective.as_str());
1674                    }
1675                }
1676            }
1677        }
1678        out
1679    }
1680}
1681
1682/// One dialogue node: text plus branching options.
1683#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1684#[serde(deny_unknown_fields)]
1685pub struct DialogueNode {
1686    /// Node id (unique within this NPC's dialogue).
1687    pub id: DialogueId,
1688    /// The line the NPC speaks.
1689    pub text: String,
1690    /// Branching options; empty closes the dialog.
1691    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1692    pub options: Vec<DialogueOption>,
1693}
1694
1695/// One selectable dialogue option.
1696#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1697#[serde(deny_unknown_fields)]
1698pub struct DialogueOption {
1699    /// Button label.
1700    pub label: String,
1701    /// The full line this button is the caption of (DSL v0.8). Vanilla's dialog button codec
1702    /// (`CommonButtonData`) carries an optional `tooltip` component beside
1703    /// `label`, and the client hangs it on the button as a real hover tooltip —
1704    /// so a caption on the button and the sentence the character actually says
1705    /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
1706    /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
1707    /// scrolls. Player-visible, so it translates like the label
1708    /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
1709    #[serde(default, skip_serializing_if = "Option::is_none")]
1710    pub tooltip: Option<String>,
1711    /// Next node; omitted closes the dialog.
1712    #[serde(default, skip_serializing_if = "Option::is_none")]
1713    pub next: Option<DialogueId>,
1714    /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
1715    /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
1716    /// gated option is hidden until every referenced flag has been set by a
1717    /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
1718    /// option cannot make a critical-path node unreachable (`DW0191`).
1719    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1720    pub requires_flags: Vec<FlagId>,
1721    /// Negative flag gate (DSL v0.6): the option is
1722    /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
1723    /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
1724    /// option counts as *gated* for the `DW0191` deadlock guard: it can be
1725    /// suppressed at any point, so it cannot be the only completing path.
1726    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1727    pub forbids_flags: Vec<FlagId>,
1728    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
1729    /// hold for this gate to be open. The third field of the one gate, carried by
1730    /// every gate consumer — never by the verb that first wanted it. Default
1731    /// empty, so a pre-0.10 campaign is byte-identical.
1732    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1733    pub requires_state: Vec<StateCompare>,
1734    /// Effects fired when this option is chosen.
1735    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1736    pub effects: Vec<DialogueEffect>,
1737    /// What choosing this option does to the story (spec-0025). Required for a
1738    /// **story-weight** beat
1739    /// — an option carrying a `set-flag` effect, which is how a player's choice
1740    /// forks the world (`DW0481`). An option that only walks the tree or
1741    /// completes an objective needs none: the objective already declares one.
1742    #[serde(default, skip_serializing_if = "Option::is_none")]
1743    pub happening: Option<Happening>,
1744}
1745
1746/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
1747/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
1748/// dialogue choice, enabling flag-gated options/objectives/triggers).
1749#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1750#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
1751pub enum DialogueEffect {
1752    /// Marks a stage-5 objective complete.
1753    CompleteObjective {
1754        /// The objective to complete (resolved at the stage-5 boundary).
1755        objective: ObjectiveId,
1756    },
1757    /// Sets a campaign flag (DSL v0.4), mirroring [`Verb::SetFlag`].
1758    SetFlag {
1759        /// The flag to set.
1760        flag: FlagId,
1761    },
1762    /// Cuts the world time (DSL v0.5), mirroring [`Verb::SetTime`].
1763    SetTime {
1764        /// The time state to cut to.
1765        time: WorldTime,
1766    },
1767    /// Cuts the weather (DSL v0.5), mirroring [`Verb::SetWeather`].
1768    SetWeather {
1769        /// The weather state to cut to.
1770        weather: WorldWeather,
1771    },
1772    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
1773    /// [`Verb::SetCheckpoint`] — usable from a dialogue outcome.
1774    SetCheckpoint {
1775        /// The prefab checkpoint anchor the party respawns at.
1776        anchor: AnchorId,
1777        /// Per-player effects re-run on respawn while this checkpoint is active
1778        /// (scene reset). Empty = no hook.
1779        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1780        on_respawn: Vec<QuestEffect>,
1781    },
1782    /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
1783    /// [`Verb::SpawnNpc`] — a character who walks in mid-conversation.
1784    SpawnNpc {
1785        /// The NPC (stage-2 ref) to summon.
1786        npc: NpcId,
1787    },
1788}
1789
1790impl DialogueEffect {
1791    /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
1792    /// (`set-time`/`set-weather`, spec-0010).
1793    pub fn v05_effect(&self) -> Option<&'static str> {
1794        match self {
1795            DialogueEffect::SetTime { .. } => Some("set-time"),
1796            DialogueEffect::SetWeather { .. } => Some("set-weather"),
1797            _ => None,
1798        }
1799    }
1800
1801    /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
1802    /// (`set-checkpoint`, spec-0012; `spawn-npc`).
1803    pub fn v06_effect(&self) -> Option<&'static str> {
1804        match self {
1805            DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
1806            DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
1807            _ => None,
1808        }
1809    }
1810
1811    /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
1812    pub fn spawn_npc(&self) -> Option<&NpcId> {
1813        match self {
1814            DialogueEffect::SpawnNpc { npc } => Some(npc),
1815            _ => None,
1816        }
1817    }
1818
1819    /// **The body this dialogue effect puts into the world**, by id — the
1820    /// dialogue half of [`QuestEffect::body_entry`].
1821    ///
1822    /// A body can enter the world from a conversation as well as from a quest
1823    /// bundle, and a rule about what is standing where has to enumerate **every**
1824    /// entry point or it is a gate with a door beside it. This enum carries no
1825    /// exit at all: nothing a dialogue option does removes a body.
1826    pub fn body_entry(&self) -> Option<&str> {
1827        match self {
1828            DialogueEffect::SpawnNpc { npc } => Some(npc.as_str()),
1829            _ => None,
1830        }
1831    }
1832
1833    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
1834    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
1835        match self {
1836            DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
1837                Some((anchor, on_respawn.as_slice()))
1838            }
1839            _ => None,
1840        }
1841    }
1842
1843    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
1844    /// **effect root 5** ([`crate::effects`]). Named separately from
1845    /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
1846    /// same accessor modulo mutability, which is what lets one macro body generate
1847    /// both.
1848    pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
1849        match self {
1850            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
1851            _ => None,
1852        }
1853    }
1854
1855    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
1856    /// mutably so the localization pass can rewrite the player-visible strings
1857    /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
1858    /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
1859    /// that reaches it read-only needs a way to reach it writable too.
1860    pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
1861        match self {
1862            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
1863            _ => None,
1864        }
1865    }
1866}
1867
1868impl DialogueEffect {
1869    /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
1870    pub fn set_flag(&self) -> Option<&FlagId> {
1871        match self {
1872            DialogueEffect::SetFlag { flag } => Some(flag),
1873            _ => None,
1874        }
1875    }
1876
1877    /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
1878    /// (`set-flag`).
1879    pub fn v04_effect(&self) -> Option<&'static str> {
1880        match self {
1881            DialogueEffect::SetFlag { .. } => Some("set-flag"),
1882            _ => None,
1883        }
1884    }
1885
1886    /// The target time if this is a v0.5 `set-time` dialogue effect.
1887    pub fn set_time(&self) -> Option<WorldTime> {
1888        match self {
1889            DialogueEffect::SetTime { time } => Some(*time),
1890            _ => None,
1891        }
1892    }
1893
1894    /// The target weather if this is a v0.5 `set-weather` dialogue effect.
1895    pub fn set_weather(&self) -> Option<WorldWeather> {
1896        match self {
1897            DialogueEffect::SetWeather { weather } => Some(*weather),
1898            _ => None,
1899        }
1900    }
1901}
1902
1903// ---------------------------------------------------------------------------
1904// Stage 3 — classes
1905// ---------------------------------------------------------------------------
1906
1907/// Stage 3 payload: 1..4 selectable classes.
1908#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1909#[serde(deny_unknown_fields)]
1910pub struct ClassesContent {
1911    /// The selectable classes.
1912    pub classes: Vec<Class>,
1913}
1914
1915/// A player class with a starting kit.
1916#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1917#[serde(deny_unknown_fields)]
1918pub struct Class {
1919    /// Unique class id.
1920    pub id: ClassId,
1921    /// Player-facing name.
1922    pub name: String,
1923    /// Selection-screen blurb.
1924    pub blurb: String,
1925    /// Granted items.
1926    pub kit: Vec<KitItem>,
1927}
1928
1929/// One item in a class kit.
1930///
1931/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
1932/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
1933/// document using them is rejected as an unknown field (`DW0100`).
1934#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1935#[serde(deny_unknown_fields)]
1936pub struct KitItem {
1937    /// Vanilla item id, validated against the pinned 1.21.11 registry.
1938    pub item: String,
1939    /// Stack count.
1940    pub count: u32,
1941    /// Optional display name.
1942    #[serde(default, skip_serializing_if = "Option::is_none")]
1943    pub name: Option<String>,
1944    /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
1945    /// per-player gear by construction — every player who picks the class gets the
1946    /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
1947    /// enters the party, given to the first player to pick this class.
1948    #[serde(default, skip_serializing_if = "Option::is_none")]
1949    pub carrier: Option<Carrier>,
1950    /// **The flask** (DSL v0.8, spec-0016 §1): this kit
1951    /// entry is the class's recovery item, and resting at a bonfire replenishes
1952    /// it to exactly `count`. A campaign that places a `bonfire` and declares no
1953    /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
1954    /// dying an investment, so a souls campaign without one is a build error, not
1955    /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
1956    #[serde(default, skip_serializing_if = "is_false")]
1957    pub flask: bool,
1958    /// **What is in the bottle** (DSL v0.8, spec-0016 §1): the vanilla
1959    /// `minecraft:potion_contents` component of a
1960    /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
1961    /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
1962    /// nothing — which is exactly the placeholder flask this field exists to
1963    /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
1964    /// no `contents` is `DW0487`.
1965    #[serde(default, skip_serializing_if = "Option::is_none")]
1966    pub contents: Option<PotionContents>,
1967}
1968
1969/// The items whose vanilla item definition carries a `minecraft:potion_contents`
1970/// component — the only items a kit `contents` may be declared on (`DW0486`).
1971///
1972/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
1973/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
1974/// misode/mcmeta ref `crates/delvec/data/PROVENANCE.md` pins): exactly these
1975/// four items declare the component, and on any other item the game drops the
1976/// data on the floor.
1977pub const POTION_BEARING_ITEMS: &[&str] = &[
1978    "minecraft:lingering_potion",
1979    "minecraft:potion",
1980    "minecraft:splash_potion",
1981    "minecraft:tipped_arrow",
1982];
1983
1984/// True if `item_id` (optionally un-namespaced) is one of the four
1985/// [`POTION_BEARING_ITEMS`].
1986pub fn is_potion_bearing_item(item_id: &str) -> bool {
1987    let norm = if item_id.contains(':') {
1988        item_id.to_string()
1989    } else {
1990        format!("minecraft:{item_id}")
1991    };
1992    POTION_BEARING_ITEMS.contains(&norm.as_str())
1993}
1994
1995/// The two **instantaneous** status effects: they are applied once, on the tick
1996/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
1997/// `isInstantenous` before any effect instance is ever added), so a `duration` on
1998/// one is a statement the game never reads — `DW0486` says so rather than letting
1999/// an author believe they wrote a thirty-second heal.
2000pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
2001
2002/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
2003/// modelled field for field on vanilla rather than invented: a **named** potion,
2004/// a list of **custom effects**, or both, plus the bottle-colour override.
2005///
2006/// Vanilla resolves a drink as the named potion's effects followed by the custom
2007/// ones, and derives the bottle colour from those effects unless `color`
2008/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
2009/// Potion of Healing II a player would brew, not an approximation of one.
2010#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2011#[serde(deny_unknown_fields)]
2012pub struct PotionContents {
2013    /// A named vanilla potion id (`minecraft:strong_healing`,
2014    /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
2015    /// `potion` registry (`DW0486`).
2016    #[serde(default, skip_serializing_if = "Option::is_none")]
2017    pub potion: Option<String>,
2018    /// Custom effects applied on top of (or instead of) the named potion — the
2019    /// escape hatch for a recovery item vanilla has no brew for.
2020    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2021    pub effects: Vec<PotionEffect>,
2022    /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
2023    /// colour vanilla derives from the effects themselves.
2024    #[serde(default, skip_serializing_if = "Option::is_none")]
2025    pub color: Option<String>,
2026}
2027
2028/// One entry of a potion's `custom_effects` list.
2029#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2030#[serde(deny_unknown_fields)]
2031pub struct PotionEffect {
2032    /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
2033    /// against the pinned registry (`DW0486`).
2034    pub effect: String,
2035    /// How long it lasts, in **ticks** (20 = one second). Required for every
2036    /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
2037    #[serde(default, skip_serializing_if = "Option::is_none")]
2038    pub duration: Option<u32>,
2039    /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
2040    #[serde(default, skip_serializing_if = "Option::is_none")]
2041    pub amplifier: Option<u32>,
2042}
2043
2044impl PotionEffect {
2045    /// True if this effect is applied once on drinking rather than over time.
2046    pub fn is_instant(&self) -> bool {
2047        let norm = crate::registry::namespaced_effect_id(&self.effect);
2048        INSTANT_EFFECTS.contains(&norm.as_str())
2049    }
2050}
2051
2052/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
2053/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
2054/// need is refused — while a duration typed in *milliseconds*, or one that would
2055/// overflow vanilla's int, is caught (`DW0486`).
2056pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
2057
2058/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
2059/// unsigned byte, so 255 is not a policy but the end of the field.
2060pub const MAX_POTION_AMPLIFIER: u32 = 255;
2061
2062/// The largest `seconds` a [`Verb::GiveEffect`] may declare, derived from
2063/// [`MAX_POTION_DURATION_TICKS`] rather than chosen again: the two are the same
2064/// quantity in different units, and a second independently-picked ceiling is how
2065/// two limits for one fact drift apart. ≈13.9 hours, past the 10-hour delve
2066/// ceiling, so nothing a delve can legally need is refused — while a duration
2067/// typed in *ticks* or in milliseconds is caught (`DW0541`).
2068pub const MAX_EFFECT_SECONDS: u32 = MAX_POTION_DURATION_TICKS / 20;
2069
2070/// The canonical English title of the bonfire rest dialog. Baked at emit time
2071/// when the campaign authors no `prompt`, in the
2072/// `world.boundary.message` tradition: a compiler default is not inventoried, an
2073/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
2074pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
2075/// The canonical English label of the **rest and save** option.
2076pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
2077/// The canonical English label of the **save only** option.
2078pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
2079
2080/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
2081/// leaves the compiler's canonical English in place
2082/// ([`QuestEffect::bonfire_labels`]).
2083#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2084pub struct BonfireLabels<'a> {
2085    /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
2086    pub prompt: Option<&'a str>,
2087    /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
2088    pub rest_label: Option<&'a str>,
2089    /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
2090    pub save_label: Option<&'a str>,
2091    /// **Rest and save** button hover tooltip (spec-0078); `None` → none emitted.
2092    pub rest_tooltip: Option<&'a str>,
2093    /// **Save only** button hover tooltip (spec-0078); `None` → none emitted.
2094    pub save_tooltip: Option<&'a str>,
2095}
2096
2097impl BonfireLabels<'_> {
2098    /// The dialog title actually emitted.
2099    pub fn prompt_or_default(&self) -> &str {
2100        self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
2101    }
2102    /// The **rest and save** label actually emitted.
2103    pub fn rest_or_default(&self) -> &str {
2104        self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
2105    }
2106    /// The **save only** label actually emitted.
2107    pub fn save_or_default(&self) -> &str {
2108        self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
2109    }
2110}
2111
2112// ---------------------------------------------------------------------------
2113// Stage 4 — quest-plan
2114// ---------------------------------------------------------------------------
2115
2116/// Stage 4 payload: the quest dependency plan.
2117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2118#[serde(deny_unknown_fields)]
2119pub struct QuestPlanContent {
2120    /// Planned quests (expanded in stage 5).
2121    pub quests: Vec<PlannedQuest>,
2122    /// The quest whose completion ends the campaign.
2123    pub finale: QuestId,
2124    /// The campaign's declared **story forks** (DSL v0.8, spec-0025). Empty/absent = a campaign that claims to have no
2125    /// branch — which the compiler then *verifies* rather than assumes: any flag
2126    /// that gates casts, staging or structure and is set on some playthroughs and
2127    /// not others belongs to no declared point and is `DW0480`.
2128    ///
2129    /// Enumerated branches are the **product of the declared points**, so the
2130    /// branch set is authored and small — never a combinatorial sweep of every
2131    /// flag in the campaign.
2132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2133    pub branch_points: Vec<BranchPoint>,
2134}
2135
2136impl QuestPlanContent {
2137    /// **The ONE authority on which quests are the spine**: the finale and every
2138    /// quest its `depends_on` chain transitively demands — the quests a body
2139    /// cannot reach the finale without.
2140    ///
2141    /// The capability belongs here, on the stage-4 document, because the spine is
2142    /// a fact about the quest plan and about nothing else. It had grown two
2143    /// derivations of the same closure in two files — one inline in
2144    /// [`crate::validate`]'s `DW0132` convergence check, one a private
2145    /// `mandatory_quests` in [`crate::layout`] read by the layout binding and by
2146    /// the critical-path spine obligation. Both were correct and neither said it
2147    /// was the authority, which is exactly the shape a later clean merge turns
2148    /// into two rules that disagree.
2149    ///
2150    /// **Why the closure is taken over the raw `depends_on` edges, unfiltered.**
2151    /// The `validate` copy first dropped every dep naming a quest the plan does
2152    /// not declare. That filtering is not this function's question: a dangling
2153    /// `depends_on` is `DW0112`'s finding, and silently pruning it here would
2154    /// make the set disagree with the document it is derived from. So an id the
2155    /// plan does not declare is reported in the spine and expands no further —
2156    /// and the one reader that could care, `DW0132`, only ever asks whether a
2157    /// **declared** quest is a member, so an undeclared member cannot change its
2158    /// verdict.
2159    ///
2160    /// Cycle-safe by construction (a quest already in the set is not expanded
2161    /// again), so a plan `DW0130` will refuse still yields a set rather than
2162    /// hanging.
2163    ///
2164    /// **Not the same question as the `mandatory` field**, and the name says so
2165    /// deliberately. Today the two sets always coincide, because `DW0132` demands
2166    /// every declared quest be a transitive dependency of the finale and `DW0866`
2167    /// demands every quest set `mandatory: true`. If `mandatory: false` ever
2168    /// becomes legal those coincide no longer, and this function keeps answering
2169    /// the graph question it has always answered.
2170    #[must_use]
2171    pub fn spine(&self) -> BTreeSet<&str> {
2172        let deps: BTreeMap<&str, &[QuestId]> = self
2173            .quests
2174            .iter()
2175            .map(|q| (q.id.as_str(), q.depends_on.as_slice()))
2176            .collect();
2177        let mut spine: BTreeSet<&str> = BTreeSet::new();
2178        let mut stack = vec![self.finale.as_str()];
2179        while let Some(q) = stack.pop() {
2180            if !spine.insert(q) {
2181                continue;
2182            }
2183            for dep in deps.get(q).copied().unwrap_or(&[]) {
2184                stack.push(dep.as_str());
2185            }
2186        }
2187        spine
2188    }
2189
2190    /// **The ONE authority on which quests are elective** (spec-0051): the
2191    /// quests declaring `mandatory: false`.
2192    ///
2193    /// The counterpart to [`Self::spine`], and deliberately a *different*
2194    /// question. `spine` asks what the graph demands; this asks what the author
2195    /// claims. `DW0866`/`DW0867` are exactly the rules that keep the two
2196    /// answers honest about each other, and they can only do that while each
2197    /// side has one derivation — which is the defect the spine function was
2198    /// created to end, and which a second private `!q.mandatory` filter in the
2199    /// compiler would re-introduce on the other half.
2200    #[must_use]
2201    pub fn optional(&self) -> BTreeSet<&str> {
2202        self.quests
2203            .iter()
2204            .filter(|q| !q.mandatory)
2205            .map(|q| q.id.as_str())
2206            .collect()
2207    }
2208}
2209
2210/// One declared story fork (DSL v0.8, spec-0025).
2211///
2212/// A branch point names the flag set the story forks on, the quest at which the
2213/// fork opens, and every branch it offers. Each branch pins the point's whole
2214/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
2215/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
2216/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
2217#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2218#[serde(deny_unknown_fields)]
2219pub struct BranchPoint {
2220    /// Unique branch-point id.
2221    pub id: BranchPointId,
2222    /// The quest at which the fork opens — every branch's divergent content is at
2223    /// or after it in the stage-4 DAG.
2224    pub opens_at: QuestId,
2225    /// The flag set the story forks on. Every branch's `flags` is a subset of
2226    /// this, and the flags it does not list are pinned **unset** on that branch.
2227    pub forks_on: Vec<FlagId>,
2228    /// The alternatives (≥ 2).
2229    pub branches: Vec<BranchDecl>,
2230}
2231
2232/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
2233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2234#[serde(deny_unknown_fields)]
2235pub struct BranchDecl {
2236    /// Unique branch id (campaign-wide — it names the emitted chronicle file).
2237    pub id: BranchId,
2238    /// The subset of the point's `forks_on` that is SET on this branch. The rest
2239    /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
2240    /// option" branch — as long as it is genuinely reachable.
2241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2242    pub flags: Vec<FlagId>,
2243    /// Where this branch goes: either the `quest/<kebab>` the branches converge
2244    /// at, or the `ending/<kebab>` this branch runs to.
2245    ///
2246    /// One field, not two mutually exclusive ones, because the **id prefix
2247    /// already says which it is** — the same convention every cross-stage
2248    /// reference in the DSL uses. That is what makes "exactly one of them" an
2249    /// unrepresentable state rather than a rule some diagnostic has to police: a
2250    /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
2251    /// the ordinary malformed-id `DW0110`, and one that names nothing is the
2252    /// ordinary dangling-reference `DW0112`.
2253    pub leads_to: String,
2254}
2255
2256impl BranchDecl {
2257    /// The convergence quest, if [`Self::leads_to`] names one.
2258    pub fn converges_at(&self) -> Option<QuestId> {
2259        let q = QuestId(self.leads_to.clone());
2260        q.is_valid_syntax().then_some(q)
2261    }
2262
2263    /// The ending, if [`Self::leads_to`] names one.
2264    pub fn ending(&self) -> Option<EndingId> {
2265        let e = EndingId(self.leads_to.clone());
2266        e.is_valid_syntax().then_some(e)
2267    }
2268}
2269
2270/// What a story node does to the story (DSL v0.8, spec-0025).
2271///
2272/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
2273/// design that never got written down node by node cannot compile. It is
2274/// **node-local on purpose** — there is no parallel per-branch script document
2275/// that could itself drift from the graph.
2276///
2277/// `text` is authoring/validation metadata, never shown to a player, so it is
2278/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
2279/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
2280/// chronicle is assembled from.
2281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2282#[serde(deny_unknown_fields)]
2283pub struct Happening {
2284    /// The structured event verb — the machine-decidable part.
2285    pub verb: HappeningVerb,
2286    /// One line of prose stating what this node does to the story.
2287    pub text: String,
2288    /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
2289    /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
2290    /// story token the campaign tracks by hand. Optional, because not every beat
2291    /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
2292    /// over the beats that name one.
2293    #[serde(default, skip_serializing_if = "Option::is_none")]
2294    pub subject: Option<String>,
2295}
2296
2297/// The subject a beat is about, and whether the document said so (spec-0071 §3).
2298///
2299/// Answered by [`QuestEffect::happening_subject`], the one derivation. `derived`
2300/// is carried rather than dropped because the two readers want different things
2301/// from it: the namespace check reports on what an author **wrote**, and the
2302/// chronicle reasons over what the beat **is about**.
2303#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2304pub struct HappeningSubject<'a> {
2305    /// The subject id (`npc/`, `actor/`, `wave/`, `anchor/` or an `item/` label).
2306    pub id: &'a str,
2307    /// `true` when the effect's own single object supplied it, `false` when the
2308    /// `happening` states it.
2309    pub derived: bool,
2310}
2311
2312/// The structured event vocabulary (DSL v0.8, spec-0025).
2313///
2314/// Deliberately small and closed. These ten verbs are what make a subset of
2315/// narrative errors machine-decidable per branch (`DW0485`); everything else a
2316/// beat means lives in [`Happening::text`], which the compiler never interprets.
2317/// Extend only when a real campaign cannot state its beat with what is here.
2318#[derive(
2319    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2320)]
2321#[serde(rename_all = "kebab-case")]
2322pub enum HappeningVerb {
2323    /// The subject is killed / destroyed. Terminal: nothing the subject does may
2324    /// follow it on the same branch.
2325    Dies,
2326    /// The subject comes through alive — the explicit counterpart of `dies`,
2327    /// which is what lets a branch state that somebody *did not* die.
2328    Survives,
2329    /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
2330    Departs,
2331    /// The subject enters the stage.
2332    Arrives,
2333    /// Somebody learns a fact — the true-information beat.
2334    Learns,
2335    /// Somebody comes to believe something (whether or not it is true) — the beat
2336    /// that carries a wrong belief forward, which is where branch drift shows.
2337    Believes,
2338    /// The party (or the subject) gains a thing.
2339    Gains,
2340    /// The party (or the subject) loses a thing. A second `loses` with no
2341    /// intervening `gains` is spending what is already spent (`DW0485`).
2342    Loses,
2343    /// A way is opened.
2344    Opens,
2345    /// A way is sealed. Cleared by `opens`.
2346    Seals,
2347}
2348
2349/// One planned quest (dependency-graph node).
2350#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2351#[serde(deny_unknown_fields)]
2352pub struct PlannedQuest {
2353    /// Unique quest id.
2354    pub id: QuestId,
2355    /// Human-readable goal.
2356    pub goal: String,
2357    /// Area this quest takes place in (stage-1 ref).
2358    pub area: AreaId,
2359    /// NPCs involved (stage-2 refs).
2360    pub npcs: Vec<NpcId>,
2361    /// Prerequisite quests; edges must form a DAG.
2362    pub depends_on: Vec<QuestId>,
2363    /// `false` declares an optional quest (spec-0051).
2364    pub mandatory: bool,
2365    /// Act number (informational).
2366    pub act: u32,
2367}
2368
2369// ---------------------------------------------------------------------------
2370// Stage 5 — quests
2371// ---------------------------------------------------------------------------
2372
2373/// Stage 5 payload: quest expansions.
2374#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2375#[serde(deny_unknown_fields)]
2376pub struct QuestsContent {
2377    /// The expanded quests (1:1 with stage 4).
2378    pub quests: Vec<Quest>,
2379    /// **How loudly the campaign guides** (spec-0093): the default for every
2380    /// objective's `marker` and `announcement`. Absent = both `shown`, which is
2381    /// what every campaign written before the block existed gets, byte for byte.
2382    #[serde(default, skip_serializing_if = "Guidance::is_default")]
2383    pub guidance: Guidance,
2384    /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
2385    /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
2386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2387    pub waves: Vec<Wave>,
2388    /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
2389    /// watches an anchor for a strike / use / approach event and fires a bundle
2390    /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
2391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2392    pub triggers: Vec<EnvTrigger>,
2393    /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
2394    /// compiler-emitted per-tick teleport. Distinct from stage-2 NPCs
2395    /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
2396    /// staging effects. Empty/absent before v0.6.
2397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2398    pub actors: Vec<Actor>,
2399    /// Traps (DSL v0.6, spec-0011; command payloads spec-0022): environmental
2400    /// hazards, each at one point anchor an area's prefab provides. Each says
2401    /// what springs the trap, what it then does (a command `payload`, a legacy
2402    /// dispenser `effect`, or both), how dangerous it is, how it is disarmed and
2403    /// whether it re-arms. Empty/absent in pre-0.6 campaigns, so a v0.5-or-earlier campaign that declares none stays
2404    /// byte-identical.
2405    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2406    pub traps: Vec<Trap>,
2407    /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
2408    /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
2409    /// campaigns, so a campaign that declares none stays
2410    /// byte-identical.
2411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2412    pub shortcuts: Vec<Shortcut>,
2413    /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
2414    /// springs them". Empty/absent in pre-0.6 campaigns.
2415    ///
2416    /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
2417    /// each ambush into `triggers`, so the canonical form of a campaign is its
2418    /// **desugared** form. That is what keeps the canonical round-trip idempotent
2419    /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
2420    /// second time and duplicate the trigger ids), and it means the sugar exists
2421    /// at exactly one layer boundary — the authored `.json` — with nothing
2422    /// downstream needing to know it was ever there. The list itself is kept in
2423    /// memory so diagnostics can name the ambush the author wrote.
2424    /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
2425    /// Empty/absent in pre-0.6 campaigns.
2426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2427    pub timed_gates: Vec<TimedGate>,
2428    /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
2429    /// given contents at world init. Empty/absent in pre-0.6 campaigns
2430    ///.
2431    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2432    pub loot: Vec<Loot>,
2433    /// Runtime state data (DSL v0.10, spec-0031): named, scoped, integer-valued
2434    /// counters the campaign sets, adds to and clears at runtime, and compares
2435    /// against in any gate. A campaign that declares none emits none of it.
2436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2437    pub state: Vec<StateDecl>,
2438    /// **The campaign's death beat** (DSL v0.10, spec-0031): effects run at the
2439    /// moment a player dies, for that player. Effect root **R7**
2440    /// ([`crate::EffectRootKind::OnDeath`]); a campaign that declares none emits
2441    /// none of it.
2442    ///
2443    /// **Why this is campaign-wide and not a field on a checkpoint.** The engine
2444    /// already has `on_respawn`, and it hangs off a `set-checkpoint` because
2445    /// *where you come back* is a property of the checkpoint. *That you died* is
2446    /// not: it is true at every point of the delve, under every checkpoint, and a
2447    /// bundle repeated on each checkpoint would be the same content written N
2448    /// times with N chances to forget one. So death is a moment in the campaign,
2449    /// and this is the one place it is named. Anything that should only happen in
2450    /// some phase of the delve is expressed by the ordinary per-effect
2451    /// `requires_flags` / `forbids_flags` gate every other root already carries —
2452    /// no second gating surface.
2453    ///
2454    /// **Audience is the dying player** (`Audience::Solo`, the audience
2455    /// `on_respawn` and `on_caught` already use): a death is one player's, and
2456    /// re-broadcasting it to the party would duplicate their narration and their
2457    /// kit. A beat the whole party should see is a `narrate` addressed by the
2458    /// author to the party through the effect's own vocabulary, not a different
2459    /// default here.
2460    ///
2461    /// **Timing.** It fires on the death edge while the player is still a corpse
2462    /// (`Health: 0.0f`, on the death screen) — see
2463    /// `emit::emit_checkpoint_functions`. That is the difference between this and
2464    /// `on_respawn`, which deliberately waits for the player to come back.
2465    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2466    pub on_death: Vec<QuestEffect>,
2467    /// Lethal volumes (DSL v0.10, spec-0031): declared boxes that kill whatever
2468    /// enters them. Empty/absent in pre-0.10 campaigns, so a
2469    /// campaign that declares none stays byte-identical.
2470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2471    pub lethal_volumes: Vec<LethalVolume>,
2472    /// Shops (DSL v0.10, spec-0032): interaction points that open a list of
2473    /// gated offers. Empty/absent in pre-0.10 campaigns, so a
2474    /// campaign that declares none stays byte-identical.
2475    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2476    pub shops: Vec<Shop>,
2477    /// Recovery stakes (DSL v0.10, spec-0032): what a death forfeits, where the
2478    /// marker lands, and how it comes back. Empty/absent in pre-0.10 campaigns
2479    ///, so a campaign that declares none stays byte-identical.
2480    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2481    pub stakes: Vec<Stake>,
2482    /// Assemblies (spec-0082): fixed things built of display entities that
2483    /// play clips from a library rig, can be struck in melee, and strike back
2484    /// at a player who stands where they reach. Appear on `spawn-assembly`,
2485    /// leave on `despawn-assembly`. Empty/absent = nothing emitted.
2486    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2487    pub assemblies: Vec<Assembly>,
2488    /// Loops (spec-0086): slabs whose crossing returns a body by a whole-block
2489    /// offset to an identical earlier section, held while a party gate is open.
2490    /// Empty/absent for every campaign that declares none, so such a campaign
2491    /// stays byte-identical.
2492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2493    pub loops: Vec<Loop>,
2494    #[serde(default, skip_serializing)]
2495    pub ambushes: Vec<Ambush>,
2496    /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
2497    /// authored `ambushes` are deliberately KEPT after expansion so validation
2498    /// and the counterplay proof can attribute diagnostics to the ambush the
2499    /// author actually wrote; this flag is what makes a second expansion a no-op.
2500    #[serde(skip)]
2501    pub ambushes_expanded: bool,
2502}
2503
2504/// Whether a piece of guidance is put in front of the player (spec-0093).
2505#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2506#[serde(rename_all = "kebab-case")]
2507pub enum Visibility {
2508    /// Shown — the marker is summoned, the announcement is printed.
2509    #[default]
2510    Shown,
2511    /// Hidden — nothing is summoned or printed; the objective still adjudicates.
2512    Hidden,
2513}
2514
2515impl Visibility {
2516    /// `true` for [`Visibility::Shown`]. Takes a reference so it doubles as the
2517    /// serde skip predicate on [`Guidance`]'s two fields.
2518    pub fn is_shown(&self) -> bool {
2519        *self == Visibility::Shown
2520    }
2521}
2522
2523/// The campaign's guidance defaults (spec-0093): what an objective gets when it
2524/// states no `marker` or `announcement` of its own.
2525///
2526/// Two values, both defaulting to `shown`, so a document that omits the block
2527/// is the document every campaign already was. The objective's own field wins
2528/// over the campaign's; there is no third level, because a quest is where a
2529/// beat is booked and not the object a lantern hangs over.
2530#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2531#[serde(deny_unknown_fields)]
2532pub struct Guidance {
2533    /// The default for every `interact` (without a `prop`) and `reach-anchor`
2534    /// objective's glowing marker.
2535    #[serde(default, skip_serializing_if = "Visibility::is_shown")]
2536    pub markers: Visibility,
2537    /// The default for every objective's announcement — the `New objective`
2538    /// line, the hint's line, the cue sound and the `Objective complete` line.
2539    #[serde(default, skip_serializing_if = "Visibility::is_shown")]
2540    pub announcements: Visibility,
2541}
2542
2543impl Guidance {
2544    /// Serde skip predicate: both `shown` needs no block on the wire.
2545    pub fn is_default(&self) -> bool {
2546        *self == Guidance::default()
2547    }
2548}
2549
2550impl QuestsContent {
2551    /// Every environment trigger this stage produces: the authored `triggers`
2552    /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
2553    /// declared order.
2554    ///
2555    /// **This is the single trigger authority.** Validation, the l10n
2556    /// inventory, the flag/wave producer scans, the nav proofs and emission all
2557    /// read triggers through it, so an ambush behaves exactly like the trigger
2558    /// an author would otherwise hand-write — there is no second code path for
2559    /// the sugar to drift down. Deterministic (declaration order, no hashing).
2560    pub fn all_triggers(&self) -> Vec<EnvTrigger> {
2561        let mut out = self.triggers.clone();
2562        out.extend(self.ambushes.iter().map(Ambush::to_trigger));
2563        out
2564    }
2565
2566    /// **Does the campaign itself answer a right-click at `anchor`?**
2567    ///
2568    /// One predicate, read by both consumers of the press-answer rule, so they can
2569    /// never disagree about what "the campaign answered it" means: the compiler's
2570    /// synthesis (`plan::collect_press_answers`, which stands down where this is
2571    /// true) and the obligation on a shortcut door (`DW0429`, which fires where it
2572    /// is false). Split across the two crates they would drift, and the drift
2573    /// would read as "the compiler refused a door I answered".
2574    ///
2575    /// Deliberately the widest reading — *any* `use` trigger anchored there.
2576    /// Pressing it already does something the author chose, and the engine does
2577    /// not adjudicate whether what they chose counts as an answer.
2578    pub fn answers_press_at(&self, anchor: &str) -> bool {
2579        self.all_triggers()
2580            .iter()
2581            .any(|t| matches!(t.on, TriggerOn::Use) && t.at_anchor() == Some(anchor))
2582    }
2583
2584    /// Desugar every `ambush` into a real environment trigger and clear the
2585    /// ambush list (spec-0016 §3). Called once, by
2586    /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
2587    /// (a second call sees no ambushes left to expand).
2588    pub fn expand_ambushes(&mut self) {
2589        if self.ambushes_expanded || self.ambushes.is_empty() {
2590            return;
2591        }
2592        self.triggers = self.all_triggers();
2593        self.ambushes_expanded = true;
2594    }
2595
2596    /// The declared datum with this id, if any (DSL v0.10).
2597    pub fn state_decl(&self, id: &str) -> Option<&StateDecl> {
2598        self.state.iter().find(|s| s.id.as_str() == id)
2599    }
2600
2601    /// The declared stake with this id, if any (DSL v0.10, spec-0032).
2602    pub fn stake_decl(&self, id: &str) -> Option<&Stake> {
2603        self.stakes.iter().find(|s| s.id.as_str() == id)
2604    }
2605
2606    /// The declared assembly with this id, if any (spec-0082).
2607    pub fn assembly_decl(&self, id: &str) -> Option<&Assembly> {
2608        self.assemblies.iter().find(|a| a.id.as_str() == id)
2609    }
2610}
2611
2612// ---------------------------------------------------------------------------
2613// Stage 5 — runtime state (DSL v0.10, spec-0031)
2614// ---------------------------------------------------------------------------
2615
2616/// Who holds a datum's value (DSL v0.10, spec-0031).
2617///
2618/// **Declared, never inferred.** A datum's multiplayer semantics is the one
2619/// thing about it that cannot be recovered from its uses: a purse read on a
2620/// `talk-to` looks identical whether every player has their own or the party
2621/// shares one, and the difference decides the whole design. spec-0031 states it
2622/// as a rule, and the type makes it un-omittable — there is no default.
2623#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2624#[serde(rename_all = "kebab-case")]
2625pub enum StateScope {
2626    /// Each player holds their own value. Read and written against the acting
2627    /// player, so a bundle with no acting player (the scheduler) cannot touch one
2628    /// (`DW0503`).
2629    Player,
2630    /// One value the whole party shares — the same holder story flags use
2631    /// (spec-0018). Readable and writable from every audience, including the
2632    /// scheduler.
2633    Party,
2634}
2635
2636impl StateScope {
2637    /// The wire token (`player` / `party`).
2638    pub fn token(self) -> &'static str {
2639        match self {
2640            StateScope::Player => "player",
2641            StateScope::Party => "party",
2642        }
2643    }
2644}
2645
2646/// One declared runtime datum (DSL v0.10, spec-0031): a named, scoped,
2647/// integer-valued counter.
2648///
2649/// This is what [`FlagId`] is not. A flag is boolean, party-wide and
2650/// **monotonic** — no verb clears one — which is exactly right for "this has
2651/// happened" and useless for a balance, a floor number, or "a ride is in
2652/// progress". A datum clears, counts down as well as up, and states its scope.
2653#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2654#[serde(deny_unknown_fields)]
2655pub struct StateDecl {
2656    /// Unique datum id (`state/<kebab>`).
2657    pub id: StateId,
2658    /// Who holds the value. Required — see [`StateScope`].
2659    pub scope: StateScope,
2660    /// The value the datum starts at, and the value `clear-state` returns it to.
2661    /// Defaults to `0`.
2662    ///
2663    /// One field rather than a separate `initial` and `cleared`: "the value this
2664    /// datum has when nothing has happened to it yet" is one fact, and two fields
2665    /// would let a campaign declare a datum that can never be returned to its own
2666    /// starting state.
2667    #[serde(default, skip_serializing_if = "is_zero_i32")]
2668    pub initial: i32,
2669    /// Free prose: what this datum means. Never machine-checked, never shown to a
2670    /// player — the forcing function that makes an author say what the number is,
2671    /// the same role `cast[].doing` plays.
2672    #[serde(default, skip_serializing_if = "Option::is_none")]
2673    pub note: Option<String>,
2674    /// The player-visible name of this datum (DSL v0.10, spec-0032).
2675    ///
2676    /// **A named datum is a currency.** There is no separate `currencies` section
2677    /// and there deliberately is not: a purse is a runtime datum that the player
2678    /// can see, and "the player can see it" is a property of the datum, not a
2679    /// different object class. A second struct carrying `id` + `scope` +
2680    /// `initial` + `name` would be a private copy of this one, which is the defect
2681    /// CLAUDE.md names second.
2682    ///
2683    /// Present ⇒ every `set-state` / `add-state` / `clear-state` on this datum
2684    /// also states the new balance to whoever holds it, on the action bar, as
2685    /// `<name>: <value>` with the value carried by vanilla's own `score`
2686    /// component. Absent ⇒ the datum is silent bookkeeping and emission is exactly
2687    /// what it was.
2688    ///
2689    /// Player-visible, so it is inventoried under `state.<id>.name` and
2690    /// translated like any other authored line.
2691    #[serde(default, skip_serializing_if = "Option::is_none")]
2692    pub name: Option<String>,
2693    /// Where this datum **stands** on screen between changes (spec-0076).
2694    ///
2695    /// The announcement `name` buys fades with the action bar; a datum that
2696    /// declares `display` also occupies a vanilla display slot, so its balance is
2697    /// on screen at every moment for every player. Declared, never automatic: a
2698    /// creator may want a named tally that is spoken only when it moves, and the
2699    /// engine does not decide which of two named datums is the purse.
2700    ///
2701    /// Present ⇒ `setup` heads the datum's objective with its translated `name`,
2702    /// paints the value gold, and puts the objective in the slot. Requires `name`
2703    /// (the slot's heading is the display name, and without one it would show the
2704    /// objective's id) and a `player` scope (the sidebar hides `#`-prefixed
2705    /// holders, which is what a `party` datum's value lives on); one datum per
2706    /// campaign may stand, because the slot holds one objective — all three are
2707    /// `DW0919`. Absent ⇒ the datum announces itself and stands nowhere.
2708    #[serde(default, skip_serializing_if = "Option::is_none")]
2709    pub display: Option<StateDisplay>,
2710}
2711
2712/// The vanilla display slot a named datum stands in (spec-0076).
2713///
2714/// One value, because vanilla has one slot that stands for the viewer: `list`
2715/// shows only while the tab key is held and `below_name` draws under *other*
2716/// players' name tags, never over the viewer's own body. A second variant is
2717/// added when the pinned game offers a second standing surface, not before.
2718#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2719#[serde(rename_all = "kebab-case")]
2720pub enum StateDisplay {
2721    /// The right-hand sidebar: a heading (the datum's `name`) and one line per
2722    /// player, each showing that player's own balance.
2723    Sidebar,
2724}
2725
2726impl StateDisplay {
2727    /// The `minecraft:scoreboard_slot` token the slot is addressed by.
2728    pub fn slot(self) -> &'static str {
2729        match self {
2730            StateDisplay::Sidebar => "sidebar",
2731        }
2732    }
2733}
2734
2735/// serde `skip_serializing_if` helper: skip a zero `i32` (`StateDecl.initial`).
2736fn is_zero_i32(v: &i32) -> bool {
2737    *v == 0
2738}
2739
2740/// How a [`StateCompare`] relates a datum to its operand (DSL v0.10).
2741///
2742/// Four operators, not six: over integers `less-than n` is `at-most n-1` and
2743/// `greater-than n` is `at-least n+1`, so the extra spellings would add a second
2744/// way to say one thing and a second emission path to keep honest.
2745#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2746#[serde(rename_all = "kebab-case")]
2747pub enum CompareOp {
2748    /// The datum is exactly `value`.
2749    Equals,
2750    /// The datum is anything but `value`.
2751    NotEquals,
2752    /// The datum is `value` or more.
2753    AtLeast,
2754    /// The datum is `value` or less.
2755    AtMost,
2756}
2757
2758impl CompareOp {
2759    /// The wire token (`equals` / `not-equals` / `at-least` / `at-most`).
2760    pub fn token(self) -> &'static str {
2761        match self {
2762            CompareOp::Equals => "equals",
2763            CompareOp::NotEquals => "not-equals",
2764            CompareOp::AtLeast => "at-least",
2765            CompareOp::AtMost => "at-most",
2766        }
2767    }
2768}
2769
2770/// What one of the DSL v0.10 state verbs does to a datum — the value half of
2771/// [`QuestEffect::writes_state`].
2772#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2773pub enum StateWrite {
2774    /// `set-state`: the datum becomes this value.
2775    Set(i32),
2776    /// `add-state`: the datum moves by this signed amount.
2777    Add(i32),
2778    /// `clear-state`: the datum returns to its declared `initial`.
2779    Clear,
2780}
2781
2782/// One numeric term of a gate (DSL v0.10, spec-0031): *this datum compares thus
2783/// to this value*.
2784///
2785/// It rides [`Gate`](crate::gate::Gate) — the shared gate, carried by every
2786/// consumer of `requires_flags`/`forbids_flags` — and not any one verb. The
2787/// comparison's consumers are exactly the gate's consumers ("this door opens at
2788/// 500", "this line is withheld below 200", "this lever does nothing while the
2789/// car is moving"), so hanging it off the first verb that asked would leave the
2790/// second with no surface and make a second bespoke field look like the fix.
2791#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2792#[serde(deny_unknown_fields)]
2793pub struct StateCompare {
2794    /// The datum to read. Must be declared in the stage-5 `state` list
2795    /// (`DW0502`).
2796    pub state: StateId,
2797    /// How to compare it.
2798    pub op: CompareOp,
2799    /// What to compare it against.
2800    pub value: i32,
2801}
2802
2803impl StateCompare {
2804    /// Whether a datum holding `v` satisfies this comparison.
2805    pub fn holds(&self, v: i32) -> bool {
2806        match self.op {
2807            CompareOp::Equals => v == self.value,
2808            CompareOp::NotEquals => v != self.value,
2809            CompareOp::AtLeast => v >= self.value,
2810            CompareOp::AtMost => v <= self.value,
2811        }
2812    }
2813}
2814
2815/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
2816/// barrel the prefab already placed.
2817///
2818/// The container is **hardware the prefab authored**, exactly like a trap's
2819/// dispenser: this declaration gives an already-placed, already-lit, already
2820/// composed piece of furniture its contents. The compiler never places the
2821/// container itself — if the anchor's cell does not already hold one, that is a
2822/// content defect and a build error (`DW0431`), not something to paper over by
2823/// setblock-ing a chest into a wall.
2824#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2825#[serde(deny_unknown_fields)]
2826pub struct Loot {
2827    /// Unique loot id (`loot/<kebab>`).
2828    pub id: LootId,
2829    /// The anchor whose cell holds the container to fill.
2830    pub anchor: AnchorId,
2831    /// Contents, in declaration order. Slot assignment is positional and
2832    /// deterministic — the first entry lands in `container.0`, the second in
2833    /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
2834    pub items: Vec<LootItem>,
2835}
2836
2837/// One stack inside a [`Loot`] container.
2838#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2839#[serde(deny_unknown_fields)]
2840pub struct LootItem {
2841    /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
2842    /// 1.21.11 item registry (`DW0143`).
2843    pub item: String,
2844    /// Stack size. Defaults to 1.
2845    #[serde(default = "one_u32")]
2846    pub count: u32,
2847    /// Optional custom item name. Enters the l10n string inventory exactly like
2848    /// a class kit item's name.
2849    #[serde(default, skip_serializing_if = "Option::is_none")]
2850    pub name: Option<String>,
2851    /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
2852    /// 1.21 `minecraft:enchantments` item component — or, on a
2853    /// `minecraft:enchanted_book`, `minecraft:stored_enchantments`
2854    /// ([`enchantment_component`]).
2855    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2856    pub enchantments: BTreeMap<String, u32>,
2857}
2858
2859fn one_u32() -> u32 {
2860    1
2861}
2862
2863/// A stage-5 trap (DSL v0.6, spec-0011; command payloads spec-0022): an
2864/// environmental hazard at one cell of a placed piece.
2865///
2866/// **What the prefab has to provide is one point anchor with the trigger block
2867/// in its cell, and for most traps that is all.** [`Trap::at`] names the
2868/// trigger/hazard cell; the piece places the plate, tripwire or trapped chest
2869/// there (`DW0917`); the compiler models it as a hazard for the completability
2870/// proofs (`DW0342`) and, for a disarmable trap, emits the disarm affordance. A
2871/// [`payload`](Trap::payload) trap needs nothing else: **the compiler owns the
2872/// detection**, emitting a per-tick, edge-latched `execute … if entity @a[<cell>]` and running the authored effect
2873/// bundle from it.
2874///
2875/// Two things a piece must pre-wire, each for one case and neither for the
2876/// common one:
2877///
2878/// * the legacy [`effect`](Trap::effect) — a `dispense` payload the prefab's own
2879///   redstone fires — needs the anchor's `dispenser` socket cell, which the
2880///   compiler fills. That is the case "harm is redstone-native" (spec-0011) was
2881///   written about, and the only one in which no detection is emitted.
2882/// * a **flag-gated** trap ([`requires_flags`](Trap::requires_flags) /
2883///   [`forbids_flags`](Trap::forbids_flags)) needs the anchor's `trigger_block`,
2884///   because gating removes the trigger block from the world while the gate is
2885///   shut and puts it back verbatim (`DW0363`).
2886///
2887/// Player-vs-mob distinguishing matters in a sealed box-garden with controlled
2888/// mobs, so `trapped-chest` (opened by a player) is called out as the only
2889/// player-distinct trigger.
2890#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2891#[serde(deny_unknown_fields)]
2892pub struct Trap {
2893    /// Unique trap id (`trap/<kebab>`).
2894    pub id: TrapId,
2895    /// **The point anchor this trap sits on** — any anchor an area's prefab
2896    /// provides, whatever it is called. Its cell is the trigger/hazard cell the
2897    /// compiler models, and for a `payload` trap that cell, holding the block
2898    /// its [`trigger`](Trap::trigger) names, is the whole of what the piece has
2899    /// to provide: detection is the compiler's, the block is the piece's
2900    /// (`DW0917`).
2901    ///
2902    /// The anchor additionally needs a `dispenser` socket for a legacy
2903    /// [`effect`](Trap::effect) trap, and a `trigger_block` for a flag-gated one
2904    /// (`DW0363`). `anchor/trap` is the name the shipped pieces use, and a name
2905    /// is all it is.
2906    pub at: AnchorId,
2907    /// What springs the trap (all redstone-native).
2908    pub trigger: TrapTrigger,
2909    /// The **legacy** redstone consequence (spec-0011): a static dispenser
2910    /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
2911    /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
2912    /// so existing campaigns build unchanged. Optional since spec-0022; a trap
2913    /// must declare `effect`, `payload`, or both (`DW0440`).
2914    #[serde(default, skip_serializing_if = "Option::is_none")]
2915    pub effect: Option<TrapEffect>,
2916    /// The **command payload** (spec-0022): an ordered effect list in the same
2917    /// vocabulary quests use, run when the trigger fires. This is where a trap's
2918    /// consequence lives now — the compiler owns the detection tick and the
2919    /// effect vocabulary, so a trap's payload is authored like any other effect
2920    /// bundle rather than built out of dust and repeaters. Expressiveness moves
2921    /// from "what dust can carry" to "what the effect vocabulary can say":
2922    /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
2923    /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
2924    ///
2925    /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
2926    /// emitted before (byte-identical).
2927    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2928    pub payload: Vec<QuestEffect>,
2929    /// How dangerous the trap is. A `lethal` trap on the forced critical path
2930    /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
2931    /// carry none. Defaults to `harmful`.
2932    #[serde(default)]
2933    pub lethality: Lethality,
2934    /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
2935    /// turn the trap off — setting a flag and emptying the dispenser — before the
2936    /// trap cell is forced.
2937    #[serde(default, skip_serializing_if = "Option::is_none")]
2938    pub disarm: Option<TrapDisarm>,
2939    /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
2940    /// spent — the survivability path); `rearm` = re-fires each trigger (default).
2941    #[serde(default)]
2942    pub reset: TrapReset,
2943    /// Flags that must be set before the trap is considered active (mirrors
2944    /// [`EnvTrigger::requires_flags`]). Default empty.
2945    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2946    pub requires_flags: Vec<FlagId>,
2947    /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
2948    /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
2949    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2950    pub forbids_flags: Vec<FlagId>,
2951    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2952    /// hold for this gate to be open. The third field of the one gate, carried by
2953    /// every gate consumer — never by the verb that first wanted it. Default
2954    /// empty, so a pre-0.10 campaign is byte-identical.
2955    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2956    pub requires_state: Vec<StateCompare>,
2957}
2958
2959impl Trap {
2960    /// `(item, count)` if this trap declares a legacy `dispense` effect.
2961    pub fn dispense(&self) -> Option<(&str, u32)> {
2962        match &self.effect {
2963            Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
2964            None => None,
2965        }
2966    }
2967
2968    /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
2969    /// forced critical path).
2970    pub fn is_lethal(&self) -> bool {
2971        matches!(self.lethality, Lethality::Lethal)
2972    }
2973}
2974
2975/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
2976/// redstone-native — the hardware fires without any command — so the compiler
2977/// emits no detection for them; it only models the trigger cell as a hazard and
2978/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
2979/// is deliberately *not* a trap trigger: it is already fully expressible as an
2980/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
2981#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2982#[serde(rename_all = "kebab-case")]
2983pub enum TrapTrigger {
2984    /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
2985    PressurePlate,
2986    /// A tripwire line: any entity crossing it. Auto-rearms.
2987    Tripwire,
2988    /// A trapped chest: a *player opening it* (comparator pulse). The only
2989    /// player-distinct trigger — a controlled mob cannot spring it.
2990    TrappedChest,
2991}
2992
2993impl TrapTrigger {
2994    /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
2995    pub fn kind(&self) -> &'static str {
2996        match self {
2997            TrapTrigger::PressurePlate => "pressure-plate",
2998            TrapTrigger::Tripwire => "tripwire",
2999            TrapTrigger::TrappedChest => "trapped-chest",
3000        }
3001    }
3002
3003    /// Whether `block` (an id, with or without its blockstate) is the hardware
3004    /// this trigger kind names: the block the party sees and springs. A plate
3005    /// is any `*_pressure_plate`, a tripwire is the string itself
3006    /// (`minecraft:tripwire`, not the hook), a trapped chest is
3007    /// `minecraft:trapped_chest`.
3008    pub fn is_trigger_block(&self, block: &str) -> bool {
3009        let id = block.split('[').next().unwrap_or(block);
3010        match self {
3011            TrapTrigger::PressurePlate => id.ends_with("_pressure_plate"),
3012            TrapTrigger::Tripwire => id == "minecraft:tripwire",
3013            TrapTrigger::TrappedChest => id == "minecraft:trapped_chest",
3014        }
3015    }
3016
3017    /// The block [`TrapTrigger::is_trigger_block`] accepts, as a refusal names it.
3018    pub fn trigger_block_name(&self) -> &'static str {
3019        match self {
3020            TrapTrigger::PressurePlate => "a pressure plate (`minecraft:*_pressure_plate`)",
3021            TrapTrigger::Tripwire => "a tripwire string (`minecraft:tripwire`)",
3022            TrapTrigger::TrappedChest => "a trapped chest (`minecraft:trapped_chest`)",
3023        }
3024    }
3025}
3026
3027impl TrapTrigger {
3028    /// The trigger kinds a body fires by walking onto them — a plate and a
3029    /// tripwire. A trapped chest is opened, not stepped on.
3030    pub const STEPPED: [TrapTrigger; 2] = [TrapTrigger::PressurePlate, TrapTrigger::Tripwire];
3031}
3032
3033/// **Every block of the pinned registry that a step fires**, sorted: the
3034/// registry's ids that [`TrapTrigger::is_trigger_block`] accepts for a
3035/// [`TrapTrigger::STEPPED`] kind. Read from `crates/dsl/data/blocks-1.21.11.json`
3036/// rather than listed, so a pin that adds a plate adds it here;
3037/// `crates/delvec/tests/stepped_blocks_tag.rs` holds the set equal to vanilla's
3038/// own `#pressure_plates` tag plus the tripwire string.
3039pub fn stepped_blocks() -> Vec<&'static str> {
3040    crate::blocks::BlockRegistry::v1_21_11()
3041        .ids()
3042        .filter(|id| TrapTrigger::STEPPED.iter().any(|k| k.is_trigger_block(id)))
3043        .collect()
3044}
3045
3046/// Whether `block` — an id, bare or namespaced, with or without a blockstate —
3047/// is one a step fires ([`stepped_blocks`]).
3048pub fn fires_on_step(block: &str) -> bool {
3049    let id = block.split('[').next().unwrap_or(block);
3050    let id = if id.contains(':') {
3051        id.to_string()
3052    } else {
3053        format!("minecraft:{id}")
3054    };
3055    stepped_blocks().contains(&id.as_str())
3056}
3057
3058/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
3059/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
3060/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
3061/// block-destroying and unmodeled effects out of the schema by construction
3062/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
3063#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3064#[serde(rename_all = "kebab-case", deny_unknown_fields)]
3065pub enum TrapEffect {
3066    /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
3067    /// arrows, splash potions). The redstone fires it; terrain is untouched
3068    /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
3069    /// `Items` NBT — a deterministic, static payload.
3070    Dispense {
3071        /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
3072        item: String,
3073        /// How many to load into the dispenser stack.
3074        count: u32,
3075    },
3076}
3077
3078/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
3079/// forced-critical-path completability obligation (`DW0342`).
3080#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3081#[serde(rename_all = "kebab-case")]
3082pub enum Lethality {
3083    /// Can kill a full-health player — carries the `DW0342` obligation on the path.
3084    Lethal,
3085    /// Hurts but is not designed to kill (the default).
3086    #[default]
3087    Harmful,
3088    /// Cosmetic / trivial (a stumble, a scare).
3089    Nonlethal,
3090}
3091
3092/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
3093#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3094#[serde(rename_all = "kebab-case")]
3095pub enum TrapReset {
3096    /// Fires once, then is spent — the survivability path for a forced lethal trap
3097    /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
3098    /// soft-loop).
3099    Once,
3100    /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
3101    /// trap must be avoidable or disarmable, else `DW0342`.
3102    #[default]
3103    Rearm,
3104}
3105
3106/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
3107/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
3108/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
3109/// before the trap cell is forced. Discharges the `DW0342` obligation when the
3110/// affordance is reachable ahead of the trap without crossing it.
3111#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3112#[serde(deny_unknown_fields)]
3113pub struct TrapDisarm {
3114    /// The anchor the player interacts with to disarm.
3115    pub via: AnchorId,
3116    /// The flag set when the trap is disarmed (a new flag this trap produces; other
3117    /// objectives/triggers may read it via `requires_flags`).
3118    pub sets_flag: FlagId,
3119}
3120
3121/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
3122/// deterministic open/close clock, so passage is a timing read rather than a
3123/// permanent state.
3124///
3125/// **The proof is deliberately NOT all-phase passability** — a gate that
3126/// punishes bad timing is the entire point. What the
3127/// compiler requires is that the gate is *readable*: the set of entry phases from
3128/// which a walking player clears the span before it shuts must cover **≥ 20% of
3129/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
3130/// amount of learning the level makes it fair.
3131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3132#[serde(deny_unknown_fields)]
3133pub struct TimedGate {
3134    /// Unique timed-gate id (`timed-gate/<kebab>`).
3135    pub id: TimedGateId,
3136    /// The gate anchor the clock drives. Its prefab metadata must declare a fill
3137    /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
3138    pub gate: AnchorId,
3139    /// Ticks the gate stays OPEN each cycle (> 0).
3140    pub open_ticks: u32,
3141    /// Ticks the gate stays CLOSED each cycle (> 0).
3142    pub closed_ticks: u32,
3143    /// Ticks after world init before the first open window begins (default 0) —
3144    /// how two gates in the same room are put out of step with each other. Must
3145    /// be less than the full cycle.
3146    #[serde(default, skip_serializing_if = "is_zero")]
3147    pub phase: u32,
3148    /// Whether the gate **kills** a player caught inside its region when it
3149    /// shuts (spec-0016 §4 addendum). A portcullis
3150    /// that merely shoves you aside teaches nothing; mistiming the crossing is
3151    /// supposed to be a death you learn from, which is the whole point of §4's
3152    /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
3153    /// absolute.
3154    ///
3155    /// This is a real judgement issued by command on the closing tick, not
3156    /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
3157    /// escapable, so it would make the portcullis a suggestion. `damage` at the
3158    /// closing edge is exact and unarguable.
3159    ///
3160    /// **Defaults to `false`**, so every campaign authored before this field
3161    /// existed compiles byte-identically; a delve opts its portcullis in.
3162    #[serde(default, skip_serializing_if = "is_false")]
3163    pub crush: bool,
3164    /// Optional **disarm** affordance (souls dossier §5.2): the third
3165    /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
3166    /// The real games' best timed hazards can be removed for good (Smouldering
3167    /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
3168    /// dance with is one rung short of the vocabulary.
3169    ///
3170    /// Interacting with the affordance suppresses the clock **permanently, with
3171    /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
3172    /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
3173    /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
3174    ///
3175    /// **Defaults to absent**, so every campaign authored before this field
3176    /// existed compiles byte-identically; a delve opts its portcullis in.
3177    #[serde(default, skip_serializing_if = "Option::is_none")]
3178    pub disarm: Option<TimedGateDisarm>,
3179}
3180
3181/// A [`TimedGate`]'s disarm affordance (souls dossier §5.2) — the
3182/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
3183/// grammar for every mechanism the party can switch off.
3184///
3185/// The player acts on the `via` anchor (a compiler-emitted interaction entity
3186/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
3187/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
3188/// put the gate back.
3189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3190#[serde(deny_unknown_fields)]
3191pub struct TimedGateDisarm {
3192    /// The anchor the player interacts with to jam the gate. Must be an anchor
3193    /// some area's prefab provides, and never the gate anchor itself — the
3194    /// mechanism belongs beside the doorway, not inside the span that crushes.
3195    pub via: AnchorId,
3196    /// The flag set when the gate is disarmed (a new flag this gate produces;
3197    /// other objectives/triggers may read it via `requires_flags`).
3198    pub sets_flag: FlagId,
3199}
3200
3201/// serde `skip_serializing_if` helper: skip a `0`.
3202fn is_zero(n: &u32) -> bool {
3203    *n == 0
3204}
3205
3206/// A stage-5 **ambush** (spec-0016 §3) — one declaration for a beat that
3207/// otherwise takes a deferred actor set plus a hand-wired trigger.
3208///
3209/// **`telegraph` is optional, and that is a design ruling, not an oversight.**
3210/// The un-telegraphed ambush — the shove off the cliff you
3211/// could not have known about — is core souls vocabulary: 初见杀 is how the level
3212/// teaches. The engine does not sand that edge off.
3213///
3214/// What the engine *does* owe the player is **counterplay on the retry**: having
3215/// died once, an informed player must have something to do about it. Determinism
3216/// guarantees the second attempt meets the same ambushers in the same cells; the
3217/// compiler adds the missing half — `DW0376` proves the trigger cell is not a
3218/// sealed pocket, i.e. that with every ambusher standing where it will stand,
3219/// a route out still exists (a retreat, luring ground, a positioning line).
3220/// Dying uninformed is a lesson; dying with no play available is a broken beat.
3221#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3222#[serde(deny_unknown_fields)]
3223pub struct Ambush {
3224    /// Unique ambush id (`ambush/<kebab>`).
3225    pub id: AmbushId,
3226    /// The anchor the trigger watches — the corner, doorway or ledge the player
3227    /// walks into.
3228    pub at: AnchorId,
3229    /// The stage-5 actors that spring (1..N). Each is summoned at its own
3230    /// declared anchor and immediately unleashed to real AI.
3231    pub actors: Vec<ActorId>,
3232    /// What springs it (`approach{range}` / `strike` / `use`), exactly the v0.4
3233    /// environment-trigger vocabulary.
3234    pub trigger: TriggerOn,
3235    /// The **optional** tell, fired at the trigger before the ambushers exist:
3236    /// a sound, a shadow, a line of narration. Empty = un-telegraphed, which is
3237    /// fully legal.
3238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3239    pub telegraph: Vec<QuestEffect>,
3240    /// What the spring does to the story (DSL v0.8, spec-0025; required at
3241    /// 0.8.0, `DW0481`).
3242    ///
3243    /// **The declaration lives here because this is the object the author
3244    /// wrote.** An ambush desugars into a `spawn-actor` plus an `unleash-actor`
3245    /// per listed actor, and every one of those is a story node `DW0481` demands
3246    /// a `happening` from — on effects the author never wrote and cannot reach.
3247    /// So for as long as this field did not exist, an `ambushes[]` entry could
3248    /// not compile at 0.8.0 or above at all: declared in the schema, accepted by
3249    /// the schema check, and refused at validation with a prescription naming a
3250    /// field that was not there. Nothing caught it because no campaign and no
3251    /// fixture had ever written an ambush at a version where the obligation was
3252    /// live — which is the case spec-0039 exists to make impossible.
3253    ///
3254    /// An ambush is **one** beat, not `2N` of them: the ambushers appearing and
3255    /// coming at you is a single dramatic moment, so one declaration covers the
3256    /// whole spring. [`Self::to_trigger`] stamps it onto the first generated
3257    /// `spawn-actor` so the chronicle carries the line at the right position,
3258    /// and `branch::check_happenings` reads the obligation off the ambush rather
3259    /// than off the beats derived from it. Repeating it on all `2N` is the
3260    /// obvious alternative and it is wrong twice over: the chronicle would gain
3261    /// `N` duplicate lines, and `DW0485` would see one subject act repeatedly
3262    /// and be right to call it a contradiction.
3263    #[serde(default, skip_serializing_if = "Option::is_none")]
3264    pub happening: Option<Happening>,
3265}
3266
3267impl Ambush {
3268    /// The environment trigger this ambush desugars to (spec-0016 §3): a
3269    /// one-shot trigger at `at` whose effects are the telegraph, then a
3270    /// `spawn-actor` + `unleash-actor` per listed actor, in declared order.
3271    ///
3272    /// This is the **single** expansion authority. Every consumer — validation,
3273    /// the l10n inventory, the flag/wave producer scans, nav, emission — reads
3274    /// triggers through [`QuestsContent::all_triggers`], so the sugar cannot
3275    /// diverge from a hand-written equivalent, and an ambush is exactly as
3276    /// debuggable as the trigger an author would otherwise type.
3277    pub fn to_trigger(&self) -> EnvTrigger {
3278        let mut effects = self.telegraph.clone();
3279        for (i, a) in self.actors.iter().enumerate() {
3280            effects.push(QuestEffect {
3281                when: None,
3282                // The ambush declaration, on the beat where the spring becomes
3283                // real. Only the FIRST, for the reason the field documents: one
3284                // ambush is one beat, and stamping the line on every generated
3285                // effect would pad the chronicle and trip `DW0485`.
3286                happening: if i == 0 { self.happening.clone() } else { None },
3287                audience: None,
3288                within: None,
3289                verb: Verb::SpawnActor { actor: a.clone() },
3290            });
3291        }
3292        for a in &self.actors {
3293            effects.push(Verb::UnleashActor { actor: a.clone() }.into());
3294        }
3295        EnvTrigger {
3296            id: TriggerId(format!(
3297                "trigger/{}",
3298                crate::l10n::local_id(self.id.as_str())
3299            )),
3300            at: Some(self.at.clone()),
3301            // An ambush springs on a body's position or a click on open air; it
3302            // binds to no pressed block.
3303            prop: None,
3304            on: self.trigger.clone(),
3305            requires_flags: Vec::new(),
3306            forbids_flags: Vec::new(),
3307            requires_state: Vec::new(),
3308            once: true,
3309            // An ambush is a party beat by construction — it springs actors at
3310            // the room, not a reply to the one who walked in.
3311            audience: TriggerAudience::Party,
3312            effects,
3313        }
3314    }
3315}
3316
3317/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
3318///
3319/// The owner's definition of the pattern: between two rest points there are two
3320/// routes. The **short** one starts sealed and holds nothing; the **long** one is
3321/// full of enemies and mechanisms. You earn the far side the hard way, pull one
3322/// mechanism, and the short route opens **forever**. That moment is the design.
3323///
3324/// The compiler owns three obligations, none of them optional:
3325/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
3326///    long route genuinely exists (`DW0373`);
3327/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
3328///    pays nothing is a leak, not a shortcut (`DW0360`);
3329/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
3330///    (`DW0372`). There is no re-sealing verb to reach for.
3331///
3332/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
3333/// untouched by this — the two verbs are deliberately disjoint.
3334#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3335#[serde(deny_unknown_fields)]
3336pub struct Shortcut {
3337    /// Unique shortcut id (`shortcut/<kebab>`).
3338    pub id: ShortcutId,
3339    /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
3340    /// carries the physical fill), and its metadata must declare the fill `block`
3341    /// the compiler clears — the same requirement `close-gate` has.
3342    pub gate: AnchorId,
3343    /// The FAR-side anchor whose interaction fires the permanent open. The
3344    /// compiler summons the affordance there and polls it, reusing the v0.4
3345    /// interaction-entity `use` primitive.
3346    pub unlock: AnchorId,
3347    /// Effects fired once, when the shortcut opens — the bar lifting, the
3348    /// elevator descending, the sound of a door you will never have to earn
3349    /// again. Emitted server-source-safe (the poll lives on the tick).
3350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3351    pub on_unlock: Vec<QuestEffect>,
3352}
3353
3354/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
3355/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
3356/// entity's attack/interaction records; `approach` is a `distance` selector on
3357/// the tick. Look-at / break-attempt detection is excluded on principle (no
3358/// vanilla primitive).
3359#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3360#[serde(deny_unknown_fields)]
3361pub struct EnvTrigger {
3362    /// Unique trigger id (`trigger/<kebab>`).
3363    pub id: TriggerId,
3364    /// The anchor this trigger watches. Required for `strike` / `use` /
3365    /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
3366    /// v0.6), which watches a *character* and names it in `on.npc` instead —
3367    /// there is no cell for the author to supply and no cell the compiler
3368    /// would use. Either mismatch is `DW0194`.
3369    #[serde(default, skip_serializing_if = "Option::is_none")]
3370    pub at: Option<AnchorId>,
3371    /// **The visible object the click acts on** (spec-0093 §6.5): for a `use`
3372    /// or a `strike` trigger, the block the compiler places at `at` — a lever,
3373    /// a bell, a lamp, a stone. A `use` on a block vanilla reports the use of
3374    /// (a lever, a button, a bell) fires through vanilla's `default_block_use`
3375    /// criterion and summons no hitbox; any other prop, and every `strike`, is
3376    /// placed with the `minecraft:interaction` hitbox fitted over it as its hit
3377    /// area. A click trigger with no `prop` on open air is refused (`DW0963`):
3378    /// the hitbox is invisible and is never the object. A `prop` on an event
3379    /// with no cell of its own — an approach, a `strike-npc`, a
3380    /// `strike-assembly` — is `DW0964`.
3381    #[serde(default, skip_serializing_if = "Option::is_none")]
3382    pub prop: Option<Prop>,
3383    /// The event that fires it.
3384    pub on: TriggerOn,
3385    /// Flags that must be set before the trigger can fire (DSL v0.4).
3386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3387    pub requires_flags: Vec<FlagId>,
3388    /// Negative flag gate (DSL v0.6): the trigger is
3389    /// **suppressed** while ANY listed flag is set (by any player — flags are
3390    /// campaign state). The dual of `requires_flags`, so an "armed between two
3391    /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
3392    /// retaliation trigger with `requires_flags: [flag/sealed]` and
3393    /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
3394    /// the moment the wake beat takes over.
3395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3396    pub forbids_flags: Vec<FlagId>,
3397    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
3398    /// hold for this gate to be open. The third field of the one gate, carried by
3399    /// every gate consumer — never by the verb that first wanted it. Default
3400    /// empty, so a pre-0.10 campaign is byte-identical.
3401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3402    pub requires_state: Vec<StateCompare>,
3403    /// Fire at most once (default `true`, mirroring objective completion). Set
3404    /// `false` to allow re-firing every time the condition is met.
3405    #[serde(default = "default_true")]
3406    pub once: bool,
3407    /// **Who the trigger's effects address** (DSL v0.11). Default
3408    /// [`TriggerAudience::Party`], so every campaign written before this field
3409    /// existed is byte-identical.
3410    ///
3411    /// A trigger is two different things depending on what the author means by
3412    /// it. A pressure plate that opens a gate and narrates the room is a **party
3413    /// beat**: everyone should see it, and it does not matter who stepped on the
3414    /// plate. A barred door that answers *"this cannot be opened from this
3415    /// side"* is a **reply to one person**: broadcasting it tells four players
3416    /// about a door three of them are nowhere near.
3417    ///
3418    /// Until this field the second was inexpressible, which is why the two verbs
3419    /// that needed it (`close-gate`'s seal answer, and nothing at all for a
3420    /// shortcut door) grew their own private reply machinery instead. The
3421    /// capability belongs to the press, not to the verb.
3422    #[serde(default, skip_serializing_if = "TriggerAudience::is_party")]
3423    pub audience: TriggerAudience,
3424    /// Effects fired when the trigger matches.
3425    pub effects: Vec<QuestEffect>,
3426}
3427
3428impl EnvTrigger {
3429    /// The anchor this trigger watches, if it watches a place at all. `None`
3430    /// for `strike-npc`, whose target is a character.
3431    pub fn at_anchor(&self) -> Option<&str> {
3432        self.at.as_ref().map(|a| a.as_str())
3433    }
3434
3435    /// Whether this trigger's bundle is addressed to the player who pressed it.
3436    pub fn addresses_presser(&self) -> bool {
3437        self.audience == TriggerAudience::Presser
3438    }
3439
3440    /// Whether vanilla can name the player whose act fired this trigger, which
3441    /// is what `audience: presser` needs: a right-click (`use`, through
3442    /// `minecraft:player_interacted_with_entity`) and a step (`step`, a player
3443    /// in the cell). A left-click is recorded as a UUID no command can become;
3444    /// everything else is refused by `DW0427`.
3445    pub fn attributes_its_actor(&self) -> bool {
3446        matches!(self.on, TriggerOn::Use | TriggerOn::Step)
3447    }
3448}
3449
3450/// Who an [`EnvTrigger`]'s effects address (DSL v0.11).
3451///
3452/// **This is a dispatch decision, not a cosmetic one.** A `party` trigger is
3453/// polled on the tick with no executor, so `@s` does not exist and every
3454/// player-facing command addresses `@a`. A `presser` trigger is dispatched by a
3455/// `minecraft:player_interacted_with_entity` advancement — the one vanilla
3456/// primitive that runs a function *as the player who clicked* — so `@s` is the
3457/// presser and the bundle addresses them alone. A `presser` trigger `on: step`
3458/// is polled as `execute as @a[<the cell>]`, so `@s` is each player who stepped
3459/// on, on their own step.
3460///
3461/// The click primitive exists for **right-clicks only**. Vanilla records a
3462/// left-click on an interaction entity in NBT (which names a UUID no command can
3463/// become) and offers no criterion for it, so `presser` on a `strike` is refused
3464/// (`DW0427`) rather than approximated: per CLAUDE.md's no-hack rule, a
3465/// capability with no vanilla primitive under it is excluded, never faked
3466/// downstream.
3467#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3468#[serde(rename_all = "kebab-case")]
3469pub enum TriggerAudience {
3470    /// The whole party (the default, and what every trigger did before v0.11).
3471    #[default]
3472    Party,
3473    /// The one player whose press fired it: the right-click of a `use`, the
3474    /// step of a `step`.
3475    Presser,
3476}
3477
3478impl TriggerAudience {
3479    /// Serde skip predicate: the default needs no field on the wire, so a
3480    /// canonical round-trip of a pre-0.11 campaign is byte-identical.
3481    fn is_party(&self) -> bool {
3482        *self == TriggerAudience::Party
3483    }
3484}
3485
3486/// The event an [`EnvTrigger`] watches (DSL v0.4).
3487#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3488#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
3489pub enum TriggerOn {
3490    /// The player attacks (left-clicks) the interaction entity at the anchor.
3491    Strike,
3492    /// The player uses (right-clicks) the interaction entity at the anchor.
3493    Use,
3494    /// The player comes within `range` blocks of the anchor.
3495    Approach {
3496        /// Approach radius (blocks).
3497        range: u32,
3498    },
3499    /// A player steps onto the anchor's cell, which holds a block a step fires
3500    /// — a pressure plate or the tripwire string ([`stepped_blocks`]); the
3501    /// piece places it, and a cell that holds anything else is `DW0917`.
3502    ///
3503    /// Detected as a player whose hitbox is in the cell (the selector a plate
3504    /// or tripwire trap fires on), edge-latched so standing on the plate fires
3505    /// once. With `audience: presser` each player who steps on is dispatched
3506    /// as `@s` on their own step: the act and the actor are the same fact, a
3507    /// body in the cell, so no player is inferred after the event.
3508    Step,
3509    /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6).
3510    ///
3511    /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
3512    /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
3513    /// body eclipses that cell (`DW0359`), so the click never reaches the
3514    /// trigger — the owner's island round-7 finding. This form has no cell. It
3515    /// rides the interaction entity the NPC already owns, which is the entity a
3516    /// click on that NPC reaches by construction.
3517    ///
3518    /// Right-click and left-click stay separate all the way down: a
3519    /// `minecraft:interaction` records them in two distinct NBT fields
3520    /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
3521    /// and this trigger takes the left-click, on one shared hitbox.
3522    StrikeNpc {
3523        /// The NPC (stage-2 ref) whose body is the target.
3524        npc: NpcId,
3525    },
3526    /// The player attacks (left-clicks) an **assembly's hitbox** (spec-0082).
3527    ///
3528    /// The exact shape of [`TriggerOn::StrikeNpc`]: no `at`, because the target
3529    /// is an object with a hitbox of its own, and the trigger rides it. Melee
3530    /// only — the hitbox is a `minecraft:interaction`, and an arrow passes
3531    /// through one without writing its `attack` record (spec-0082 §8 row 5).
3532    /// `once: false` with an `add-state` is how a hit count is built.
3533    StrikeAssembly {
3534        /// The assembly (stage-5 `assemblies` ref) whose hitbox is the target.
3535        assembly: AssemblyId,
3536    },
3537}
3538
3539impl TriggerOn {
3540    /// The kebab tag (`strike` / `use` / `approach` / `step` / `strike-npc` /
3541    /// `strike-assembly`).
3542    pub fn kind(&self) -> &'static str {
3543        match self {
3544            TriggerOn::Strike => "strike",
3545            TriggerOn::Use => "use",
3546            TriggerOn::Approach { .. } => "approach",
3547            TriggerOn::Step => "step",
3548            TriggerOn::StrikeNpc { .. } => "strike-npc",
3549            TriggerOn::StrikeAssembly { .. } => "strike-assembly",
3550        }
3551    }
3552
3553    /// Whether this event is a click on a `minecraft:interaction` hitbox — a
3554    /// `strike`, a `use`, a `strike-npc`, a `strike-assembly`. An `approach`
3555    /// and a `step` are a body's position, read on the tick, and have no
3556    /// hitbox.
3557    pub fn is_click(&self) -> bool {
3558        matches!(
3559            self,
3560            TriggerOn::Strike
3561                | TriggerOn::Use
3562                | TriggerOn::StrikeNpc { .. }
3563                | TriggerOn::StrikeAssembly { .. }
3564        )
3565    }
3566
3567    /// Whether this event needs an `at` anchor — true for everything that
3568    /// watches a place, false for `strike-npc` and `strike-assembly`, which
3569    /// watch an object that carries its own hitbox.
3570    pub fn needs_anchor(&self) -> bool {
3571        !matches!(
3572            self,
3573            TriggerOn::StrikeNpc { .. } | TriggerOn::StrikeAssembly { .. }
3574        )
3575    }
3576
3577    /// The assembly whose hitbox this event watches (`strike-assembly` only).
3578    pub fn assembly_target(&self) -> Option<&AssemblyId> {
3579        match self {
3580            TriggerOn::StrikeAssembly { assembly } => Some(assembly),
3581            _ => None,
3582        }
3583    }
3584
3585    /// The NPC whose body this event watches (`strike-npc` only).
3586    pub fn npc_target(&self) -> Option<&NpcId> {
3587        match self {
3588            TriggerOn::StrikeNpc { npc } => Some(npc),
3589            _ => None,
3590        }
3591    }
3592}
3593
3594/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
3595/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
3596/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
3597/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
3598/// and the `kill` objective completes when the count reaches zero.
3599#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3600#[serde(deny_unknown_fields)]
3601pub struct Wave {
3602    /// Unique wave id.
3603    pub id: WaveId,
3604    /// The anchor the wave's mobs spawn at.
3605    pub anchor: AnchorId,
3606    /// The mobs that make up the wave (1..N).
3607    pub mobs: Vec<WaveMob>,
3608    /// Re-seat this wave every time the party rests at (or respawns from) a
3609    /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
3610    /// enemies come back. The compiler kills any survivor carrying the wave tag
3611    /// and re-runs the wave's own spawn function, so the room is restored to its
3612    /// authored composition and spawn cells.
3613    ///
3614    /// Inert without a `bonfire` in the campaign, which is a compile error
3615    /// (`DW0370`) rather than a silent no-op.
3616    #[serde(default, skip_serializing_if = "is_false")]
3617    pub respawns_on_rest: bool,
3618    /// Tower-defense lane routing (spec-0016 §6): march this wave along a
3619    /// waypoint polyline while distant, hand it to native AI the instant a
3620    /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
3621    /// stand), byte-identical.
3622    #[serde(default, skip_serializing_if = "Option::is_none")]
3623    pub lane: Option<WaveLane>,
3624    /// Where the wave's mobs materialize (spec-0016 §6). Absent =
3625    /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
3626    /// wave `anchor`.
3627    #[serde(default, skip_serializing_if = "Option::is_none")]
3628    pub summon: Option<WaveSummon>,
3629    /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
3630    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
3631    ///
3632    /// This is a **declaration, not a knob**: the compiler never scales content
3633    /// from it (spec-0023 "Out of scope"). It exists because the validation
3634    /// ladder's inverted floor gate needs to know which fights the content
3635    /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
3636    /// on its first attempt is reported as too easy for its billing. Marking it
3637    /// is how the author opts into that scrutiny; the alternative — inferring
3638    /// "elite" from how tuned a stack looks — is exactly the downstream folklore
3639    /// CLAUDE.md's no-hack rule forbids.
3640    ///
3641    /// **It does reach emission in exactly one place** (spec-0016 §1): in a
3642    /// campaign with a `bonfire`, a billed `elite`/
3643    /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
3644    /// rest *while it is still standing* — deleted and re-seated at full count
3645    /// and full health, so chipping it down one life at a time is never a path.
3646    /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
3647    /// `respawns_on_rest` at once.
3648    #[serde(default, skip_serializing_if = "Option::is_none")]
3649    pub tier: Option<EncounterTier>,
3650    /// A health bar over this wave's bodies (DSL v0.31, spec-0073): a named bar
3651    /// over their total health, drawn for every player within `range` blocks of a
3652    /// live one. Absent = no bar, byte-identical. Declared, never derived from
3653    /// `tier`; a `boss`-billed wave without one is advised (`DW0912`).
3654    #[serde(default, skip_serializing_if = "Option::is_none")]
3655    pub health_bar: Option<crate::healthbar::HealthBar>,
3656    /// What happens each time a player is credited with killing one of this
3657    /// wave's bodies (spec-0074) — effect root R9, the same
3658    /// [`OnKill`] an actor declares. Absent = no bundle, and the
3659    /// wave's emission is byte-identical.
3660    #[serde(default, skip_serializing_if = "Option::is_none")]
3661    pub on_kill: Option<OnKill>,
3662}
3663
3664/// What happens each time a body of this fight is killed (spec-0074): the
3665/// `on_kill` of a wave or an actor.
3666#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3667#[serde(deny_unknown_fields)]
3668pub struct OnKill {
3669    /// Whether a body that comes back pays again. Required where the fight comes
3670    /// back after the party has met it — a bonfire re-seats it, or the beat that
3671    /// seats it can fire more than once (`DW0915`); left off where it does not,
3672    /// and `every-kill` there is refused as inert (`DW0914`). No default: whether
3673    /// an economy can be farmed is the creator's judgement.
3674    #[serde(default, skip_serializing_if = "Option::is_none")]
3675    pub fires: Option<KillFires>,
3676    /// The effects, run as the credited player for each credited kill. Every verb
3677    /// an `on_objective_complete` bundle accepts, each gated by its own `when`.
3678    #[schemars(length(min = 1))]
3679    pub effects: Vec<QuestEffect>,
3680}
3681
3682/// Whether a body that comes back after a rest pays again (spec-0074 §4).
3683#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3684#[serde(rename_all = "kebab-case")]
3685pub enum KillFires {
3686    /// Over the whole delve the fight pays at most once per body it seats — the
3687    /// wave's body count, or once for an actor. A re-seat does not renew it.
3688    FirstKill,
3689    /// Every credited kill pays, however many times the fight is re-seated.
3690    EveryKill,
3691}
3692
3693impl KillFires {
3694    /// The kebab token, as it appears in the DSL.
3695    pub fn token(self) -> &'static str {
3696        match self {
3697            KillFires::FirstKill => "first-kill",
3698            KillFires::EveryKill => "every-kill",
3699        }
3700    }
3701}
3702
3703/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
3704/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
3705/// — by one emission site: a bonfire refreshes a billed wave that is still
3706/// standing. Nothing about the encounter itself is scaled from it.
3707#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3708#[serde(rename_all = "kebab-case")]
3709pub enum EncounterTier {
3710    /// Trash / pressure. No floor expectation: a bot that wins it cold proves
3711    /// nothing either way. The default.
3712    #[default]
3713    Ordinary,
3714    /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
3715    /// and spawn-and-unleash vocabulary).
3716    Elite,
3717    /// A campaign's named fight. Same floor rule as `elite`; the distinction is
3718    /// for the run report a human reads.
3719    Boss,
3720}
3721
3722impl EncounterTier {
3723    /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
3724    pub fn token(self) -> &'static str {
3725        match self {
3726            EncounterTier::Ordinary => "ordinary",
3727            EncounterTier::Elite => "elite",
3728            EncounterTier::Boss => "boss",
3729        }
3730    }
3731
3732    /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
3733    /// content bills as hard carries an expectation the bot can measure; an
3734    /// ordinary one does not.
3735    pub fn has_floor_expectation(self) -> bool {
3736        matches!(self, EncounterTier::Elite | EncounterTier::Boss)
3737    }
3738}
3739
3740/// Where a wave's mobs materialize (spec-0016 §6).
3741#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3742#[serde(rename_all = "kebab-case")]
3743pub enum WaveSummon {
3744    /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
3745    /// behaviour and the default.
3746    Anchor,
3747    /// **Spirit-summoned at the edge of perception**:
3748    /// each mob appears on the ring at its own `attributes.follow_range` from
3749    /// the wave `anchor`, so it acquires a target the instant it exists and
3750    /// closes under pure native AI. Species without patrol AI never march a
3751    /// lane; this is what they do instead — never a spawn on top of the party,
3752    /// never a mob that brushes past.
3753    ///
3754    /// With this mode the wave `anchor` is the **defended point** (what the ring
3755    /// is drawn around), not the spawn point. Each mob stack must declare its
3756    /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
3757    /// never guessed from a vanilla defaults table the compiler cannot verify.
3758    AggroEdge,
3759}
3760
3761/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
3762/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
3763/// (`docs/notes/td-routing-spike.md`).
3764///
3765/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
3766/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
3767/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
3768/// From that instant the mob is a plain native hostile. "Combat preempts
3769/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
3770/// no target — so the owner's rule (march while distant, fight with NATIVE AI
3771/// once aggroed, never brush past) falls out of the primitive unforced.
3772///
3773/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
3774/// pillager must keep its crossbow (`DW0384`).
3775#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3776#[serde(deny_unknown_fields)]
3777pub struct WaveLane {
3778    /// The lane polyline, in march order: anchors some area's prefab provides.
3779    /// At least one; consecutive legs (counting the wave `anchor` as the origin)
3780    /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
3781    /// within 10 blocks of arrival, so a tighter lane is a lane the engine
3782    /// quietly stops following (`DW0386`).
3783    pub waypoints: Vec<AnchorId>,
3784    /// The release radius, in blocks: the compiler sets every lane mob's
3785    /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
3786    /// at the same distance. The two MUST be equal — a patrolling raider that
3787    /// targets a player it cannot engage holds ground instead of marching, so a
3788    /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
3789    pub aggro_radius: u32,
3790}
3791
3792/// One mob stack in a [`Wave`].
3793#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3794#[serde(deny_unknown_fields)]
3795pub struct WaveMob {
3796    /// Vanilla entity id, validated against the pinned 1.21.11 registry.
3797    pub entity: String,
3798    /// How many to spawn.
3799    pub count: u32,
3800    /// Optional custom name (shown above the mob).
3801    #[serde(default, skip_serializing_if = "Option::is_none")]
3802    pub name: Option<String>,
3803    /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
3804    /// components. Enables e.g. a weakened live warden as a survivable stealth
3805    /// threat. Omitted = vanilla defaults.
3806    #[serde(default, skip_serializing_if = "Option::is_none")]
3807    pub attributes: Option<MobAttributes>,
3808    /// Optional permanent, ambient status effects (DSL v0.4).
3809    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3810    pub effects: Vec<MobEffect>,
3811    /// Optional worn/held equipment (DSL v0.6). A helmet is the
3812    /// sanctioned fix for daylight-burning undead — never
3813    /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
3814    /// (`DW0143`, the give-item family); every emitted slot carries drop
3815    /// chance 0 so players can never farm wave gear (no-grind constitution).
3816    #[serde(default, skip_serializing_if = "Option::is_none")]
3817    pub equipment: Option<MobEquipment>,
3818    /// What this mob leaves behind when it dies (DSL v0.9). Only an `elite`/`boss` wave may declare it (`DW0491`)
3819    /// — an ordinary mob's kit is never farmable. Empty = drop chance 0 on
3820    /// every slot.
3821    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3822    pub drops: Vec<MobDrop>,
3823}
3824
3825/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
3826/// id for the matching vanilla equipment slot; an unset slot stays empty —
3827/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
3828/// wither-skeleton sword) still applies unless overridden. Emitted as the
3829/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
3830/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
3831#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3832#[serde(deny_unknown_fields)]
3833pub struct MobEquipment {
3834    /// Head slot.
3835    #[serde(default, skip_serializing_if = "Option::is_none")]
3836    pub head: Option<EquipItem>,
3837    /// Chest slot.
3838    #[serde(default, skip_serializing_if = "Option::is_none")]
3839    pub chest: Option<EquipItem>,
3840    /// Legs slot.
3841    #[serde(default, skip_serializing_if = "Option::is_none")]
3842    pub legs: Option<EquipItem>,
3843    /// Feet slot.
3844    #[serde(default, skip_serializing_if = "Option::is_none")]
3845    pub feet: Option<EquipItem>,
3846    /// Main-hand slot. Overrides the compiler's armed-mob default.
3847    #[serde(default, skip_serializing_if = "Option::is_none")]
3848    pub main_hand: Option<EquipItem>,
3849    /// Off-hand slot.
3850    #[serde(default, skip_serializing_if = "Option::is_none")]
3851    pub off_hand: Option<EquipItem>,
3852    /// Body slot: horse armour, wolf armour, a llama's carpet, a nautilus's
3853    /// armour, a happy ghast's harness (spec-0067). Shown only on a body whose
3854    /// entity type draws it (`DW0898`).
3855    #[serde(default, skip_serializing_if = "Option::is_none")]
3856    pub body: Option<EquipItem>,
3857    /// Saddle slot (spec-0067). Shown only on a body whose entity type draws a
3858    /// saddle (`DW0898`).
3859    #[serde(default, skip_serializing_if = "Option::is_none")]
3860    pub saddle: Option<EquipItem>,
3861}
3862
3863impl MobEquipment {
3864    /// Every slot as `(dsl_field_name, piece)`, in [`EquipSlot::ALL`]'s order —
3865    /// the single iteration source for validation paths and emission.
3866    pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); EquipSlot::ALL.len()] {
3867        EquipSlot::ALL.map(|s| (s.field(), self.filled(s)))
3868    }
3869
3870    /// Every slot as `(slot, piece)`, in [`EquipSlot::ALL`]'s order.
3871    pub fn pieces(&self) -> [(EquipSlot, Option<&EquipItem>); EquipSlot::ALL.len()] {
3872        EquipSlot::ALL.map(|s| (s, self.filled(s)))
3873    }
3874
3875    /// The piece this equipment declaration puts in `slot`, if any. The single
3876    /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
3877    /// actually wears (`DW0490`).
3878    pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
3879        match slot {
3880            EquipSlot::Head => self.head.as_ref(),
3881            EquipSlot::Chest => self.chest.as_ref(),
3882            EquipSlot::Legs => self.legs.as_ref(),
3883            EquipSlot::Feet => self.feet.as_ref(),
3884            EquipSlot::MainHand => self.main_hand.as_ref(),
3885            EquipSlot::OffHand => self.off_hand.as_ref(),
3886            EquipSlot::Body => self.body.as_ref(),
3887            EquipSlot::Saddle => self.saddle.as_ref(),
3888        }
3889    }
3890}
3891
3892/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
3893/// fills it. The DSL name and the summon-NBT key differ (`main_hand` vs
3894/// `mainhand`), so both live here and nowhere else.
3895///
3896/// **The set is the pinned game's equipment-slot set** (spec-0067 §2), a
3897/// [`crate::metrics::Provenance::VanillaRule`]: the eight values the
3898/// `minecraft:equippable` component's `slot` field takes, per the Minecraft
3899/// Wiki page *Data component format/equippable* for Java 1.21.11, which are the
3900/// serialised names of the client's `EquipmentSlot` enum. The pinned item data
3901/// is the cross-check, not the source: every `slot` value an item declares is
3902/// asserted to be one of these, and no item declares `mainhand`, because a hand
3903/// takes anything.
3904#[derive(
3905    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
3906)]
3907#[serde(rename_all = "snake_case")]
3908pub enum EquipSlot {
3909    /// Head slot.
3910    Head,
3911    /// Chest slot.
3912    Chest,
3913    /// Legs slot.
3914    Legs,
3915    /// Feet slot.
3916    Feet,
3917    /// Main-hand slot.
3918    MainHand,
3919    /// Off-hand slot.
3920    OffHand,
3921    /// Body slot (horse armour, wolf armour, carpet, harness).
3922    Body,
3923    /// Saddle slot.
3924    Saddle,
3925}
3926
3927impl EquipSlot {
3928    /// Every slot, in emission order: the two hands, the four armour slots,
3929    /// then `body` and `saddle`. The one enumeration every slot list derives
3930    /// from — `MobEquipment::slots()`, the summon `equipment` / `drop_chances`
3931    /// compounds and the drop-strip line.
3932    pub const ALL: [EquipSlot; 8] = [
3933        EquipSlot::MainHand,
3934        EquipSlot::OffHand,
3935        EquipSlot::Head,
3936        EquipSlot::Chest,
3937        EquipSlot::Legs,
3938        EquipSlot::Feet,
3939        EquipSlot::Body,
3940        EquipSlot::Saddle,
3941    ];
3942
3943    /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
3944    pub fn field(self) -> &'static str {
3945        match self {
3946            EquipSlot::Head => "head",
3947            EquipSlot::Chest => "chest",
3948            EquipSlot::Legs => "legs",
3949            EquipSlot::Feet => "feet",
3950            EquipSlot::MainHand => "main_hand",
3951            EquipSlot::OffHand => "off_hand",
3952            EquipSlot::Body => "body",
3953            EquipSlot::Saddle => "saddle",
3954        }
3955    }
3956
3957    /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`) — also the
3958    /// game's own name for the slot, as an `equippable` component spells it.
3959    pub fn nbt(self) -> &'static str {
3960        match self {
3961            EquipSlot::Head => "head",
3962            EquipSlot::Chest => "chest",
3963            EquipSlot::Legs => "legs",
3964            EquipSlot::Feet => "feet",
3965            EquipSlot::MainHand => "mainhand",
3966            EquipSlot::OffHand => "offhand",
3967            EquipSlot::Body => "body",
3968            EquipSlot::Saddle => "saddle",
3969        }
3970    }
3971
3972    /// The slot the game names `name` (`mainhand`), if it is one.
3973    pub fn from_nbt(name: &str) -> Option<EquipSlot> {
3974        EquipSlot::ALL.into_iter().find(|s| s.nbt() == name)
3975    }
3976
3977    /// Whether this is a hand: a hand takes any item, so an item's own declared
3978    /// slot never contradicts it.
3979    pub fn is_hand(self) -> bool {
3980        matches!(self, EquipSlot::MainHand | EquipSlot::OffHand)
3981    }
3982}
3983
3984/// One declared drop of an elite or boss (DSL v0.9).
3985///
3986/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
3987/// usually one piece and never automatically everything. Two forms, told apart
3988/// by which field is present:
3989///
3990/// ```json
3991/// { "slot": "main_hand" }                                  // its axe
3992/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
3993/// ```
3994///
3995/// A `slot` entry must name a slot the same entity's `equipment` really fills
3996/// (`DW0490`) — the drop is the piece the player has been *looking at* through
3997/// the whole fight, so the two declarations cannot disagree. An `item` entry is
3998/// a token the fight *yields* rather than wears, and rides the entity's own
3999/// death loot table.
4000///
4001/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
4002/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
4003#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4004#[serde(untagged)]
4005pub enum MobDrop {
4006    /// A worn/held piece, named by its equipment slot.
4007    Slot(SlotDrop),
4008    /// A quest token the fight yields (not worn).
4009    Item(ItemDrop),
4010}
4011
4012/// The worn-piece form of a [`MobDrop`].
4013#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4014#[serde(deny_unknown_fields)]
4015pub struct SlotDrop {
4016    /// The equipment slot whose piece drops. Must be filled by the entity's own
4017    /// `equipment` declaration (`DW0490`).
4018    pub slot: EquipSlot,
4019}
4020
4021/// The quest-token form of a [`MobDrop`].
4022#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4023#[serde(deny_unknown_fields)]
4024pub struct ItemDrop {
4025    /// Vanilla item id, validated against the pinned 1.21.11 registry
4026    /// (`DW0143`, the give-item family).
4027    pub item: String,
4028    /// Display name the dropped stack carries as a `custom_name` component.
4029    /// Player-visible, so it enters the l10n string inventory and translates
4030    /// like any other line.
4031    #[serde(default, skip_serializing_if = "Option::is_none")]
4032    pub name: Option<String>,
4033}
4034
4035impl MobDrop {
4036    /// The equipment slot this entry names, or `None` for a quest-item drop.
4037    pub fn slot(&self) -> Option<EquipSlot> {
4038        match self {
4039            MobDrop::Slot(s) => Some(s.slot),
4040            MobDrop::Item(_) => None,
4041        }
4042    }
4043
4044    /// The item id this entry names, or `None` for a worn-piece drop.
4045    pub fn item(&self) -> Option<&str> {
4046        match self {
4047            MobDrop::Slot(_) => None,
4048            MobDrop::Item(i) => Some(&i.item),
4049        }
4050    }
4051
4052    /// The declared display name of a quest-item drop, when set.
4053    pub fn name(&self) -> Option<&String> {
4054        match self {
4055            MobDrop::Slot(_) => None,
4056            MobDrop::Item(i) => i.name.as_ref(),
4057        }
4058    }
4059
4060    /// Mutable access to the display name — the l10n traversal's hook.
4061    pub fn name_mut(&mut self) -> Option<&mut String> {
4062        match self {
4063            MobDrop::Slot(_) => None,
4064            MobDrop::Item(i) => i.name.as_mut(),
4065        }
4066    }
4067}
4068
4069/// One equipped item: either a bare item id, or an id carrying enchantments.
4070///
4071/// The plain form is the common case and stays a plain JSON string, which is
4072/// what keeps every campaign written before enchantments existed byte-identical
4073/// on re-serialisation:
4074///
4075/// ```json
4076/// "main_hand": "minecraft:netherite_sword"
4077/// "head": { "item": "minecraft:netherite_helmet",
4078///           "enchantments": { "minecraft:protection": 4 } }
4079/// ```
4080#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4081#[serde(untagged)]
4082pub enum EquipItem {
4083    /// A bare item id — no enchantments.
4084    Plain(String),
4085    /// An item id plus its enchantments.
4086    Enchanted(EnchantedItem),
4087}
4088
4089impl EquipItem {
4090    /// The item id, whichever form was authored.
4091    pub fn item(&self) -> &str {
4092        match self {
4093            EquipItem::Plain(s) => s,
4094            EquipItem::Enchanted(e) => &e.item,
4095        }
4096    }
4097
4098    /// The enchantments on this piece — empty for the plain form. `BTreeMap`
4099    /// ordered, so emission order is the id order and never hash order
4100    /// (ADR-0006).
4101    pub fn enchantments(&self) -> &BTreeMap<String, u32> {
4102        static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
4103            std::sync::LazyLock::new(BTreeMap::new);
4104        match self {
4105            EquipItem::Plain(_) => &EMPTY,
4106            EquipItem::Enchanted(e) => &e.enchantments,
4107        }
4108    }
4109}
4110
4111/// **The item component a stack's enchantments are written to** — vanilla's own
4112/// rule (`EnchantmentHelper.getComponentType` at 1.21.11): an enchanted book
4113/// STORES its enchantments (`minecraft:stored_enchantments`, what an anvil
4114/// applies to the item it is combined with), and every other item CARRIES them
4115/// (`minecraft:enchantments`). Both are in the pinned 1.21.11
4116/// `data_component_type` registry.
4117///
4118/// A property of the item, not of the surface that writes the stack: a book in
4119/// a `loot` chest, a book handed over by `give-item` and a book on an equipped
4120/// piece are one object, so every emitter asks this one function. An
4121/// enchantment map written to the other component is a book that glints and
4122/// an anvil ignores, which the game accepts without a word.
4123pub fn enchantment_component(item: &str) -> &'static str {
4124    match item.strip_prefix("minecraft:").unwrap_or(item) {
4125        "enchanted_book" => "minecraft:stored_enchantments",
4126        _ => "minecraft:enchantments",
4127    }
4128}
4129
4130/// The enchanted form of [`EquipItem`].
4131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4132#[serde(deny_unknown_fields)]
4133pub struct EnchantedItem {
4134    /// Item id (e.g. `minecraft:netherite_chestplate`).
4135    pub item: String,
4136    /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
4137    /// the 1.21 `minecraft:enchantments` item component.
4138    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4139    pub enchantments: BTreeMap<String, u32>,
4140}
4141
4142/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
4143/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
4144#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4145#[serde(deny_unknown_fields)]
4146pub struct MobAttributes {
4147    /// `minecraft:max_health` base value.
4148    #[serde(default, skip_serializing_if = "Option::is_none")]
4149    pub max_health: Option<f64>,
4150    /// `minecraft:attack_damage` base value.
4151    #[serde(default, skip_serializing_if = "Option::is_none")]
4152    pub attack_damage: Option<f64>,
4153    /// `minecraft:movement_speed` base value.
4154    #[serde(default, skip_serializing_if = "Option::is_none")]
4155    pub movement_speed: Option<f64>,
4156    /// `minecraft:follow_range` base value.
4157    #[serde(default, skip_serializing_if = "Option::is_none")]
4158    pub follow_range: Option<f64>,
4159}
4160
4161/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
4162/// non-expiring `effect give`.
4163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4164#[serde(deny_unknown_fields)]
4165pub struct MobEffect {
4166    /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
4167    /// pinned registry (`DW0192`).
4168    pub effect: String,
4169    /// Amplifier (0 = level I).
4170    pub amplifier: u32,
4171}
4172
4173/// One expanded quest.
4174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4175#[serde(deny_unknown_fields)]
4176pub struct Quest {
4177    /// Quest id (matches a stage-4 planned quest).
4178    pub id: QuestId,
4179    /// What starts the quest.
4180    pub trigger: Trigger,
4181    /// Ordered objectives (intra-quest DAG via `after`).
4182    pub objectives: Vec<Objective>,
4183    /// Effects fired when a given objective completes.
4184    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4185    pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
4186    /// Effects fired when the whole quest completes.
4187    pub on_complete: Vec<QuestEffect>,
4188    /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
4189    /// live during this quest — spawned and not explicitly removed — where it
4190    /// stands, what it is doing, and what its right-click offers *for this
4191    /// quest's duration*.
4192    ///
4193    /// The ledger exists because an NPC's dialogue used to be one tree for the
4194    /// whole campaign: after the climactic escape a crew member still offered
4195    /// "Tell me what he is." — a premise question absurd once the story moved on.
4196    /// Declaring the scene per quest makes the compiler able to check it
4197    /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
4198    /// emitted right-click shows the root this quest declares, so a stale root
4199    /// retires *because the ledger says so*, not because an author remembered a
4200    /// flag.
4201    ///
4202    /// Every NPC live during the quest owes an entry (`DW0460`).
4203    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4204    pub cast: BTreeMap<NpcId, CastEntry>,
4205    /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
4206    /// `DW0481`).
4207    #[serde(default, skip_serializing_if = "Option::is_none")]
4208    pub happening: Option<Happening>,
4209}
4210
4211/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
4212///
4213/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
4214/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
4215/// per-branch casts, each gated by the flags that select its branch (spec-0020
4216/// proof 4: where the effect history is branch-dependent, a single flat
4217/// declaration cannot hold on every reachable branch, and `DW0462` says so).
4218#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4219#[serde(untagged)]
4220pub enum CastEntry {
4221    /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
4222    /// placement whose `at` is that keyword and which carries no `doing` or
4223    /// `dialogue` — a character who is not in the world has no business and
4224    /// answers no right-click.
4225    Absent(CastAbsence),
4226    /// A single placement that must hold on every reachable branch.
4227    Placement(CastPlacement),
4228    /// Per-branch placements (spec-0020 proof 4). Each carries the
4229    /// `requires_flags`/`forbids_flags` that select its branch.
4230    Branches(Vec<CastPlacement>),
4231}
4232
4233impl CastEntry {
4234    /// Every placement this entry declares, in declared order. The bare-keyword
4235    /// form yields none — there is no scene to check.
4236    pub fn placements(&self) -> Vec<&CastPlacement> {
4237        match self {
4238            CastEntry::Absent(_) => Vec::new(),
4239            CastEntry::Placement(p) => vec![p],
4240            CastEntry::Branches(ps) => ps.iter().collect(),
4241        }
4242    }
4243
4244    /// Every placement this entry declares, mutably (the l10n traversal).
4245    pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
4246        match self {
4247            CastEntry::Absent(_) => Vec::new(),
4248            CastEntry::Placement(p) => vec![p],
4249            CastEntry::Branches(ps) => ps.iter_mut().collect(),
4250        }
4251    }
4252
4253    /// The bare-keyword absence this entry declares, if it is that form.
4254    pub fn absence(&self) -> Option<CastAbsence> {
4255        match self {
4256            CastEntry::Absent(a) => Some(*a),
4257            _ => None,
4258        }
4259    }
4260
4261    /// True if this entry declares the NPC out of the world entirely — the bare
4262    /// keyword, or every placement's `at` being a keyword.
4263    pub fn is_absent(&self) -> bool {
4264        match self {
4265            CastEntry::Absent(_) => true,
4266            CastEntry::Placement(p) => p.at.absence().is_some(),
4267            CastEntry::Branches(ps) => {
4268                !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
4269            }
4270        }
4271    }
4272}
4273
4274/// A declared absence: the NPC is deliberately not in the world.
4275#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4276#[serde(rename_all = "kebab-case")]
4277pub enum CastAbsence {
4278    /// Removed from the story for good (must match a `despawn-npc` with no later
4279    /// `spawn-npc`).
4280    Dead,
4281    /// Not in the world for this quest, but may return (must match a
4282    /// `despawn-npc`).
4283    Offstage,
4284}
4285
4286impl CastAbsence {
4287    /// The authored keyword (`dead` / `offstage`).
4288    pub fn token(self) -> &'static str {
4289        match self {
4290            CastAbsence::Dead => "dead",
4291            CastAbsence::Offstage => "offstage",
4292        }
4293    }
4294}
4295
4296/// Where a cast entry puts an NPC: a prefab anchor, a mark, or a declared
4297/// absence.
4298#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4299#[serde(untagged)]
4300pub enum CastPlace {
4301    /// `"dead"` or `"offstage"` — explicitly not in the world.
4302    Absent(CastAbsence),
4303    /// The anchor the NPC stands on for this quest's duration. Must equal the
4304    /// position the effect history actually produces (`DW0461`): declaring an
4305    /// anchor does not teleport anybody.
4306    Anchor(AnchorId),
4307    /// The mark the NPC stands on for this quest's duration (spec-0066): the
4308    /// spelling for a body that stands at an offset from its anchor. `DW0461`
4309    /// compares anchor and offset both.
4310    Mark(Mark),
4311}
4312
4313impl CastPlace {
4314    /// The anchor this place names, if it names one.
4315    pub fn anchor(&self) -> Option<&AnchorId> {
4316        match self {
4317            CastPlace::Anchor(a) => Some(a),
4318            CastPlace::Mark(m) => Some(&m.anchor),
4319            CastPlace::Absent(_) => None,
4320        }
4321    }
4322
4323    /// The mark this place names, if it names one: a bare anchor is the mark at
4324    /// a zero offset.
4325    pub fn mark(&self) -> Option<Mark> {
4326        match self {
4327            CastPlace::Anchor(a) => Some(Mark::at(a.clone())),
4328            CastPlace::Mark(m) => Some(m.clone()),
4329            CastPlace::Absent(_) => None,
4330        }
4331    }
4332
4333    /// The declared absence, if this place is one.
4334    pub fn absence(&self) -> Option<CastAbsence> {
4335        match self {
4336            CastPlace::Absent(a) => Some(*a),
4337            CastPlace::Anchor(_) | CastPlace::Mark(_) => None,
4338        }
4339    }
4340
4341    /// The authored token, for diagnostics.
4342    pub fn token(&self) -> String {
4343        match self {
4344            CastPlace::Absent(a) => a.token().to_string(),
4345            CastPlace::Anchor(a) => a.as_str().to_string(),
4346            CastPlace::Mark(m) => m.display(),
4347        }
4348    }
4349}
4350
4351/// One declared scene for one NPC in one quest.
4352#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4353#[serde(deny_unknown_fields)]
4354pub struct CastPlacement {
4355    /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
4356    pub at: CastPlace,
4357    /// What the character is *doing* — free prose, never machine-checked.
4358    ///
4359    /// This field is the forcing function, which is the whole reason it is
4360    /// required (`DW0463`) despite being unverifiable: you cannot fill it in
4361    /// without deciding the character's business in this beat, and stage 6
4362    /// receives it as the context the NPC's lines are written against.
4363    #[serde(default, skip_serializing_if = "Option::is_none")]
4364    pub doing: Option<String>,
4365    /// What right-click offers during this quest. Required for an on-stage
4366    /// placement (`DW0463`) — including the explicit `"none"`.
4367    #[serde(default, skip_serializing_if = "Option::is_none")]
4368    pub dialogue: Option<CastDialogue>,
4369    /// Branch gate (per-branch casts): this placement describes the world only
4370    /// once every listed flag is set. Mirrors an option's `requires_flags`.
4371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4372    pub requires_flags: Vec<FlagId>,
4373    /// Negative branch gate: this placement describes the world only while no
4374    /// listed flag is set. Mirrors an option's `forbids_flags`.
4375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4376    pub forbids_flags: Vec<FlagId>,
4377    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
4378    /// hold for this gate to be open. The third field of the one gate, carried by
4379    /// every gate consumer — never by the verb that first wanted it. Default
4380    /// empty, so a pre-0.10 campaign is byte-identical.
4381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4382    pub requires_state: Vec<StateCompare>,
4383}
4384
4385/// What an NPC's right-click offers for a quest's duration.
4386///
4387/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
4388/// `{"barks": […]}` pool, then a dialogue root id.
4389#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4390#[serde(untagged)]
4391pub enum CastDialogue {
4392    /// A keyword: `"none"` or `"unchanged"`.
4393    Keyword(CastDialogueKeyword),
4394    /// A bark pool: right-click yields one inconsequential in-character line —
4395    /// no tree, no options, no consequences.
4396    Barks(CastBarks),
4397    /// A dialogue root id: right-click opens this node of the NPC's stage-6
4398    /// tree. Must be a node of *that* NPC's tree (`DW0464`).
4399    Root(DialogueId),
4400}
4401
4402/// The keyword forms of [`CastDialogue`].
4403#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4404#[serde(rename_all = "kebab-case")]
4405pub enum CastDialogueKeyword {
4406    /// Genuinely no reaction: the right-click is recorded and consumed, and
4407    /// nothing opens. Legal, but a last resort — if a body is clickable, the
4408    /// world should answer; prefer a bark.
4409    None,
4410    /// Carry forward whatever this NPC's dialogue was at its **previous**
4411    /// appearance in the quest-DAG ordering.
4412    ///
4413    /// The point is that carrying dialogue forward is a *conscious, declared
4414    /// act*. It is never an implicit default — an omitted `dialogue` is
4415    /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
4416    /// intent without re-spelling a root id that then drifts out of sync. It
4417    /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
4418    /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
4419    ///
4420    /// Emission is a **no-op** — no root swap is emitted for that NPC at that
4421    /// quest — which is what makes the sugar cheap and byte-stable.
4422    Unchanged,
4423}
4424
4425/// A bark pool: inconsequential in-character lines, cycled deterministically.
4426#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4427#[serde(deny_unknown_fields)]
4428pub struct CastBarks {
4429    /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
4430    /// they enter the l10n inventory like any narrate text.
4431    pub barks: Vec<String>,
4432}
4433
4434/// What triggers a quest.
4435#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4436#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
4437pub enum Trigger {
4438    /// Fires when the campaign starts.
4439    CampaignStart,
4440    /// Fires when another quest completes.
4441    QuestComplete {
4442        /// The prerequisite quest.
4443        quest: QuestId,
4444    },
4445}
4446
4447/// A quest objective.
4448///
4449/// Every variant may carry `requires_flags`:
4450/// flag-gated activation, satisfied only once each referenced flag has been set
4451/// by a `set-flag` effect.
4452#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4453#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
4454pub enum Objective {
4455    /// Completed by a dialogue option's `complete-objective` effect.
4456    TalkTo {
4457        /// Objective id.
4458        id: ObjectiveId,
4459        /// Short player-facing objective name (v0.3, optional).
4460        #[serde(default, skip_serializing_if = "Option::is_none")]
4461        title: Option<String>,
4462        /// One-line location/direction hint (v0.3, optional).
4463        #[serde(default, skip_serializing_if = "Option::is_none")]
4464        hint: Option<String>,
4465        /// Whether this objective is **announced** — the `New objective` line, the
4466        /// hint's line, the cue sound and the `Objective complete` line (spec-0093).
4467        /// Absent = the campaign's [`Guidance::announcements`]. An objective with
4468        /// no `title` is never announced whatever this says; `shown` on one is
4469        /// `DW0961`. A hint on an unannounced objective is `DW0862`.
4470        #[serde(default, skip_serializing_if = "Option::is_none")]
4471        announcement: Option<Visibility>,
4472        /// The NPC to talk to.
4473        npc: NpcId,
4474        /// Prerequisite objectives (intra-quest ordering).
4475        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4476        after: Vec<ObjectiveId>,
4477        /// Flags that must be set before this objective activates (v0.3).
4478        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4479        requires_flags: Vec<FlagId>,
4480        /// Negative flag gate (DSL v0.6): the
4481        /// objective is suppressed (cannot activate or complete) while ANY listed
4482        /// flag is set for the player — the dual of `requires_flags`.
4483        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4484        forbids_flags: Vec<FlagId>,
4485        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4486        /// must hold for this gate to be open. The third field of the one gate,
4487        /// carried by every gate consumer — never by the verb that first wanted
4488        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4489        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4490        requires_state: Vec<StateCompare>,
4491        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4492        /// bot should traverse sneaking (sprint disabled). Emitted into
4493        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4494        /// hint; no datapack effect.
4495        #[serde(default, skip_serializing_if = "is_false")]
4496        stealth: bool,
4497        /// What this objective does to the story (DSL v0.8, spec-0025; required
4498        /// at 0.8.0, `DW0481`).
4499        #[serde(default, skip_serializing_if = "Option::is_none")]
4500        happening: Option<Happening>,
4501    },
4502    /// Completed by reaching an anchor once prerequisites are met.
4503    ReachAnchor {
4504        /// Objective id.
4505        id: ObjectiveId,
4506        /// Short player-facing objective name (v0.3, optional).
4507        #[serde(default, skip_serializing_if = "Option::is_none")]
4508        title: Option<String>,
4509        /// One-line location/direction hint (v0.3, optional).
4510        #[serde(default, skip_serializing_if = "Option::is_none")]
4511        hint: Option<String>,
4512        /// Whether this objective is **announced** — the `New objective` line, the
4513        /// hint's line, the cue sound and the `Objective complete` line (spec-0093).
4514        /// Absent = the campaign's [`Guidance::announcements`]. An objective with
4515        /// no `title` is never announced whatever this says; `shown` on one is
4516        /// `DW0961`. A hint on an unannounced objective is `DW0862`.
4517        #[serde(default, skip_serializing_if = "Option::is_none")]
4518        announcement: Option<Visibility>,
4519        /// The anchor to reach.
4520        anchor: AnchorId,
4521        /// Whether the glowing end-rod marker is summoned at the anchor when this
4522        /// objective activates (spec-0093). Absent = the campaign's
4523        /// [`Guidance::markers`]. The completion volume is adjudicated either way.
4524        #[serde(default, skip_serializing_if = "Option::is_none")]
4525        marker: Option<Visibility>,
4526        /// Completion radius (blocks).
4527        radius: u32,
4528        /// Prerequisite objectives (intra-quest ordering).
4529        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4530        after: Vec<ObjectiveId>,
4531        /// Flags that must be set before this objective activates (v0.3).
4532        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4533        requires_flags: Vec<FlagId>,
4534        /// Negative flag gate (DSL v0.6): the
4535        /// objective is suppressed (cannot activate or complete) while ANY listed
4536        /// flag is set for the player — the dual of `requires_flags`.
4537        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4538        forbids_flags: Vec<FlagId>,
4539        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4540        /// must hold for this gate to be open. The third field of the one gate,
4541        /// carried by every gate consumer — never by the verb that first wanted
4542        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4543        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4544        requires_state: Vec<StateCompare>,
4545        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4546        /// bot should traverse sneaking (sprint disabled). Emitted into
4547        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4548        /// hint; no datapack effect.
4549        #[serde(default, skip_serializing_if = "is_false")]
4550        stealth: bool,
4551        /// What this objective does to the story (DSL v0.8, spec-0025; required
4552        /// at 0.8.0, `DW0481`).
4553        #[serde(default, skip_serializing_if = "Option::is_none")]
4554        happening: Option<Happening>,
4555    },
4556    /// Completed when the referenced wave is fully slain (v0.3).
4557    Kill {
4558        /// Objective id.
4559        id: ObjectiveId,
4560        /// Short player-facing objective name (v0.3, optional).
4561        #[serde(default, skip_serializing_if = "Option::is_none")]
4562        title: Option<String>,
4563        /// One-line location/direction hint (v0.3, optional).
4564        #[serde(default, skip_serializing_if = "Option::is_none")]
4565        hint: Option<String>,
4566        /// Whether this objective is **announced** — the `New objective` line, the
4567        /// hint's line, the cue sound and the `Objective complete` line (spec-0093).
4568        /// Absent = the campaign's [`Guidance::announcements`]. An objective with
4569        /// no `title` is never announced whatever this says; `shown` on one is
4570        /// `DW0961`. A hint on an unannounced objective is `DW0862`.
4571        #[serde(default, skip_serializing_if = "Option::is_none")]
4572        announcement: Option<Visibility>,
4573        /// The wave (stage-5 `waves` ref) whose mobs must be slain.
4574        wave: WaveId,
4575        /// Prerequisite objectives.
4576        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4577        after: Vec<ObjectiveId>,
4578        /// Flags that must be set before this objective activates (v0.3).
4579        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4580        requires_flags: Vec<FlagId>,
4581        /// Negative flag gate (DSL v0.6): the
4582        /// objective is suppressed (cannot activate or complete) while ANY listed
4583        /// flag is set for the player — the dual of `requires_flags`.
4584        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4585        forbids_flags: Vec<FlagId>,
4586        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4587        /// must hold for this gate to be open. The third field of the one gate,
4588        /// carried by every gate consumer — never by the verb that first wanted
4589        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4590        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4591        requires_state: Vec<StateCompare>,
4592        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4593        /// bot should traverse sneaking (sprint disabled). Emitted into
4594        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4595        /// hint; no datapack effect.
4596        #[serde(default, skip_serializing_if = "is_false")]
4597        stealth: bool,
4598        /// What this objective does to the story (DSL v0.8, spec-0025; required
4599        /// at 0.8.0, `DW0481`).
4600        #[serde(default, skip_serializing_if = "Option::is_none")]
4601        happening: Option<Happening>,
4602    },
4603    /// Completed when `count` of `item` have been collected (v0.3).
4604    ///
4605    /// The items are provided in a container: the compiler's own chest at
4606    /// `anchor` by default, or — since DSL v0.8 — the prefab's existing
4607    /// chest/barrel at [`Objective::Collect::container`], optionally carrying an
4608    /// [`Objective::Collect::item_name`] and padded to read full with
4609    /// [`Objective::Collect::fill_count`].
4610    Collect {
4611        /// Objective id.
4612        id: ObjectiveId,
4613        /// Short player-facing objective name (v0.3, optional).
4614        #[serde(default, skip_serializing_if = "Option::is_none")]
4615        title: Option<String>,
4616        /// One-line location/direction hint (v0.3, optional).
4617        #[serde(default, skip_serializing_if = "Option::is_none")]
4618        hint: Option<String>,
4619        /// Whether this objective is **announced** — the `New objective` line, the
4620        /// hint's line, the cue sound and the `Objective complete` line (spec-0093).
4621        /// Absent = the campaign's [`Guidance::announcements`]. An objective with
4622        /// no `title` is never announced whatever this says; `shown` on one is
4623        /// `DW0961`. A hint on an unannounced objective is `DW0862`.
4624        #[serde(default, skip_serializing_if = "Option::is_none")]
4625        announcement: Option<Visibility>,
4626        /// Vanilla item id to collect (validated against the registry).
4627        item: String,
4628        /// How many are required.
4629        count: u32,
4630        /// The anchor items are provided at (chest / pickup).
4631        anchor: AnchorId,
4632        /// **Adopt the container the prefab already placed**: the anchor whose
4633        /// assembled-world cell holds
4634        /// a `chest` / `trapped_chest` / `barrel` this collect fills instead of
4635        /// conjuring its own chest at [`Objective::Collect::anchor`].
4636        ///
4637        /// Same division of labour a `loot` entry and a trap's dispenser already
4638        /// keep with the prefab: furniture belongs in the piece. A beach camp's
4639        /// barrel is scenery the player has been walking past since minute one —
4640        /// having the compiler `setblock` a *second*, floating chest beside it to
4641        /// hold the quest item is exactly the downstream workaround the no-hack
4642        /// rule forbids. A `container` whose cell holds no container is a build
4643        /// error (`DW0438`), never a silent fill into a wall.
4644        ///
4645        /// The critical-path step's position follows the container (the bot opens
4646        /// *this* block), and no chest is placed at `anchor` when it is set.
4647        #[serde(default, skip_serializing_if = "Option::is_none")]
4648        container: Option<AnchorId>,
4649        /// **The item comes off a body, not out of a box**: the wave whose
4650        /// declared `drops[]` yield
4651        /// this objective's item. No container is placed — not the compiler's own
4652        /// chest at `anchor`, not a prefab one — and `container` is therefore
4653        /// mutually exclusive with it (`DW0100`-adjacent; `DW0492`).
4654        ///
4655        /// This is what makes "kill the boss → pick up its key → open the door"
4656        /// a *proved* chain rather than an authoring intention. The compiler
4657        /// requires (a) that the named wave really declares an `{item}` drop of
4658        /// this item (`DW0492`), and (b) that a `kill` objective for that wave
4659        /// precedes this collect in the objective graph (`DW0493`). The existing
4660        /// flow machinery then carries the ordering the rest of the way: the
4661        /// door's `requires_flags` hangs off this collect exactly as it would off
4662        /// a chest one.
4663        ///
4664        /// **Waves only.** An actor's death is not observable by any objective —
4665        /// there is no vanilla-side signal the flow machinery could consume — so
4666        /// an actor-gated collect would be an unprovable claim, and per the
4667        /// no-hack doctrine it is excluded rather than approximated. An actor may
4668        /// still declare `drops[]`; those drops just cannot gate a quest.
4669        #[serde(default, skip_serializing_if = "Option::is_none")]
4670        dropped_by: Option<WaveId>,
4671        /// Display name for the collected item (DSL v0.8), emitted as the vanilla `custom_name` item component.
4672        ///
4673        /// A quest item is a *named thing* in the story ("Cheese", "Tide
4674        /// Ledger"), and a player who opens the barrel must read that name — an
4675        /// unnamed `minecraft:pumpkin_pie` says nothing about what the quest asked
4676        /// for. Player-visible, so it enters the l10n string inventory
4677        /// (`obj.<quest>.<obj>.item_name`) and translates like any other line.
4678        ///
4679        /// Naming changes nothing about adjudication: the completion advancement
4680        /// and the per-tick held check both match on the ITEM ID, which a named
4681        /// stack still carries.
4682        #[serde(default, skip_serializing_if = "Option::is_none")]
4683        item_name: Option<String>,
4684        /// Padding stacks that make the container **read full**. Default `0` =
4685        /// the single required
4686        /// stack and nothing else.
4687        ///
4688        /// A barrel of cheese that opens on one lonely wheel reads as a bug, and
4689        /// vanilla's notion of "full" is *occupied slots*, not stack size — so
4690        /// this counts SLOTS: the objective's own stack lands in `container.0` and
4691        /// each padding stack repeats it in `container.1`, `container.2`, … Slot
4692        /// assignment is positional and total, the same determinism story `loot`
4693        /// tells (ADR-0006): no RNG, no loot tables, nothing to reseed.
4694        ///
4695        /// The padding is the same item, so taking the whole barrel still
4696        /// completes the objective and never over- or under-counts it.
4697        #[serde(default, skip_serializing_if = "is_zero")]
4698        fill_count: u32,
4699        /// Prerequisite objectives.
4700        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4701        after: Vec<ObjectiveId>,
4702        /// Flags that must be set before this objective activates (v0.3).
4703        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4704        requires_flags: Vec<FlagId>,
4705        /// Negative flag gate (DSL v0.6): the
4706        /// objective is suppressed (cannot activate or complete) while ANY listed
4707        /// flag is set for the player — the dual of `requires_flags`.
4708        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4709        forbids_flags: Vec<FlagId>,
4710        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4711        /// must hold for this gate to be open. The third field of the one gate,
4712        /// carried by every gate consumer — never by the verb that first wanted
4713        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4714        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4715        requires_state: Vec<StateCompare>,
4716        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4717        /// bot should traverse sneaking (sprint disabled). Emitted into
4718        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4719        /// hint; no datapack effect.
4720        #[serde(default, skip_serializing_if = "is_false")]
4721        stealth: bool,
4722        /// What this objective does to the story (DSL v0.8, spec-0025; required
4723        /// at 0.8.0, `DW0481`).
4724        #[serde(default, skip_serializing_if = "Option::is_none")]
4725        happening: Option<Happening>,
4726    },
4727    /// Completed by interacting with an entity at `anchor`; if `requires_item` is
4728    /// set, the item must be **held in the main hand** (v0.3; held semantics since
4729    /// DSL v0.7 — see [`Objective::Interact::requires_item`]).
4730    Interact {
4731        /// Objective id.
4732        id: ObjectiveId,
4733        /// Short player-facing objective name (v0.3, optional).
4734        #[serde(default, skip_serializing_if = "Option::is_none")]
4735        title: Option<String>,
4736        /// One-line location/direction hint (v0.3, optional).
4737        #[serde(default, skip_serializing_if = "Option::is_none")]
4738        hint: Option<String>,
4739        /// Whether this objective is **announced** — the `New objective` line, the
4740        /// hint's line, the cue sound and the `Objective complete` line (spec-0093).
4741        /// Absent = the campaign's [`Guidance::announcements`]. An objective with
4742        /// no `title` is never announced whatever this says; `shown` on one is
4743        /// `DW0961`. A hint on an unannounced objective is `DW0862`.
4744        #[serde(default, skip_serializing_if = "Option::is_none")]
4745        announcement: Option<Visibility>,
4746        /// The anchor the interaction entity stands at.
4747        anchor: AnchorId,
4748        /// Whether the glowing lantern marker is summoned beside the hitbox when
4749        /// this objective activates (spec-0093). Absent = the campaign's
4750        /// [`Guidance::markers`]. The `minecraft:interaction` hitbox is summoned
4751        /// either way — it is what the player presses. Meaningless beside a
4752        /// `prop`, which never had a marker: declaring both is `DW0962`.
4753        #[serde(default, skip_serializing_if = "Option::is_none")]
4754        marker: Option<Visibility>,
4755        /// Item the player must be **holding in the main hand** for the
4756        /// interaction to complete (optional).
4757        ///
4758        /// Held, not merely possessed: presenting the
4759        /// item IS the action — a player who right-clicks a sleeping giant with a
4760        /// sharpened stake buried in their backpack has not stabbed anything.
4761        /// Before this ruling the gate read the whole inventory, which made every
4762        /// `requires_item` interaction fire the moment the item was picked up
4763        /// anywhere, whatever the player was actually doing with their hands.
4764        #[serde(default, skip_serializing_if = "Option::is_none")]
4765        requires_item: Option<String>,
4766        /// Diegetic feedback for a click that arrives without the required item in
4767        /// hand (DSL v0.7): narrated to that player in
4768        /// chat instead of the silence the gate used to answer with. Requires
4769        /// `requires_item` (`DW0437`).
4770        ///
4771        /// Only fires while the objective is genuinely open — same activation gate
4772        /// as the affordance itself — so a finished or not-yet-active interaction
4773        /// stays quiet.
4774        #[serde(default, skip_serializing_if = "Option::is_none")]
4775        missing_item_hint: Option<String>,
4776        /// Prop block that IS the interaction affordance (DSL v0.4, spec-0008
4777        /// §2): the compiler `setblock`s it at the anchor on activation (exactly
4778        /// as `collect` uses a real chest). Omitted = the glowing-lantern
4779        /// hologram marker (the v0.3 fallback).
4780        #[serde(default, skip_serializing_if = "Option::is_none")]
4781        prop: Option<Prop>,
4782        /// Prerequisite objectives.
4783        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4784        after: Vec<ObjectiveId>,
4785        /// Flags that must be set before this objective activates (v0.3).
4786        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4787        requires_flags: Vec<FlagId>,
4788        /// Negative flag gate (DSL v0.6): the
4789        /// objective is suppressed (cannot activate or complete) while ANY listed
4790        /// flag is set for the player — the dual of `requires_flags`.
4791        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4792        forbids_flags: Vec<FlagId>,
4793        /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4794        /// must hold for this gate to be open. The third field of the one gate,
4795        /// carried by every gate consumer — never by the verb that first wanted
4796        /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4797        #[serde(default, skip_serializing_if = "Vec::is_empty")]
4798        requires_state: Vec<StateCompare>,
4799        /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4800        /// bot should traverse sneaking (sprint disabled). Emitted into
4801        /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4802        /// hint; no datapack effect.
4803        #[serde(default, skip_serializing_if = "is_false")]
4804        stealth: bool,
4805        /// What this objective does to the story (DSL v0.8, spec-0025; required
4806        /// at 0.8.0, `DW0481`).
4807        #[serde(default, skip_serializing_if = "Option::is_none")]
4808        happening: Option<Happening>,
4809    },
4810}
4811
4812/// A prop block for an `interact` objective or a `use` trigger (DSL v0.4;
4813/// spec-0093 §6.5). The block is the affordance the player interacts with; its
4814/// id is validated against the pinned 1.21.11 block registry (`DW0193`).
4815///
4816/// **When the block is one a hand presses** — a lever or a button
4817/// ([`crate::blockshape::is_hand_pressed`]) — the block IS the detector: the
4818/// compiler summons no `minecraft:interaction` hitbox and the act is vanilla's
4819/// own, reported by the `default_block_use` advancement criterion at the
4820/// block's cell. Any other block is placed and the invisible hitbox stands in
4821/// its cell, because vanilla reports no use of it.
4822#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4823#[serde(deny_unknown_fields)]
4824pub struct Prop {
4825    /// Vanilla block id, with an optional blockstate suffix (e.g.
4826    /// `minecraft:lever[face=floor,facing=north]`).
4827    pub block: String,
4828}
4829
4830impl Prop {
4831    /// Whether this prop's block is one a hand presses — the block is then the
4832    /// act's own detector (spec-0093 §6.5).
4833    pub fn is_hand_pressed(&self) -> bool {
4834        crate::blockshape::is_hand_pressed(&self.block)
4835    }
4836}
4837
4838// ---------------------------------------------------------------------------
4839// Stage 5 — scripted actors (DSL v0.6, spec-0014)
4840// ---------------------------------------------------------------------------
4841
4842/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
4843/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
4844/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
4845/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
4846/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
4847/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
4848/// by a real-AI twin with `unleash-actor`.
4849#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4850#[serde(deny_unknown_fields)]
4851pub struct Actor {
4852    /// Unique actor id (`actor/<kebab>`).
4853    pub id: ActorId,
4854    /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
4855    /// pinned 1.21.11 entity registry (`DW0173`).
4856    pub entity: String,
4857    /// Optional custom name shown above the puppet.
4858    #[serde(default, skip_serializing_if = "Option::is_none")]
4859    pub name: Option<String>,
4860    /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
4861    #[serde(default, skip_serializing_if = "Option::is_none")]
4862    pub skin: Option<NpcSkin>,
4863    /// The anchor the puppet is summoned on (resolved across areas, like an
4864    /// `open-gate` / `move-npc` destination).
4865    pub anchor: AnchorId,
4866    /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
4867    /// `[0, 0, 0]`): the puppet stands at the [`Mark`] the two fields spell, so
4868    /// a rank of bodies is one anchor and an offset apiece.
4869    #[serde(default, skip_serializing_if = "is_zero3")]
4870    pub offset: [i32; 3],
4871    /// Initial facing (default `south`). The puppet spawns yawed this way.
4872    #[serde(default, skip_serializing_if = "Option::is_none")]
4873    pub facing: Option<Facing>,
4874    /// If `true`, the puppet is damageable (a tower-defense creep) but stays
4875    /// knockback-immune; default `false` (fully `Invulnerable`).
4876    #[serde(default, skip_serializing_if = "is_false")]
4877    pub vulnerable: bool,
4878    /// Gear the actor wears and holds, in the same shape a wave mob uses
4879    /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
4880    /// unleashed twin, so the dormant elite the player has been circling is
4881    /// visibly the same armoured thing that stands up. Drop chances are zero —
4882    /// wave gear and actor gear are never farmable (no-grind constitution).
4883    #[serde(default, skip_serializing_if = "Option::is_none")]
4884    pub equipment: Option<MobEquipment>,
4885    /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
4886    /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
4887    /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
4888    /// elite the party fights is the elite the author tuned; without it an actor
4889    /// was stuck at vanilla base values while every wave mob could be tuned,
4890    /// which is what blocked elite authoring. A `vulnerable` actor's
4891    /// knockback-immunity is emitted first and is not authorable.
4892    #[serde(default, skip_serializing_if = "Option::is_none")]
4893    pub attributes: Option<MobAttributes>,
4894    /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
4895    /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
4896    /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
4897    ///
4898    /// A wave is not the only shape an elite takes. The set-piece souls fight —
4899    /// the armoured thing kneeling among the graves that stands up when you hit
4900    /// it — is an **actor**: staged by `spawn-actor`, given AI by
4901    /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
4902    /// this field nothing anywhere stated what such a fight was billed as.
4903    ///
4904    /// Like the wave field this is a **declaration, not a knob**: the compiler
4905    /// never scales an actor from it, and emission is unchanged whichever tier is
4906    /// declared. Its readers are the health-bar advisory (`DW0912`) and the drop
4907    /// rule — only a billed fight leaves anything behind.
4908    #[serde(default, skip_serializing_if = "Option::is_none")]
4909    pub tier: Option<EncounterTier>,
4910    /// A health bar over this actor's fight (DSL v0.31, spec-0073) — the same
4911    /// [`HealthBar`](crate::healthbar::HealthBar) a [`Wave`] declares. It reads the
4912    /// bodies whose health can move: the unleashed twin, or the puppet itself when
4913    /// the actor is `vulnerable`. A bar on an actor that is neither is `DW0909`.
4914    /// Absent = no bar, byte-identical.
4915    #[serde(default, skip_serializing_if = "Option::is_none")]
4916    pub health_bar: Option<crate::healthbar::HealthBar>,
4917    /// What this actor leaves behind when a player kills it. Only an
4918    /// `elite`/`boss` actor may
4919    /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
4920    /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
4921    /// not to one of its two lifecycles. A `despawn-actor` strips the
4922    /// declaration off the body before removing it, so re-caging an elite (a
4923    /// souls re-seat) never scatters its axe.
4924    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4925    pub drops: Vec<MobDrop>,
4926    /// What this body can do when it moves (DSL v0.11, spec-0034) — the same
4927    /// [`BodyTraversal`] a stage-2 [`Npc`] carries, because traversal belongs to
4928    /// the body and not to the stage that declares it. Absent = the class the
4929    /// compiler derives from `entity` (or from `minecraft:mannequin` when `skin`
4930    /// is set).
4931    #[serde(default, skip_serializing_if = "Option::is_none")]
4932    pub traversal: Option<BodyTraversal>,
4933    /// What happens each time a player is credited with killing this actor's
4934    /// body (spec-0074) — effect root R9, the same [`OnKill`] a
4935    /// wave declares. Absent = no bundle, and the actor's emission is
4936    /// byte-identical.
4937    #[serde(default, skip_serializing_if = "Option::is_none")]
4938    pub on_kill: Option<OnKill>,
4939}
4940
4941/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
4942/// (MC: yaw 0 = +z/south).
4943#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4944#[serde(rename_all = "kebab-case")]
4945pub enum Facing {
4946    /// Facing +z (yaw 0) — the default.
4947    South,
4948    /// Facing -z (yaw 180).
4949    North,
4950    /// Facing -x (yaw 90).
4951    West,
4952    /// Facing +x (yaw 270).
4953    East,
4954}
4955
4956impl Facing {
4957    /// The kebab token (`south` / `north` / `west` / `east`).
4958    pub fn token(self) -> &'static str {
4959        match self {
4960            Facing::South => "south",
4961            Facing::North => "north",
4962            Facing::West => "west",
4963            Facing::East => "east",
4964        }
4965    }
4966}
4967
4968/// Whether a `cutscene` shows the party's bodies (spec-0095).
4969#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4970#[serde(rename_all = "kebab-case")]
4971pub enum CutsceneParty {
4972    /// Each player in play is shown by a stand-in where they stood: a
4973    /// `minecraft:mannequin` wearing their own profile (skin) and a copy of
4974    /// their armour and held items, removed when the cutscene ends.
4975    #[default]
4976    Present,
4977    /// No stand-ins: the bodies leave the scene for the cutscene's length.
4978    Absent,
4979}
4980
4981impl CutsceneParty {
4982    /// The kebab token (`present` / `absent`).
4983    pub fn token(self) -> &'static str {
4984        match self {
4985            CutsceneParty::Present => "present",
4986            CutsceneParty::Absent => "absent",
4987        }
4988    }
4989}
4990
4991/// How a `despawn-actor` removes its puppet (DSL v0.6).
4992#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4993#[serde(rename_all = "kebab-case")]
4994pub enum DespawnStyle {
4995    /// The body leaves unseen: no death animation, red flash or death particles
4996    /// where it stood. It is moved under the world and removed there.
4997    Vanish,
4998    /// The body dies where it stands, with the vanilla death animation (a death
4999    /// the player is meant to watch).
5000    Kill,
5001}
5002
5003impl DespawnStyle {
5004    /// The kebab token (`vanish` / `kill`).
5005    pub fn token(self) -> &'static str {
5006        match self {
5007            DespawnStyle::Vanish => "vanish",
5008            DespawnStyle::Kill => "kill",
5009        }
5010    }
5011}
5012
5013// ---------------------------------------------------------------------------
5014// Stage 5 — assemblies (spec-0082)
5015// ---------------------------------------------------------------------------
5016
5017/// A fixed thing that can be hit and hits back (spec-0082): an object built of
5018/// display entities standing at a [`Mark`], moving through the clips of a
5019/// library [`rig`](crate::rig), struck in melee through an optional
5020/// `minecraft:interaction` hitbox, and striking a player who stands in its
5021/// arming region.
5022///
5023/// It is not a fight class and never dies: no health, equipment, traversal,
5024/// health bar or kill credit. A hit count is an ordinary `state` datum a
5025/// `strike-assembly` trigger adds to, and what happens at a count is an effect
5026/// behind the ordinary gate.
5027#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5028#[serde(deny_unknown_fields)]
5029pub struct Assembly {
5030    /// Unique assembly id (`assembly/<kebab>`).
5031    pub id: AssemblyId,
5032    /// The library rig (`rig/<name>`, resolved to `rigs/<name>/rig.json`
5033    /// beside the prefab library) whose parts and clips this assembly is.
5034    pub rig: RigId,
5035    /// Where the rig's origin stands: an anchor and an optional offset. The
5036    /// rig's origin is the mark cell's centre at its floor plane.
5037    pub at: Mark,
5038    /// Which way the rig's `+z` front faces (default `south`). Applied by the
5039    /// compiler to every frame, so the emitted entities stand at yaw 0.
5040    #[serde(default, skip_serializing_if = "Option::is_none")]
5041    pub facing: Option<Facing>,
5042    /// The clip playing from spawn. Absent: the parts stand in the rig's rest
5043    /// pose and no clip plays until a `play-clip`.
5044    #[serde(default, skip_serializing_if = "Option::is_none")]
5045    pub initial: Option<String>,
5046    /// The `minecraft:interaction` a player strikes. Absent: the assembly
5047    /// cannot be struck, and a `strike-assembly` on it is refused (`DW0936`).
5048    #[serde(default, skip_serializing_if = "Option::is_none")]
5049    pub hitbox: Option<AssemblyHitbox>,
5050    /// The blows it deals. Absent: it never strikes.
5051    #[serde(default, skip_serializing_if = "Option::is_none")]
5052    pub strikes: Option<AssemblyStrikes>,
5053}
5054
5055impl Assembly {
5056    /// The declared facing, `south` when absent.
5057    pub fn facing(&self) -> Facing {
5058        self.facing.unwrap_or(Facing::South)
5059    }
5060}
5061
5062/// An assembly's hitbox (spec-0082 §3.2): a `minecraft:interaction` of
5063/// `width × height` whose bottom centre is the mark's cell centre plus
5064/// `offset`. Melee only: an arrow passes through an interaction.
5065#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5066#[serde(deny_unknown_fields)]
5067pub struct AssemblyHitbox {
5068    /// Width in blocks (`0 < width <= 6`, `DW0936`).
5069    pub width: f64,
5070    /// Height in blocks (`0 < height <= 22`, `DW0936`).
5071    pub height: f64,
5072    /// Integer `[x, y, z]` block offset of the box's bottom centre from the
5073    /// mark (default `[0, 0, 0]`).
5074    #[serde(default, skip_serializing_if = "is_zero3")]
5075    pub offset: [i32; 3],
5076}
5077
5078/// An assembly's strike pattern (spec-0082 §3.2).
5079///
5080/// The pattern runs, repeating from its first step, on every tick on which
5081/// some player's body is in `while_in`, and stops at the end of the step in
5082/// flight when nobody is.
5083#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5084#[serde(deny_unknown_fields)]
5085pub struct AssemblyStrikes {
5086    /// The arming region: an anchor-centred box. Every landing box lies inside
5087    /// it (`DW0938`), so a player who never entered it is never struck.
5088    pub while_in: StealthZone,
5089    /// The steps, in order.
5090    pub pattern: Vec<StrikeStep>,
5091    /// Aim (spec-0082 §5.7). Absent: every blow lands where its `on_land`
5092    /// boxes say. Present: at the start of every wind-up the assembly turns to
5093    /// the one of its declared facings nearest the bearing of the nearest
5094    /// player in `while_in`, and every `damage-players` box in `on_land` turns
5095    /// with it.
5096    #[serde(default, skip_serializing_if = "Option::is_none")]
5097    pub aim: Option<StrikeAim>,
5098}
5099
5100/// An aimed strike pattern's facings (spec-0082 §5.7): `facings` turns
5101/// evenly spaced round the vertical axis through the mark, the first being the
5102/// assembly's declared `facing`. Every `on_land` box is written for that first
5103/// facing; the compiler turns it to each of the others and proves every facing
5104/// a player in `while_in` can draw (`DW0938`, judged per facing). Which facing
5105/// a blow takes is chosen at run time among the proven ones; nothing about
5106/// where it lands is computed there.
5107#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5108#[serde(deny_unknown_fields)]
5109pub struct StrikeAim {
5110    /// How many facings, evenly spaced: 4 is a quarter turn apart, 8 an eighth,
5111    /// 16 a sixteenth. At least 1 (1 is the declared facing alone).
5112    pub facings: std::num::NonZeroU32,
5113}
5114
5115/// One step of a strike pattern: wind up, hold, strike, land.
5116///
5117/// How long the wind-up is and how hard the blow lands are the creator's
5118/// judgement, by spec-0016's standing ruling: no telegraph rule and no
5119/// one-shot rule.
5120#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5121#[serde(deny_unknown_fields)]
5122pub struct StrikeStep {
5123    /// The clip played first.
5124    pub windup: String,
5125    /// Ticks the windup's last frame is held before the strike clip starts.
5126    pub hold: u32,
5127    /// The clip the blow is.
5128    pub strike: String,
5129    /// The step's pace: the keyframe cadence, in ticks per frame (1–20, the
5130    /// rig's own bounds), its wind-up and strike clips play at. Absent: each
5131    /// clip's own. The wind-up lasts `1 + (frames - 1) × cadence` ticks, then
5132    /// `hold`; the blow lands one cadence after the strike's last frame.
5133    #[serde(default, skip_serializing_if = "Option::is_none")]
5134    pub ticks_per_frame: Option<u32>,
5135    /// A lock (spec-0094): at the start of the wind-up the step picks one
5136    /// player in `lock.within` by `lock.pick`, reads the cell their feet stand
5137    /// in, turns the assembly to it and strikes it with whichever of `strike`
5138    /// and `lock.reaches` the compiler proved comes down there. The blow's area
5139    /// is then the cells that clip comes down on, derived by the compiler: a
5140    /// `damage-players` in a locked step declares no `in` (`DW0969`). Absent:
5141    /// the blow lands where its `on_land` boxes say.
5142    #[serde(default, skip_serializing_if = "Option::is_none")]
5143    pub lock: Option<StrikeLock>,
5144    /// Effects run, with no acting player, on the tick a client has drawn the
5145    /// strike clip's last frame whole (one cadence after it is applied). A step
5146    /// with none is a feint.
5147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5148    pub on_land: Vec<QuestEffect>,
5149}
5150
5151/// A strike step's lock (spec-0094 §3.1): the step strikes where one player
5152/// stands, chosen when its wind-up begins.
5153///
5154/// A display entity cannot bend live to a point: its pose is a keyframe a rig
5155/// precomputed. So a lock is two run-time choices among things the compiler
5156/// proved — a **turn** of the whole assembly about its mark (any yaw: a `tp` of
5157/// the root turns every riding part with it), and a **pose**, the first of
5158/// `strike` then `reaches` whose last frame, at that turn, comes down on the
5159/// locked cell with its whole blow inside `while_in`. Every standable cell of
5160/// `within` owes such a pose, or the build refuses naming the cells (`DW0968`).
5161#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5162#[serde(deny_unknown_fields)]
5163pub struct StrikeLock {
5164    /// The region a target is chosen in: an anchor-centred box. The step winds
5165    /// up only while some player's body is in it, and every blow it can deal
5166    /// lies inside `while_in` (`DW0968`).
5167    pub within: StealthZone,
5168    /// Which player in `within` the step locks onto.
5169    pub pick: LockPick,
5170    /// Further strike clips, beyond the step's `strike`, the lock may choose
5171    /// among — a limb's blows at other reaches. Tried after `strike`, in the
5172    /// order written; the first that comes down on the locked cell is played.
5173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5174    pub reaches: Vec<String>,
5175}
5176
5177/// Which player a locked strike chooses (spec-0094 §3.1): vanilla's own
5178/// selector orders, measured from the assembly's mark.
5179#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5180#[serde(rename_all = "kebab-case")]
5181pub enum LockPick {
5182    /// The player nearest the mark (`sort=nearest`).
5183    Nearest,
5184    /// The player furthest from the mark (`sort=furthest`).
5185    Furthest,
5186    /// A player chosen at random (`sort=random`).
5187    Random,
5188}
5189
5190impl LockPick {
5191    /// The `sort=` value of the selector that makes the choice.
5192    pub fn sort(self) -> &'static str {
5193        match self {
5194            LockPick::Nearest => "nearest",
5195            LockPick::Furthest => "furthest",
5196            LockPick::Random => "random",
5197        }
5198    }
5199}
5200
5201/// One step of a [`Verb::Sequence`] (DSL v0.6): a group of effects fired at
5202/// an exact tick offset from the sequence's start.
5203#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5204#[serde(deny_unknown_fields)]
5205pub struct SequenceStep {
5206    /// Tick offset from the sequence start at which `effects` fire.
5207    pub at_ticks: u32,
5208    /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
5209    /// `sequence` (rejected with `DW0329`).
5210    pub effects: Vec<QuestEffect>,
5211}
5212
5213impl Objective {
5214    /// This objective's id.
5215    pub fn id(&self) -> &ObjectiveId {
5216        match self {
5217            Objective::TalkTo { id, .. }
5218            | Objective::ReachAnchor { id, .. }
5219            | Objective::Kill { id, .. }
5220            | Objective::Collect { id, .. }
5221            | Objective::Interact { id, .. } => id,
5222        }
5223    }
5224
5225    /// This objective's prerequisites.
5226    pub fn after(&self) -> &[ObjectiveId] {
5227        match self {
5228            Objective::TalkTo { after, .. }
5229            | Objective::ReachAnchor { after, .. }
5230            | Objective::Kill { after, .. }
5231            | Objective::Collect { after, .. }
5232            | Objective::Interact { after, .. } => after,
5233        }
5234    }
5235
5236    /// The short player-facing objective title (v0.3, optional).
5237    pub fn title(&self) -> Option<&str> {
5238        match self {
5239            Objective::TalkTo { title, .. }
5240            | Objective::ReachAnchor { title, .. }
5241            | Objective::Kill { title, .. }
5242            | Objective::Collect { title, .. }
5243            | Objective::Interact { title, .. } => title.as_deref(),
5244        }
5245    }
5246
5247    /// The one-line location/direction hint (v0.3, optional).
5248    pub fn hint(&self) -> Option<&str> {
5249        match self {
5250            Objective::TalkTo { hint, .. }
5251            | Objective::ReachAnchor { hint, .. }
5252            | Objective::Kill { hint, .. }
5253            | Objective::Collect { hint, .. }
5254            | Objective::Interact { hint, .. } => hint.as_deref(),
5255        }
5256    }
5257
5258    /// Mutable access to the optional player-facing title (i18n localization).
5259    pub fn title_mut(&mut self) -> &mut Option<String> {
5260        match self {
5261            Objective::TalkTo { title, .. }
5262            | Objective::ReachAnchor { title, .. }
5263            | Objective::Kill { title, .. }
5264            | Objective::Collect { title, .. }
5265            | Objective::Interact { title, .. } => title,
5266        }
5267    }
5268
5269    /// Mutable access to the optional one-line hint (i18n localization).
5270    pub fn hint_mut(&mut self) -> &mut Option<String> {
5271        match self {
5272            Objective::TalkTo { hint, .. }
5273            | Objective::ReachAnchor { hint, .. }
5274            | Objective::Kill { hint, .. }
5275            | Objective::Collect { hint, .. }
5276            | Objective::Interact { hint, .. } => hint,
5277        }
5278    }
5279
5280    /// The objective's own `announcement`, when it states one (spec-0093).
5281    pub fn announcement(&self) -> Option<Visibility> {
5282        match self {
5283            Objective::TalkTo { announcement, .. }
5284            | Objective::ReachAnchor { announcement, .. }
5285            | Objective::Kill { announcement, .. }
5286            | Objective::Collect { announcement, .. }
5287            | Objective::Interact { announcement, .. } => *announcement,
5288        }
5289    }
5290
5291    /// The objective's own `marker`, when it is a kind that has one and states
5292    /// it (spec-0093). `None` for a `talk-to`, `kill` or `collect`, which carry no
5293    /// such field, and for an `interact` or `reach-anchor` that leaves it absent.
5294    pub fn marker(&self) -> Option<Visibility> {
5295        match self {
5296            Objective::ReachAnchor { marker, .. } | Objective::Interact { marker, .. } => *marker,
5297            Objective::TalkTo { .. } | Objective::Kill { .. } | Objective::Collect { .. } => None,
5298        }
5299    }
5300
5301    /// Whether this objective's kind summons a wayfinding marker at all: a
5302    /// `reach-anchor` (its end rod) or an `interact` with no `prop` (its lantern).
5303    /// A `collect` places its chest, a `talk-to` has a body, a `kill` has bodies,
5304    /// and an `interact` with a `prop` has the prop.
5305    pub fn summons_marker(&self) -> bool {
5306        match self {
5307            Objective::ReachAnchor { .. } => true,
5308            Objective::Interact { prop, .. } => prop.is_none(),
5309            Objective::TalkTo { .. } | Objective::Kill { .. } | Objective::Collect { .. } => false,
5310        }
5311    }
5312
5313    /// **Is this objective marked** (spec-0093): its kind summons a marker and
5314    /// its resolved visibility — its own `marker`, else the campaign's
5315    /// [`Guidance::markers`] — is `shown`.
5316    pub fn marker_shown(&self, guidance: &Guidance) -> bool {
5317        self.summons_marker() && self.marker().unwrap_or(guidance.markers).is_shown()
5318    }
5319
5320    /// **Is this objective announced** (spec-0093): it has a `title` and its
5321    /// resolved visibility — its own `announcement`, else the campaign's
5322    /// [`Guidance::announcements`] — is `shown`. The emitter prints the
5323    /// activation and completion lines for exactly these objectives, so every
5324    /// rule about what the party is told reads this and nothing else.
5325    pub fn announced(&self, guidance: &Guidance) -> bool {
5326        self.title().is_some_and(|t| !t.trim().is_empty())
5327            && self
5328                .announcement()
5329                .unwrap_or(guidance.announcements)
5330                .is_shown()
5331    }
5332
5333    /// The flags that must be set before this objective activates (v0.3).
5334    pub fn requires_flags(&self) -> &[FlagId] {
5335        match self {
5336            Objective::TalkTo { requires_flags, .. }
5337            | Objective::ReachAnchor { requires_flags, .. }
5338            | Objective::Kill { requires_flags, .. }
5339            | Objective::Collect { requires_flags, .. }
5340            | Objective::Interact { requires_flags, .. } => requires_flags,
5341        }
5342    }
5343
5344    /// The negative flag gate (DSL v0.6): flags whose being set **suppresses**
5345    /// this objective. The dual of [`Objective::requires_flags`].
5346    pub fn forbids_flags(&self) -> &[FlagId] {
5347        match self {
5348            Objective::TalkTo { forbids_flags, .. }
5349            | Objective::ReachAnchor { forbids_flags, .. }
5350            | Objective::Kill { forbids_flags, .. }
5351            | Objective::Collect { forbids_flags, .. }
5352            | Objective::Interact { forbids_flags, .. } => forbids_flags,
5353        }
5354    }
5355
5356    /// The numeric gate terms (DSL v0.10, spec-0031): comparisons that must hold
5357    /// before this objective activates. See [`StateCompare`].
5358    pub fn requires_state(&self) -> &[StateCompare] {
5359        match self {
5360            Objective::TalkTo { requires_state, .. }
5361            | Objective::ReachAnchor { requires_state, .. }
5362            | Objective::Kill { requires_state, .. }
5363            | Objective::Collect { requires_state, .. }
5364            | Objective::Interact { requires_state, .. } => requires_state,
5365        }
5366    }
5367
5368    /// What this objective does to the story (DSL v0.8, spec-0025).
5369    pub fn happening(&self) -> Option<&Happening> {
5370        match self {
5371            Objective::TalkTo { happening, .. }
5372            | Objective::ReachAnchor { happening, .. }
5373            | Objective::Kill { happening, .. }
5374            | Objective::Collect { happening, .. }
5375            | Objective::Interact { happening, .. } => happening.as_ref(),
5376        }
5377    }
5378
5379    /// The bot stealth hint (DSL v0.4): traverse this leg sneaking.
5380    pub fn stealth(&self) -> bool {
5381        match self {
5382            Objective::TalkTo { stealth, .. }
5383            | Objective::ReachAnchor { stealth, .. }
5384            | Objective::Kill { stealth, .. }
5385            | Objective::Collect { stealth, .. }
5386            | Objective::Interact { stealth, .. } => *stealth,
5387        }
5388    }
5389
5390    /// The container this objective ADOPTS (DSL v0.8), if it is a `collect` that
5391    /// declares one: the anchor whose prefab-placed chest/barrel it fills instead
5392    /// of conjuring its own chest. `None` on every other objective and on a
5393    /// `collect` that keeps the compiler-placed chest.
5394    pub fn collect_container(&self) -> Option<&AnchorId> {
5395        match self {
5396            Objective::Collect { container, .. } => container.as_ref(),
5397            _ => None,
5398        }
5399    }
5400
5401    /// The wave whose declared drops provide this objective's item (DSL v0.9),
5402    /// if it is a `collect` that declares one. `None` on every other objective
5403    /// and on a `collect` fed by a container.
5404    pub fn collect_dropped_by(&self) -> Option<&WaveId> {
5405        match self {
5406            Objective::Collect { dropped_by, .. } => dropped_by.as_ref(),
5407            _ => None,
5408        }
5409    }
5410
5411    /// The padding-stack count of a `collect` (DSL v0.8); `0` for every other
5412    /// objective and for a `collect` that fills the single required stack only.
5413    pub fn collect_fill_count(&self) -> u32 {
5414        match self {
5415            Objective::Collect { fill_count, .. } => *fill_count,
5416            _ => 0,
5417        }
5418    }
5419
5420    /// The `interact` prop block (DSL v0.4), if this is an `interact` with a prop.
5421    pub fn prop(&self) -> Option<&Prop> {
5422        match self {
5423            Objective::Interact { prop, .. } => prop.as_ref(),
5424            _ => None,
5425        }
5426    }
5427
5428    /// The kebab type tag.
5429    pub fn kind(&self) -> &'static str {
5430        match self {
5431            Objective::TalkTo { .. } => "talk-to",
5432            Objective::ReachAnchor { .. } => "reach-anchor",
5433            Objective::Kill { .. } => "kill",
5434            Objective::Collect { .. } => "collect",
5435            Objective::Interact { .. } => "interact",
5436        }
5437    }
5438
5439    /// The v0.3 verb name if this objective is one of the verbs introduced in
5440    /// DSL v0.3 (`kill`/`collect`/`interact`). These validate in v0.3 campaigns
5441    ///.
5442    pub fn v03_verb(&self) -> Option<&'static str> {
5443        match self {
5444            Objective::Kill { .. } => Some("kill"),
5445            Objective::Collect { .. } => Some("collect"),
5446            Objective::Interact { .. } => Some("interact"),
5447            Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => None,
5448        }
5449    }
5450}
5451
5452/// The condition under which an effect fires, as one object.
5453///
5454/// The three axes of [`crate::gate::Gate`] in their declared form: the flags that
5455/// must be set, the flags that must not be, and the numeric comparisons that must
5456/// hold. Declared once and carried by [`QuestEffect::when`], so every verb is
5457/// gatable on exactly the same terms and a fourth axis is one field here.
5458#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
5459#[serde(deny_unknown_fields)]
5460pub struct Guard {
5461    /// Flags that must ALL be set (per party) for the effect to fire. Emission
5462    /// wraps the effect's commands in `execute if score #party dw.f_<flag> matches 1`.
5463    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5464    pub requires_flags: Vec<FlagId>,
5465    /// Flags whose being set SUPPRESSES the effect — the dual of `requires_flags`,
5466    /// emitted as `execute unless score #party dw.f_<flag> matches 1`.
5467    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5468    pub forbids_flags: Vec<FlagId>,
5469    /// Numeric comparisons that must ALL hold (spec-0031), emitted as
5470    /// `execute if score <holder> dw.s_<state> matches <range>`.
5471    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5472    pub requires_state: Vec<StateCompare>,
5473}
5474
5475/// An effect fired by quest progress: **one guard, one story note, one verb.**
5476///
5477/// The guard is a property of an effect, not of the verb that first wanted one, so
5478/// it is declared once in [`Guard`] and every verb carries it — including the
5479/// staging and souls vocabulary (`spawn-actor`, `move-actor`, `set-checkpoint`,
5480/// `bonfire`, `begin-stealth`, `sequence`, …) that could not be branch-gated while
5481/// the fields lived on the variants. A gate that can never open on a
5482/// `campaign-complete` is caught where it belongs, by the completability proof.
5483///
5484/// `verb` is `#[serde(flatten)]`, so the JSON is unchanged in shape apart from the
5485/// guard moving under `when`: `{"type": "open-gate", "anchor": "…", "when":
5486/// {"requires_flags": ["…"]}}`. [`Verb`] keeps `deny_unknown_fields`, which is what
5487/// the flattened deserializer applies to everything the outer struct did not claim
5488/// — an author's typo is still `DW0100`.
5489#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5490pub struct QuestEffect {
5491    /// When this effect fires. `None` is the always-open gate.
5492    #[serde(default, skip_serializing_if = "Option::is_none")]
5493    pub when: Option<Guard>,
5494    /// What this beat does to the story (spec-0025) — validation metadata with no
5495    /// emission of its own.
5496    #[serde(default, skip_serializing_if = "Option::is_none")]
5497    pub happening: Option<Happening>,
5498    /// **Who a player-facing effect addresses** (spec-0085 §3.2): `party` or
5499    /// `actor`, the one player whose act fired the root. Absent = the root's own
5500    /// answer — a quest completion addresses the party, a death, a respawn, a
5501    /// purchase, a credited kill and a `presser` trigger address their actor, a
5502    /// polled trigger, a trap and a shortcut address the party — so a campaign
5503    /// that never writes the field emits exactly what it emitted before it
5504    /// existed.
5505    ///
5506    /// A property of the envelope rather than of any verb: it reaches every
5507    /// verb the emitter addresses to players ([`Verb::addresses_players`]), and
5508    /// on any other verb it is refused (`DW0942`). `actor` where emission has no
5509    /// acting player is `DW0503`.
5510    #[serde(default, skip_serializing_if = "Option::is_none")]
5511    pub audience: Option<EffectAudience>,
5512    /// **Narrows the audience to players inside an anchor-centred box**
5513    /// (`anchor ± extent`, spec-0085 §3.2) at the moment the effect fires — the
5514    /// same [`StealthZone`] a `begin-stealth` zone and a `lethal_volumes[]`
5515    /// region take, resolved through the one `Plan::zone_box`. Composes with
5516    /// [`Self::audience`]: `actor` + `in` is the actor, if they stand in the box.
5517    ///
5518    /// One field on the envelope, reaching every player-facing verb; refused on
5519    /// any other (`DW0942`) — a box narrows an audience, and a world fact has
5520    /// none.
5521    #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5522    pub within: Option<StealthZone>,
5523    /// What the effect does.
5524    #[serde(flatten)]
5525    pub verb: Verb,
5526}
5527
5528/// **Who a player-facing effect addresses** (spec-0085 §3.2).
5529#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5530#[serde(rename_all = "kebab-case")]
5531pub enum EffectAudience {
5532    /// Every player — `@a`.
5533    Party,
5534    /// The one player whose act fired the root — the completing player, the
5535    /// presser, the dying or respawning player, the buyer, the credited killer.
5536    /// Emitted `@s`, so it exists only where emission has an acting player
5537    /// (`DW0503`).
5538    Actor,
5539}
5540
5541impl EffectAudience {
5542    /// The token the document spells.
5543    pub fn token(self) -> &'static str {
5544        match self {
5545            EffectAudience::Party => "party",
5546            EffectAudience::Actor => "actor",
5547        }
5548    }
5549}
5550
5551impl From<Verb> for QuestEffect {
5552    /// An unguarded effect with no story note and the root's own audience — the
5553    /// shape a compiler-synthesized beat and most tests want.
5554    fn from(verb: Verb) -> Self {
5555        QuestEffect {
5556            when: None,
5557            happening: None,
5558            audience: None,
5559            within: None,
5560            verb,
5561        }
5562    }
5563}
5564
5565/// What an effect does, without the guard: the closed set of verbs.
5566#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5567#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
5568pub enum Verb {
5569    /// Opens a prefab-declared gate (one-way).
5570    OpenGate {
5571        /// The gate anchor to open.
5572        anchor: AnchorId,
5573    },
5574    /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
5575    /// fills the gate anchor's region with the block the anchor declares (e.g. the
5576    /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
5577    /// The declared fill block is prefab metadata; a gate anchor with no `block` is
5578    /// rejected (`DW0343`). The completability model treats the region as **solid**
5579    /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
5580    /// clearing is modelled) — a critical path that must cross a gate after it seals
5581    /// fails the DW0311 reachability proof.
5582    CloseGate {
5583        /// The gate anchor to seal.
5584        anchor: AnchorId,
5585        /// 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
5586        /// and press: the compiler answers that press on the actionbar. Absent, the
5587        /// compiler's canonical English is baked in (`The way is sealed.`) exactly
5588        /// as `world.boundary.message` does; authored, the line is l10n-inventoried
5589        /// under `<effect-key>.sealed_hint` and translates like every other
5590        /// player-visible string.
5591        ///
5592        /// Unlike `happening`, this **does** print in the hand-written `Debug`
5593        /// below when present — it changes emission, so two otherwise-identical
5594        /// sequences that differ only in their seal's answer are different content.
5595        #[serde(default, skip_serializing_if = "Option::is_none")]
5596        sealed_hint: Option<String>,
5597    },
5598    /// Marks the campaign complete (final advancement + credits). Terminal — not
5599    /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
5600    /// so this variant carries no `requires_flags`.
5601    CampaignComplete {
5602        /// Which ENDING this is (DSL v0.8, spec-0025).
5603        /// A campaign with more than one `campaign-complete` has more than one
5604        /// ending, and a branch that runs to an ending names it here — so the
5605        /// terminality proof (`DW0482`) can state *which* ending a branch reached
5606        /// instead of merely that something ended. There is no separate `endings`
5607        /// section: the set of endings is exactly the set named here, the same
5608        /// rule flags follow.
5609        #[serde(default, skip_serializing_if = "Option::is_none")]
5610        ending: Option<EndingId>,
5611    },
5612    /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
5613    GiveItem {
5614        /// Vanilla item id to give (validated against the registry).
5615        item: String,
5616        /// How many to give.
5617        count: u32,
5618        /// Optional display name (DSL v0.4), matching [`KitItem::name`].
5619        #[serde(default, skip_serializing_if = "Option::is_none")]
5620        name: Option<String>,
5621        /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
5622        /// party member. `one` hands a single copy to the player whose action fired
5623        /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
5624        /// has no acting player.
5625        #[serde(default, skip_serializing_if = "Option::is_none")]
5626        carrier: Option<Carrier>,
5627        /// Enchantments on the given stack (`{"minecraft:sharpness": 2}`) —
5628        /// the field a `loot` stack and an equipped piece carry, under the same
5629        /// checks (`DW0433`/`DW0434`) and written by the same rule
5630        /// ([`enchantment_component`]): on a `minecraft:enchanted_book` they are
5631        /// the book's stored enchantments, the ones an anvil applies.
5632        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
5633        enchantments: BTreeMap<String, u32>,
5634    },
5635    /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
5636    SetFlag {
5637        /// The flag to set.
5638        flag: FlagId,
5639    },
5640    /// Writes a declared datum to an absolute value (DSL v0.10, spec-0031).
5641    SetState {
5642        /// The datum to write (stage-5 `state` ref).
5643        state: StateId,
5644        /// The value to write.
5645        value: i32,
5646    },
5647    /// Moves a declared datum by a signed amount (DSL v0.10, spec-0031).
5648    ///
5649    /// **Signed on purpose.** A purse that a shop debits and a stake that a death
5650    /// forfeits are the same operation with the sign flipped; a separate
5651    /// `subtract-state` would be a second verb for one mechanism, and the first
5652    /// campaign to need "add a negative" would have to choose between them.
5653    AddState {
5654        /// The datum to move (stage-5 `state` ref).
5655        state: StateId,
5656        /// How far to move it. Negative counts down.
5657        amount: i32,
5658    },
5659    /// Leaves a declared [`Stake`] behind for the acting player (DSL v0.10,
5660    /// spec-0032): forfeit the declared share of its datum, and place a
5661    /// collectable marker at the compile-time anchor for where they are.
5662    ///
5663    /// **Nothing about this verb says "death".** It is written in `on_death`
5664    /// because that is where a souls-shaped delve wants it, but the mechanism is
5665    /// "leave a recoverable cache where the acting player stands", and the effect
5666    /// carries the ordinary gate so any root may run it. What *is* death-specific
5667    /// — that the corpse stands on the death position, so the placement lookup has
5668    /// a position to key on — is a property of the `on_death` root, not of this
5669    /// verb.
5670    DropStake {
5671        /// The stake (stage-5 `stakes` ref) to leave.
5672        stake: StakeId,
5673    },
5674    /// Returns a declared datum to its declared `initial` (DSL v0.10,
5675    /// spec-0031) — the verb `FlagId` has never had.
5676    ClearState {
5677        /// The datum to clear (stage-5 `state` ref).
5678        state: StateId,
5679    },
5680    /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
5681    SpawnWave {
5682        /// The wave (stage-5 `waves` ref) to spawn.
5683        wave: WaveId,
5684    },
5685    /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
5686    /// l10n key inventory like any player-visible string.
5687    Narrate {
5688        /// The line shown to the player.
5689        text: String,
5690        /// Presentation channel (default `chat`).
5691        #[serde(default, skip_serializing_if = "Option::is_none")]
5692        style: Option<NarrateStyle>,
5693        /// Optional sound id played alongside the line.
5694        #[serde(default, skip_serializing_if = "Option::is_none")]
5695        sound: Option<String>,
5696    },
5697    /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
5698    /// placement. Block id validated against the pinned 1.21.11 block registry;
5699    /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
5700    /// accepted and passed through verbatim (DSL v0.6).
5701    SetBlock {
5702        /// The anchor to place the block at.
5703        anchor: AnchorId,
5704        /// Vanilla block id to place.
5705        block: String,
5706    },
5707    /// **Fill a declared region with a block** at runtime (DSL v0.10, spec-0031).
5708    ///
5709    /// The general spelling of the capability `open-gate` / `close-gate` carried
5710    /// privately: a region, filled or cleared, from a point in the quest DAG.
5711    /// `close-gate` is this verb with the region and the block read off a prefab
5712    /// gate anchor instead of authored; `set-block` is the one-cell case at a
5713    /// point anchor. All three lower through one emission
5714    /// (`emit::fill_region_command`) and are modelled by one completability rule
5715    /// (`plan::RegionEvent`), so a third consumer inherits the proof instead of
5716    /// re-deriving it.
5717    ///
5718    /// From the DAG point at which this fires, the completability model treats the
5719    /// filled cells as whatever the **block** makes them. A full-cube block leaves
5720    /// them **solid**, exactly as a `close-gate` seal is: a critical path that must
5721    /// cross the region afterwards fails `DW0311`. `minecraft:water` /
5722    /// `minecraft:lava` leave them **flooded** — impassable and never floor, because
5723    /// nothing stands on a fluid — and because a fill carries no `replace` filter it
5724    /// takes away whatever floor was in the box, so a forced leg that needed that
5725    /// footing fails `DW0544`.
5726    FillRegion {
5727        /// The volume to fill, as an anchor-centred box (`anchor ± extent`).
5728        ///
5729        /// Deliberately the existing [`StealthZone`] — the engine's one
5730        /// anchor-centred box object class, already shared by `damage-players`'s
5731        /// `in` filter, `collapse`'s `region_anchor`, a `volley` kill zone and a
5732        /// `lethal_volumes[]` region, and resolved through the single
5733        /// `Plan::zone_box`. A private twin with the same two fields would be
5734        /// `tools/ci/check-capability-ownership.py` check C by construction.
5735        ///
5736        /// An anchor-centred box rather than a prefab `region` anchor for the
5737        /// reason `collapse` states: the assembled model deletes every gate-region
5738        /// anchor's cells, so a slab declared that way would already be gone.
5739        region: StealthZone,
5740        /// The block the region is filled with (validated against the pinned
5741        /// 1.21.11 block registry, `DW0193`).
5742        block: String,
5743    },
5744    /// **Clear a declared region to air** at runtime (DSL v0.10, spec-0031) — the
5745    /// physical dual of [`Verb::FillRegion`], and the general spelling of
5746    /// what `open-gate` does to a gate anchor's region.
5747    ///
5748    /// The completability model treats the cleared cells as **passable** from the
5749    /// DAG point at which this fires, with one exception it states out loud: a
5750    /// cleared cell the model already floods stays impassable, because clearing a
5751    /// block does not remove water (`nav::World::with_cleared`).
5752    ClearRegion {
5753        /// The volume to clear, as an anchor-centred box (`anchor ± extent`) —
5754        /// the same object class [`Verb::FillRegion`] fills.
5755        region: StealthZone,
5756    },
5757    /// **Repaint a volume's sky** (spec-0080 §3.3): `/fillbiome` over the
5758    /// volume with the named atmosphere's biome, while the party stands in it.
5759    ///
5760    /// A runtime edit of the world keyed to a volume, so it is a verb of the
5761    /// physical-edit family [`Verb::FillRegion`] / [`Verb::ClearRegion`] form.
5762    /// Exactly one of `region` / `place` (`DW0929`): a volume inside a place is
5763    /// the creator's judgement, a whole place's bounds are a derivation the
5764    /// creator never types. Painting back is this verb naming the place's own
5765    /// atmosphere, or `atmosphere: null` for the horizon's biome.
5766    ///
5767    /// A hard cut: the client blends fog over its biome-blend radius and grass
5768    /// not at all, and biome cells are 4×4×4, so the painted volume is the
5769    /// enclosing 4-aligned box, up to three blocks past each face.
5770    SetAtmosphere {
5771        /// One of `world.atmospheres[]`, or `null` for the horizon's biome.
5772        #[serde(default)]
5773        atmosphere: Option<AtmosphereId>,
5774        /// The volume, as an anchor-centred box (`anchor ± extent`) — the same
5775        /// object class [`Verb::FillRegion`] fills, resolved through the same
5776        /// `Plan::zone_box`.
5777        #[serde(default, skip_serializing_if = "Option::is_none")]
5778        region: Option<StealthZone>,
5779        /// A whole place: an `area/…` id, or a site-plan box's `node/…`. Its
5780        /// volume is the cells the place's own `atmosphere` paints at setup:
5781        /// its bounds, grown as far as the client's biome blend reads.
5782        #[serde(default, skip_serializing_if = "Option::is_none")]
5783        place: Option<String>,
5784    },
5785    /// **Opens a placed piece's contingent way** (DSL v0.12, spec-0042 §2.4): the
5786    /// broken flight a beat repairs, the bridge a beat lowers, the rubble a beat
5787    /// clears.
5788    ///
5789    /// A piece's spatial contract may declare a traversal edge whose crossability
5790    /// depends on a named region — `laid` (empty as built, opening fills it) or
5791    /// `cleared` (built solid, opening voids it). The prefab checker proves the
5792    /// piece is severed as shipped and joined once that region is opened; this is
5793    /// the verb that opens it, and it is the only one, because a way is the object
5794    /// and opening it is the operation.
5795    ///
5796    /// **There is no region, no block and no sign on this effect, and that is the
5797    /// design rather than an omission.** All three are read from the piece's own
5798    /// exported metadata (`spatial_contract.edges[].way`), so the effect and the
5799    /// building cannot disagree about what a way is — two authorities plus an
5800    /// equality check is the defect this shape avoids, not a variant of the fix
5801    /// (spec-0042 AC8). What the campaign decides is *when*.
5802    ///
5803    /// Completability: the way is **shut until this fires**, and from the DAG
5804    /// point at which it fires the region is solid-and-footing (`laid`) or
5805    /// passable (`cleared`) — the same [`Verb::FillRegion`] /
5806    /// [`Verb::ClearRegion`] model, fed from metadata instead of from an
5807    /// authored box, so this verb inherits the forced-footing rule (`DW0546`)
5808    /// rather than restating it. Required content standing beyond a way that no
5809    /// forced opening precedes is `DW0548`, which names the way, the effect and
5810    /// the element.
5811    OpenWay {
5812        /// The placed piece whose way this opens (`prefab/<name>`).
5813        ///
5814        /// A piece, not an anchor: the way's cells are the contract's, not a gate
5815        /// anchor's, and a piece placed twice has two ways. The reference must
5816        /// name exactly one placement; naming none or several is `DW0547`.
5817        piece: PrefabId,
5818        /// The way's region name, as the piece's contract exports it
5819        /// (`spatial_contract.edges[].way.region`).
5820        way: String,
5821    },
5822    /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5). The
5823    /// NPC leaves unseen: no death animation, red flash or death particles.
5824    DespawnNpc {
5825        /// The NPC (stage-2 ref) to remove.
5826        npc: NpcId,
5827    },
5828    /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
5829    /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
5830    /// path** by A* over the solved voxel grid and emits per-tick teleport
5831    /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
5832    /// a solid affordance. An unroutable move is a compile error (`DW0307`).
5833    MoveNpc {
5834        /// The NPC (stage-2 ref) to move.
5835        npc: NpcId,
5836        /// The destination mark: an anchor and an optional offset (spec-0066).
5837        to: Mark,
5838        /// Optional travel speed in blocks/tick (defaults to ~0.15).
5839        #[serde(default, skip_serializing_if = "Option::is_none")]
5840        speed: Option<f64>,
5841        /// Effects fired once the NPC arrives at the destination cell — exact
5842        /// parity with [`Verb::MoveActor`]
5843        /// `on_arrive`: same arrival detection (the walk driver's final tick), same
5844        /// execution context, and every deep effect walker recurses into it via
5845        /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
5846        /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
5847        /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
5848        /// mark).
5849        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5850        on_arrive: Vec<QuestEffect>,
5851    },
5852    /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
5853    /// gamemode+position, spectator, then dolly two co-located cameras along a
5854    /// straight-line lerp between waypoints and alternate `spectate` between them
5855    /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
5856    /// no-op and is never emitted), and restore on completion. The compiler
5857    /// validates the dolly path passes only through non-solid blocks — cameras
5858    /// fly but must not clip a solid (`DW0308`).
5859    ///
5860    /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
5861    /// emission to face that world point from its own position, so the shot keeps
5862    /// its subject framed for the whole move; without it, the camera faces along
5863    /// the direction of travel (the segment it is currently traversing).
5864    ///
5865    /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
5866    /// inside one save/restore bracket (hard cut between shots). Two accepted,
5867    /// mutually exclusive spellings, both normalized by
5868    /// [`QuestEffect::cutscene_shots`]:
5869    /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
5870    /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
5871    ///   exactly equivalent to a one-entry `shots` list.
5872    ///
5873    /// Mixing or omitting both is `DW0199`.
5874    Cutscene {
5875        /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
5876        /// with the single-shot `path`/`seconds` fields (`DW0199`).
5877        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5878        shots: Vec<CameraShot>,
5879        /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
5880        /// lerp between them).
5881        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5882        path: Vec<Mark>,
5883        /// Single-shot form (DSL v0.4): shot duration in seconds.
5884        #[serde(default, skip_serializing_if = "Option::is_none")]
5885        seconds: Option<u32>,
5886        /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
5887        /// Absent = face along the direction of travel.
5888        #[serde(default, skip_serializing_if = "Option::is_none")]
5889        look_at: Option<Mark>,
5890        /// Whether the party's bodies stay in the scene while the camera flies
5891        /// (spec-0095). Absent = `present`: every player in play is shown by a
5892        /// stand-in wearing their own skin and equipment, standing where they
5893        /// stood, for the cutscene's whole length. `absent` takes the bodies out
5894        /// of the scene: a vision, a memory, a scene somewhere else.
5895        #[serde(default, skip_serializing_if = "Option::is_none")]
5896        party: Option<CutsceneParty>,
5897    },
5898    /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
5899    /// Instantaneous (vanilla has no gradual transition); the state persists
5900    /// because the daylight cycle is frozen by sealing.
5901    SetTime {
5902        /// The time state to cut to.
5903        time: WorldTime,
5904    },
5905    /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
5906    /// Instantaneous; persists because the weather cycle is frozen by sealing.
5907    SetWeather {
5908        /// The weather state to cut to.
5909        weather: WorldWeather,
5910    },
5911    /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
5912    /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
5913    /// sound-event registry (`DW0326` unknown). `at` selects where the sound
5914    /// originates (default: each player's own position); `volume`/`pitch` map to
5915    /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
5916    /// vanilla).
5917    PlaySound {
5918        /// The vanilla sound-event id (`minecraft:` prefix optional).
5919        sound: String,
5920        /// Where the sound plays from (default: `players`).
5921        #[serde(default, skip_serializing_if = "Option::is_none")]
5922        at: Option<SoundAt>,
5923        /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
5924        #[serde(default, skip_serializing_if = "Option::is_none")]
5925        volume: Option<f64>,
5926        /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
5927        #[serde(default, skip_serializing_if = "Option::is_none")]
5928        pitch: Option<f64>,
5929    },
5930    /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
5931    /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
5932    /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
5933    /// acting player: at top level it damages every player once; inside a stealth
5934    /// `on_caught` it damages the caught player (the "caught → death → respawn at
5935    /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
5936    /// is lethal through golden apples / absorption. The envelope's `in`
5937    /// ([`QuestEffect::within`]) narrows to acting players standing inside an
5938    /// anchor-centred box, keeping the per-`@s` semantics. `damage_type` is the damage
5939    /// type — a curated set of vanilla types that all respect `keepInventory` and do
5940    /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
5941    /// (The field is `damage_type`, not `type`, because the effect enum is
5942    /// internally tagged on `type`.)
5943    DamagePlayers {
5944        /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
5945        amount: u32,
5946        /// The damage type (default [`DamageKind::Generic`]).
5947        #[serde(default, skip_serializing_if = "Option::is_none")]
5948        damage_type: Option<DamageKind>,
5949    },
5950    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
5951    /// `spawnpoint @a` at the anchor cell and mirrors the coords into
5952    /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
5953    /// `set-checkpoint` always replaces an earlier one). The compiler proves the
5954    /// cell is standable (`DW0316`) and that the remaining critical path stays
5955    /// reachable from it (`DW0315`).
5956    SetCheckpoint {
5957        /// The prefab checkpoint anchor the party respawns at.
5958        anchor: AnchorId,
5959        /// Per-player effects re-run each time a player respawns while this
5960        /// checkpoint is the active one — scene reset (e.g. re-caging an
5961        /// unleashed actor). Emitted idempotently in declared order; empty = no
5962        /// hook. Respawn is detected via the vanilla `deathCount` criterion.
5963        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5964        on_respawn: Vec<QuestEffect>,
5965    },
5966    /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
5967    /// [`Verb::SetCheckpoint`] for souls-mode pacing. The effect *arms*
5968    /// the rest affordance (a `minecraft:interaction` the player right-clicks at
5969    /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
5970    /// only **when the party actually rests**. Resting fires `on_rest` — the
5971    /// scene reset that makes retry cheap: re-arming traps, re-seating waves
5972    /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
5973    /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
5974    /// so the world's answer to a death and to a rest is identical (spec-0016:
5975    /// death is an investment, never a tax).
5976    ///
5977    /// Proofs are inherited from the checkpoint machinery: the anchor must be
5978    /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
5979    /// the beat that arms the bonfire (the earliest moment a rest can happen).
5980    Bonfire {
5981        /// The prefab anchor the rest affordance stands at, and the cell the
5982        /// party respawns at once rested.
5983        anchor: AnchorId,
5984        /// Effects re-run on every rest **and** on every respawn at this bonfire
5985        /// — the scene reset. Emitted in declared order and expected to be
5986        /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
5987        #[serde(default, skip_serializing_if = "Vec::is_empty")]
5988        on_rest: Vec<QuestEffect>,
5989        /// Title of the two-option rest dialog (DSL v0.8).
5990        /// Absent = the compiler's canonical English `Bonfire`,
5991        /// baked at emit time (the `world.boundary.message` precedent): an
5992        /// authored line is inventoried and translates like every other
5993        /// player-visible string.
5994        #[serde(default, skip_serializing_if = "Option::is_none")]
5995        prompt: Option<String>,
5996        /// Label of the **rest and save** button. Absent = `Rest and save`.
5997        /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
5998        /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
5999        #[serde(default, skip_serializing_if = "Option::is_none")]
6000        rest_label: Option<String>,
6001        /// Label of the **save only** button. Absent = `Save only`.
6002        #[serde(default, skip_serializing_if = "Option::is_none")]
6003        save_label: Option<String>,
6004        /// Hover tooltip of the **rest and save** button (spec-0078) — the same
6005        /// optional `tooltip` every dialog button carries, beside the label it
6006        /// explains. Absent = no tooltip. Not subject to `DW0331`: a tooltip
6007        /// wraps in its own hover box. Inventoried as `fx.….rest_tooltip`.
6008        #[serde(default, skip_serializing_if = "Option::is_none")]
6009        rest_tooltip: Option<String>,
6010        /// Hover tooltip of the **save only** button (spec-0078). Absent = no
6011        /// tooltip. Inventoried as `fx.….save_tooltip`.
6012        #[serde(default, skip_serializing_if = "Option::is_none")]
6013        save_tooltip: Option<String>,
6014    },
6015    /// Begins a stealth beat (DSL v0.6, spec-0014):
6016    /// zone presence alone = hidden — no sneak requirement, which collides with
6017    /// the spectator cutscene camera. While active, every player must be
6018    /// inside some `zone` each tick; a player outside every zone for
6019    /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
6020    /// Zone membership is read from the player's position. The compiler proves
6021    /// each zone is standable and reachable from the activating beat (`DW0327`).
6022    BeginStealth {
6023        /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
6024        zones: Vec<StealthZone>,
6025        /// Per-player effects fired when a player is caught (out of every zone
6026        /// for `grace_ticks`). Empty = no consequence.
6027        #[serde(default, skip_serializing_if = "Vec::is_empty")]
6028        on_caught: Vec<QuestEffect>,
6029        /// Ticks a player may be exposed before `on_caught` fires (default 20).
6030        #[serde(default = "default_grace_ticks")]
6031        grace_ticks: u32,
6032    },
6033    /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
6034    EndStealth,
6035    /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
6036    /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
6037    /// scripted entrance a staged character needs. Idempotent: spawning an NPC
6038    /// already in the world is a no-op. Only meaningful for an NPC declared
6039    /// `deferred: true`; a non-deferred NPC is already in the world from init.
6040    SpawnNpc {
6041        /// The NPC (stage-2 ref) to summon.
6042        npc: NpcId,
6043    },
6044    // --- DSL v0.6 actor staging effects (spec-0014) ---
6045    /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
6046    /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
6047    SpawnActor {
6048        /// The actor (stage-5 `actors` ref) to summon.
6049        actor: ActorId,
6050    },
6051    /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
6052    /// animation (cutscene deaths); `vanish` is silent removal.
6053    DespawnActor {
6054        /// The actor (stage-5 `actors` ref) to remove.
6055        actor: ActorId,
6056        /// How the puppet is removed.
6057        style: DespawnStyle,
6058    },
6059    /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
6060    /// the assembled model, using the actor's hitbox footprint, yawed along the
6061    /// path tangent (DSL v0.6). Concurrent movers are allowed (a herded
6062    /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
6063    /// effects fire once the puppet reaches the destination cell.
6064    MoveActor {
6065        /// The actor (stage-5 `actors` ref) to move.
6066        actor: ActorId,
6067        /// The destination mark: an anchor and an optional offset (spec-0066).
6068        to: Mark,
6069        /// Optional travel speed in blocks/tick (defaults to ~0.15).
6070        #[serde(default, skip_serializing_if = "Option::is_none")]
6071        speed: Option<f64>,
6072        /// Effects fired once the puppet arrives at the destination cell.
6073        #[serde(default, skip_serializing_if = "Vec::is_empty")]
6074        on_arrive: Vec<QuestEffect>,
6075    },
6076    /// Replaces an actor's puppet with a real-AI twin of the same type / position /
6077    /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
6078    /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
6079    /// special verb.
6080    UnleashActor {
6081        /// The actor (stage-5 `actors` ref) to unleash.
6082        actor: ActorId,
6083    },
6084    // --- spec-0082 assembly verbs ---
6085    /// Summons an assembly (spec-0082): its root, one display per rig part
6086    /// riding the root, and its hitbox when declared, at its mark, playing its
6087    /// `initial` clip. Idempotent: a spawn of a present assembly is a no-op.
6088    SpawnAssembly {
6089        /// The assembly (stage-5 `assemblies` ref) to summon.
6090        assembly: AssemblyId,
6091    },
6092    /// Removes an assembly's root, parts and hitbox (spec-0082). They leave
6093    /// unseen: a display entity has no death, so there is no `style`.
6094    DespawnAssembly {
6095        /// The assembly (stage-5 `assemblies` ref) to remove.
6096        assembly: AssemblyId,
6097    },
6098    /// Switches the clip an assembly plays (spec-0082); its first frame is
6099    /// applied on the next tick. While a strike step is in flight the switch
6100    /// waits for the step to end, so a story beat never cuts a strike at the
6101    /// frame before it lands.
6102    PlayClip {
6103        /// The assembly (stage-5 `assemblies` ref).
6104        assembly: AssemblyId,
6105        /// A clip the assembly's rig declares.
6106        clip: String,
6107    },
6108    /// Re-arms an assembly's strike pattern (spec-0094 §3.3). A `play-clip`
6109    /// stands the pattern down: the clip it plays completes and holds (or
6110    /// loops) whoever stands in `while_in`, and no wind-up begins again until
6111    /// this verb runs. The pattern resumes from its first step on the next tick
6112    /// some player is in its arming region. Naming an assembly that declares
6113    /// no `strikes` is `DW0970`.
6114    ArmStrikes {
6115        /// The assembly (stage-5 `assemblies` ref) whose pattern is re-armed.
6116        assembly: AssemblyId,
6117    },
6118    /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
6119    /// at exact tick offsets. Effects are any in the stage-5 set except a nested
6120    /// `sequence` (rejected with `DW0329`).
6121    Sequence {
6122        /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
6123        steps: Vec<SequenceStep>,
6124    },
6125    /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
6126    /// projectiles with real velocity vectors, fired from a gallery slot into a
6127    /// declared kill zone.
6128    ///
6129    /// The contract is **saturation, not sniping**.
6130    /// Every salvo puts one projectile on the trajectory to **every standable
6131    /// cell of `kill_zone`**, plus one aimed at the triggering player's
6132    /// fire-time position (which punishes standing still). A player therefore
6133    /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
6134    /// decision rather than a lucky step. Coverage is proven at compile time:
6135    /// `from_anchor` must have clear line of fire to every standable kill-zone
6136    /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
6137    /// on the stairs" is a build-time fact, not a hope.
6138    ///
6139    /// Projectiles are summoned `NoGravity` so the flown path is exactly the
6140    /// straight segment the coverage proof checks — proof and runtime share one
6141    /// geometry. Drag scales speed but not direction, so the line is preserved.
6142    Volley {
6143        /// Projectile entity id (default `minecraft:arrow`; validated against
6144        /// the pinned 1.21.11 registry, `DW0441`).
6145        #[serde(default, skip_serializing_if = "Option::is_none")]
6146        projectile: Option<String>,
6147        /// The gallery slot the volley is fired FROM — a point anchor. Its cell
6148        /// must be clear (a projectile spawned inside a wall never leaves it).
6149        from_anchor: AnchorId,
6150        /// The zone the volley must blanket, as an anchor-centred box
6151        /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
6152        /// `begin-stealth`'s `zones` use. Every standable cell in it receives
6153        /// fire each salvo.
6154        ///
6155        /// Deliberately NOT a bare prefab `region` anchor: the assembled model
6156        /// clears every gate-region anchor's cells unconditionally, so
6157        /// describing a zone that way would delete the geometry it names.
6158        kill_zone: StealthZone,
6159        /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
6160        #[serde(default, skip_serializing_if = "Option::is_none")]
6161        salvos: Option<u32>,
6162        /// Ticks between salvos (default 10, `DW0443` bounds it).
6163        #[serde(default, skip_serializing_if = "Option::is_none")]
6164        interval: Option<u32>,
6165    },
6166    /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
6167    /// drop them as `falling_block` entities — the buried-alive trap redstone
6168    /// cannot express at all.
6169    ///
6170    /// The post-collapse world is **modeled, not guessed**: the compiler clears
6171    /// the region, settles every dropped column through the existing gravity
6172    /// model (spec-0010), optionally paves the landing surface with
6173    /// `then_floor`, and re-runs the critical-path completability proof against
6174    /// that mutated world (`DW0445`). A collapse that buries the only route is a
6175    /// build error, exactly as a `shortcut` seal that strands the party is.
6176    Collapse {
6177        /// The volume whose blocks fall, as an anchor-centred box
6178        /// (`anchor ± extent`). Must hold blocks and sit above standable footing
6179        /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
6180        ///
6181        /// An anchor-centred box rather than a prefab `region` anchor for a
6182        /// load-bearing reason: the assembled model deletes every gate-region
6183        /// anchor's cells, so a ceiling slab declared that way would already be
6184        /// gone before the trap ever fired.
6185        region_anchor: StealthZone,
6186        /// The block the falling entities are made of (default
6187        /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
6188        #[serde(default, skip_serializing_if = "Option::is_none")]
6189        falling_block: Option<String>,
6190        /// Optional block the landing surface is paved with once the debris
6191        /// settles — the authored post-collapse floor the completability proof
6192        /// reasons over.
6193        #[serde(default, skip_serializing_if = "Option::is_none")]
6194        then_floor: Option<String>,
6195    },
6196    /// Grants a vanilla **status effect** for a stated duration (DSL v0.10,
6197    /// spec-0031). The engine has emitted status effects since v0.6 — the
6198    /// night-vision area mitigation is a self-rescheduling, region-scoped
6199    /// `effect give` — and exposed none, so an author who wanted blindness for a
6200    /// lift ride, slowness in deep water or regeneration at a shrine had no
6201    /// surface at all. This is that surface, over the whole pinned 1.21.11
6202    /// `mob_effect` registry (`DW0192` rejects an id outside it).
6203    ///
6204    /// **A grant carries a duration, and there is no way to spell one that does
6205    /// not.** Vanilla's `infinite` keyword is deliberately absent from this
6206    /// surface: an effect whose only removal is a later command is an effect the
6207    /// player keeps forever whenever that command does not run — a logout, a
6208    /// crash, a chain interrupted by a death. A duration expires on its own, with
6209    /// no cooperation from anything. `seconds` is therefore required and bounded
6210    /// (1..=[`MAX_EFFECT_SECONDS`], `DW0541`), and the *pattern* that reintroduces
6211    /// the same hazard — pairing a grant with a `clear-effect` that removes it
6212    /// while it is still live — is `DW0540`.
6213    ///
6214    /// The envelope's `in` ([`QuestEffect::within`]) narrows to players inside an
6215    /// anchor-centred box. It is what makes "blind whoever is riding the car"
6216    /// expressible without blinding the whole party. A blinding grant owes the
6217    /// blind-reach proof wherever it is written (`DW0943`, spec-0085 §6).
6218    GiveEffect {
6219        /// Vanilla status-effect id (e.g. `minecraft:blindness`), validated
6220        /// against the pinned 1.21.11 registry (`DW0192`).
6221        effect: String,
6222        /// Duration in seconds. Required, `1..=`[`MAX_EFFECT_SECONDS`]
6223        /// (`DW0541`) — see the variant docs for why there is no infinite form.
6224        seconds: u32,
6225        /// Amplifier (0 = level I), `0..=`[`MAX_POTION_AMPLIFIER`] (`DW0541`).
6226        /// Absent = 0.
6227        #[serde(default, skip_serializing_if = "Option::is_none")]
6228        amplifier: Option<u32>,
6229        /// Suppress the swirling particles (vanilla's `hideParticles`). Absent =
6230        /// `false`, vanilla's own default.
6231        #[serde(default, skip_serializing_if = "Option::is_none")]
6232        hide_particles: Option<bool>,
6233    },
6234    /// Removes a status effect (DSL v0.10, spec-0031) — vanilla's `effect clear`.
6235    ///
6236    /// This is **not** how a `give-effect` is supposed to end: a duration is. It
6237    /// exists for the effects the engine did not grant — a potion the player
6238    /// drank, a `wither` a mob applied, the whole set at a bonfire — which is why
6239    /// `effect` is optional (absent = clear everything, exactly as vanilla's
6240    /// `effect clear <targets>` does). Pairing it with a live grant of the same
6241    /// effect in the same bundle is `DW0540`.
6242    ClearEffect {
6243        /// The status-effect id to remove (`DW0192`). **Absent clears every
6244        /// effect**, matching `effect clear <targets>` with no id.
6245        #[serde(default, skip_serializing_if = "Option::is_none")]
6246        effect: Option<String>,
6247    },
6248    /// Teleports **everything inside a declared volume** to an anchor (DSL v0.10,
6249    /// spec-0031).
6250    ///
6251    /// **The selector is a region, never a block.** "Whoever is standing on this
6252    /// block" has three different answers for a player half a foot over the edge,
6253    /// a player mid-jump and a player sneaking on the lip; a volume has one, and
6254    /// it is the same one every tick. `from` is the anchor-centred
6255    /// [`StealthZone`] box the rest of the engine already uses.
6256    ///
6257    /// **The selection is total over bodies.** Emission is a single `tp
6258    /// @e[<box>,tag=!dw_fixture] <cell>` with no `type=`, no `limit=` and no
6259    /// `sort=` — every body in the volume moves, and
6260    /// `crates/delvec/tests/v10_teleport.rs` asserts that from the emitted
6261    /// selector rather than from anyone's memory. A machinery-**type** exemption
6262    /// of the kind a `lethal_volumes[]` entry must carry was considered and
6263    /// **rejected**: an NPC is a body plus a co-located `minecraft:interaction`
6264    /// hitbox, so exempting `minecraft:interaction` — as the lethal volume does —
6265    /// would teleport the speaker and leave its dialogue box behind. Everyone on
6266    /// the car travels, players and entities
6267    /// alike; totality over bodies is how that is true.
6268    ///
6269    /// The one narrowing is a **class the engine's own furniture declares about
6270    /// itself**: `dw_fixture` means *my position IS engine state*. A bonfire's
6271    /// hitbox, a shortcut lever and a recovery stake's marker are places, not
6272    /// passengers, and carrying one does not move a thing — it rewrites a fact
6273    /// (for a stake, the ledger holds the marker's coordinates, and the next tick
6274    /// retires a marker nobody has a wager at). Places whose cell is known at
6275    /// compile time are refused outright instead, because the author can move
6276    /// them (`DW0542`); places the runtime puts down are excluded by the selector,
6277    /// because nobody can (`DW0545`). Nothing an author writes carries either tag,
6278    /// and no campaign JSON can turn either off.
6279    ///
6280    /// **A teleport is not a rescue.** Accumulated fall distance carries across
6281    /// one unchanged and is charged in full at the destination — measured Δ
6282    /// exactly `0.0000` in 46/46 trials on the pinned 1.21.11, including
6283    /// teleports 143 and 157 blocks straight *up*, with landing damage
6284    /// `floor(fall_distance) − 3` (`docs/notes/death-and-teleport-spike.md` §3).
6285    /// A platform that arrives under a falling player past ~20 blocks of fall is
6286    /// the surface they die on. The compiler does not try to reset the counter:
6287    /// what *does* reset it was explicitly not measured, and inventing a
6288    /// mechanism from recall is the folklore this project forbids.
6289    Teleport {
6290        /// The volume whose contents are moved, as an anchor-centred box
6291        /// (`anchor ± extent`) — the same shape a `begin-stealth` zone, a
6292        /// `damage-players` `in` filter and a `lethal_volumes[]` region take.
6293        ///
6294        /// Deliberately NOT a bare prefab `region` anchor, for the reason
6295        /// [`Verb::Collapse`] records: the assembled model clears every
6296        /// gate-region anchor's cells, so a volume described that way would
6297        /// delete the geometry it names.
6298        from: StealthZone,
6299        /// The destination mark (spec-0066). Resolved to a literal cell at build
6300        /// time, so the emitted `tp` carries absolute coordinates and no runtime
6301        /// search.
6302        to: Mark,
6303    },
6304    /// Fires a **firework rocket** from a mark (DSL v0.29, spec-0068).
6305    ///
6306    /// One effect at a point, the member of the same class as [`Verb::PlaySound`]
6307    /// — a one-shot thing that happens where the campaign says, beside the sound
6308    /// that goes with it. A display of many rockets is a [`Verb::Sequence`] of
6309    /// these, not a verb with timing of its own.
6310    ///
6311    /// # The burst height is a stated number, not a roll
6312    ///
6313    /// The emitter writes the entity's `LifeTime`
6314    /// ([`crate::firework::lifetime_ticks`]) rather than leaving it to the game,
6315    /// which randomises it at launch: two runs of one datapack would otherwise
6316    /// burst at two heights and nothing could be proven about where the burst is.
6317    /// Fixed at the floor of the game's range, the burst stands
6318    /// [`crate::firework::burst_height`] blocks over the mark.
6319    ///
6320    /// # A burst hurts, so the compiler asks where it is
6321    ///
6322    /// A build refuses a rocket whose column to that height is roofed, and one
6323    /// whose burst lies within [`crate::firework::BLAST_RADIUS`] blocks of a
6324    /// place the campaign posts a body (`DW0899`). Players are **not** posted:
6325    /// a player standing level with a burst takes up to
6326    /// [`crate::firework::worst_damage_hp`] HP, under a full body's twenty, and
6327    /// that is a hazard a player can see coming.
6328    Firework {
6329        /// The mark the rocket is launched from — the cell's centre, at the
6330        /// mark's own plane.
6331        at: Mark,
6332        /// Flight duration, 1–3 (the three the game crafts). Absent =
6333        /// [`crate::firework::MIN_FLIGHT`].
6334        #[serde(default, skip_serializing_if = "Option::is_none")]
6335        #[schemars(range(min = 1, max = 3))]
6336        flight: Option<u8>,
6337        /// One to seven bursts, in the order the component carries them.
6338        #[schemars(length(min = 1, max = 7))]
6339        explosions: Vec<FireworkExplosion>,
6340    },
6341    /// Spawns **particles** (spec-0085 §4.3) — the next one-shot point effect
6342    /// after [`Verb::Firework`], at a mark or at each addressed player.
6343    ///
6344    /// `particle` is a vanilla particle type id validated against the pinned
6345    /// registry `crates/dsl/data/particles-1.21.11.json`; an unknown id, or one
6346    /// whose type **takes options** (`dust`, `block`, `item`, …), is `DW0941`.
6347    ///
6348    /// Emitted as one vanilla `particle` command, always in **`force`** mode,
6349    /// whose viewers are the effect's audience: a particle a creator writes is
6350    /// meant to be seen, and `force` is the mode the game sends 512 blocks out
6351    /// and draws even at the client's Minimal particle setting. A
6352    /// `minecraft:elder_guardian` at `players` is the full-screen face.
6353    Particle {
6354        /// The particle type id (`minecraft:` prefix optional).
6355        particle: String,
6356        /// Where the particles spawn: a [`Mark`], or `players` — at each
6357        /// addressed player's own position.
6358        at: ParticleAt,
6359        /// How many (vanilla `<count>`, default 1). Zero is vanilla's spelling of
6360        /// a different thing — one particle with a velocity — and is refused.
6361        #[serde(default, skip_serializing_if = "Option::is_none")]
6362        #[schemars(range(min = 1))]
6363        count: Option<u32>,
6364        /// Standard deviations `[x, y, z]` of the spawn spread, in blocks
6365        /// (vanilla `<delta>`, default `[0, 0, 0]`).
6366        #[serde(default, skip_serializing_if = "Option::is_none")]
6367        spread: Option<[f64; 3]>,
6368        /// Vanilla `<speed>` (default 0).
6369        #[serde(default, skip_serializing_if = "Option::is_none")]
6370        speed: Option<f64>,
6371    },
6372    /// Strikes a **lightning bolt** at a mark (DSL v0.36, spec-0092) — the
6373    /// one-shot point effect beside [`Verb::Firework`] and [`Verb::Particle`].
6374    ///
6375    /// A real `minecraft:lightning_bolt`: every client in range draws the bolt
6376    /// and the sky flash and hears the thunder. It stands at the mark's cell
6377    /// centre on the mark's plane, so it strikes the block under the mark. A
6378    /// storm of strikes is a [`Verb::Sequence`] of these.
6379    ///
6380    /// # A bolt hurts, so the compiler asks where it lands
6381    ///
6382    /// The bolt hits every living body within the reach
6383    /// [`crate::lightning::REACH_HORIZONTAL`] / [`crate::lightning::REACH_BELOW`]
6384    /// / [`crate::lightning::REACH_ABOVE`] states, and turns a villager into a
6385    /// witch; a build refuses a strike in reach of a place the campaign posts a
6386    /// body (`DW0958`), and one whose struck block the game would rewrite — a
6387    /// lightning rod or weathering copper (`DW0959`). Players are **not**
6388    /// posted: a player in reach takes at most
6389    /// [`crate::lightning::worst_damage_hp`] HP. It lights no fire: every delve
6390    /// seals `fire_spread_radius_around_player` at 0 (spec-0092 §2.3).
6391    Lightning {
6392        /// The mark the bolt strikes — the cell's centre, at the mark's plane.
6393        at: Mark,
6394    },
6395}
6396
6397/// Where a [`Verb::Particle`] spawns (spec-0085 §4.3): a mark, or the literal
6398/// `players`.
6399#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6400#[serde(untagged)]
6401pub enum ParticleAt {
6402    /// `"players"` — at each addressed player's own position.
6403    Players(PlayersKeyword),
6404    /// A mark — the cell's centre at the mark's plane.
6405    Mark(Mark),
6406}
6407
6408/// The literal `players`, the one keyword [`ParticleAt`] admits.
6409#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6410#[serde(rename_all = "kebab-case")]
6411pub enum PlayersKeyword {
6412    /// At each addressed player.
6413    Players,
6414}
6415
6416/// Default `grace_ticks` for [`Verb::BeginStealth`] (spec-0014).
6417fn default_grace_ticks() -> u32 {
6418    20
6419}
6420
6421/// Default projectile for [`Verb::Volley`] (spec-0022).
6422pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
6423/// Default salvo count for [`Verb::Volley`] (spec-0022).
6424pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
6425/// Default ticks between salvos for [`Verb::Volley`] (spec-0022).
6426pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
6427/// Largest admissible `salvos` — beyond this a volley is an entity-count
6428/// hazard rather than a trap (`DW0443`).
6429pub const MAX_VOLLEY_SALVOS: u32 = 16;
6430/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
6431/// slower than this is no longer one event the player reads as a trap.
6432pub const MAX_VOLLEY_INTERVAL: u32 = 200;
6433/// Default falling block for [`Verb::Collapse`] (spec-0022).
6434pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
6435
6436/// A stealth "shadow" region (DSL v0.6, spec-0014): an axis-aligned box centred
6437/// on `anchor`, extending `extent` blocks along each axis (so the box spans
6438/// `anchor ± extent`). Presented in-world via dark cells but judged purely by
6439/// region membership, so the check is deterministic and provable.
6440#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6441#[serde(deny_unknown_fields)]
6442pub struct StealthZone {
6443    /// The prefab anchor at the centre of the zone box.
6444    pub anchor: AnchorId,
6445    /// Half-extents `[x, y, z]` in blocks from the anchor (each component ≥ 0);
6446    /// the zone AABB is `[anchor - extent, anchor + extent]`.
6447    pub extent: [u32; 3],
6448}
6449
6450/// Where a [`Verb::PlaySound`] originates (DSL v0.6, spec-0014). A sound
6451/// plays at fixed coordinates or at each listener's own position; the compiler
6452/// resolves no position for a live actor, so the `actor` variant is accepted by
6453/// the schema and rejected with `DW0335`.
6454#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6455#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
6456pub enum SoundAt {
6457    /// Play the sound positioned at a resolved anchor, audible to all players.
6458    Anchor {
6459        /// The anchor the sound plays from.
6460        anchor: AnchorId,
6461        /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
6462        /// `[0, 0, 0]`): the sound plays at the [`Mark`] the two fields spell.
6463        #[serde(default, skip_serializing_if = "is_zero3")]
6464        offset: [i32; 3],
6465    },
6466    /// Play the sound at each player's own position (the default), or at
6467    /// `offset` in **the listener's own frame** (spec-0085 §4.4): `+x` to the
6468    /// listener's left, `+y` up, `+z` the way the listener faces, with the pitch
6469    /// flattened so *behind* stays at ear height. `[0, 0, -3]` is three blocks
6470    /// behind. Integer blocks, as a [`Mark`]'s offset is.
6471    Players {
6472        /// Integer `[x, y, z]` offset in the listener's local frame (default
6473        /// `[0, 0, 0]`).
6474        #[serde(default, skip_serializing_if = "is_zero3")]
6475        offset: [i32; 3],
6476    },
6477    /// Play the sound at a scripted actor's position (rejected — `DW0335`; no
6478    /// actor position resolves at emission).
6479    Actor {
6480        /// The actor id (stage-5 `actors[]`).
6481        actor: String,
6482    },
6483}
6484
6485/// The damage type of a [`Verb::DamagePlayers`] effect (DSL v0.6). A
6486/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
6487/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
6488/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
6489/// `generic_kill` types are deliberately excluded, so a scripted consequence can
6490/// never silently void a player's held totem. Modelled as an enum (not a free
6491/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
6492/// registry / diagnostic. `generic` is the default: command damage that respects
6493/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
6494#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6495#[serde(rename_all = "kebab-case")]
6496pub enum DamageKind {
6497    /// `minecraft:generic` — armor-ignoring command damage (default).
6498    Generic,
6499    /// `minecraft:magic` — magical damage.
6500    Magic,
6501    /// `minecraft:wither` — the wither/withering effect's damage type.
6502    Wither,
6503    /// `minecraft:on_fire` — burning damage.
6504    Fire,
6505    /// `minecraft:drown` — drowning damage.
6506    Drown,
6507    /// `minecraft:freeze` — powder-snow freezing damage.
6508    Freeze,
6509    /// `minecraft:fall` — fall damage.
6510    Fall,
6511    /// `minecraft:lightning_bolt` — a lightning strike's damage type.
6512    LightningBolt,
6513    /// `minecraft:explosion` — a (non-player) explosion's damage type.
6514    Explosion,
6515}
6516
6517impl DamageKind {
6518    /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
6519    pub fn id(self) -> &'static str {
6520        match self {
6521            DamageKind::Generic => "minecraft:generic",
6522            DamageKind::Magic => "minecraft:magic",
6523            DamageKind::Wither => "minecraft:wither",
6524            DamageKind::Fire => "minecraft:on_fire",
6525            DamageKind::Drown => "minecraft:drown",
6526            DamageKind::Freeze => "minecraft:freeze",
6527            DamageKind::Fall => "minecraft:fall",
6528            DamageKind::LightningBolt => "minecraft:lightning_bolt",
6529            DamageKind::Explosion => "minecraft:explosion",
6530        }
6531    }
6532}
6533
6534/// A stage-5 **lethal volume** (DSL v0.10, spec-0031): a declared box that kills
6535/// whatever enters it, and states — in the campaign's own words — what killed it.
6536///
6537/// # A mechanism, not a fiction
6538///
6539/// The commissioning case was a cliff whose fall must be fatal, but nothing here
6540/// knows what a cliff is: a lava pit, an acid pool, an out-of-bounds plane and the
6541/// bottom of a lift shaft are the same declaration, differently dressed and
6542/// differently worded. The alternative considered and **rejected** for the cliff
6543/// was making the world's horizon void so the fall kills anyway — that changes
6544/// approved art to obtain a behaviour, and it serves exactly one fiction.
6545///
6546/// # It is geometry, so the completability proof owns it
6547///
6548/// A volume that kills is, for a route, a volume that cannot be crossed. The
6549/// compiler models its cells as impassable in the same navigation world every
6550/// other reachability proof runs on, exactly as a `close-gate`'s sealed region is
6551/// modelled solid — so a forced path that has no way to an objective except
6552/// through a lethal volume is a build failure (`DW0510`) naming the volume, never
6553/// a shipped delve that kills the player on the critical path. A respawn seat
6554/// inside one is the death loop that failure mode ends in, and is its own
6555/// error (`DW0511`).
6556///
6557/// # It rides the death edge that already exists
6558///
6559/// The kill is a `/damage`, exactly like `damage-players`, a trap payload or a
6560/// timed gate's crush. Everything downstream — the vanilla `deathCount` edge
6561/// (`dw.deaths` / `dw.death_ack`), the checkpoint re-seat (`cp_respawn_check`),
6562/// `keep_inventory` — sees an ordinary death and needs no second detector.
6563#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6564#[serde(deny_unknown_fields)]
6565pub struct LethalVolume {
6566    /// Unique lethal-volume id (`lethal/<kebab>`).
6567    pub id: LethalVolumeId,
6568    /// The volume: an anchor-centred box (`anchor ± extent`).
6569    ///
6570    /// **Deliberately the existing zone type**, not a second struct with the same
6571    /// two fields. `StealthZone` is already the engine's anchor-centred box object
6572    /// class — `damage-players`'s `in` filter reuses it, and the compiler resolves
6573    /// every one of them through the single `Plan::zone_box`. A private twin here
6574    /// would be `tools/ci/check-capability-ownership.py` check C by construction, and
6575    /// would fork the resolution the very next time a box grew a capability.
6576    pub region: StealthZone,
6577    /// What this volume says when it kills — a player-visible line, inventoried
6578    /// under `lethal.<id>.message` and translated like every other one.
6579    ///
6580    /// Required, and deliberately so: a volume with no words is a player who dies
6581    /// with no idea why, and there is no compiler-owned default that could be
6582    /// right for a cliff, a lava pit and an acid pool at once.
6583    pub message: String,
6584    /// The damage type the kill is dealt with (default [`DamageKind::Generic`]).
6585    ///
6586    /// This is what words vanilla's own broadcast — `fall` says the party member
6587    /// fell from a high place, `on_fire` that they burnt to a crisp — while
6588    /// [`Self::message`] says what the *place* was. The curated enum is shared
6589    /// with `damage-players`, so a lethal volume can no more void a held totem
6590    /// than a scripted hit can.
6591    #[serde(default, skip_serializing_if = "Option::is_none")]
6592    pub damage_type: Option<DamageKind>,
6593    /// **The blocks that show a player this floor kills** (spec-0062 §3).
6594    ///
6595    /// A lava surface, a magma floor, a bed of spikes, a burning strip: the
6596    /// danger is level with the footing because the block *is* the signal. This
6597    /// is where a volume says so, and it is a claim about the assembled bytes
6598    /// rather than a word that switches a rule off — `DW0891` checks it per
6599    /// caught cell against the block under or in that cell, refuses a listed
6600    /// block no caught cell bears out, and refuses at validation any id vanilla
6601    /// does not hurt a body with.
6602    ///
6603    /// Empty is the ordinary case and means the ordinary thing: this volume
6604    /// catches no floor the party walks, because it sits at the bottom of a pit
6605    /// or a course under a lake's surface. It does **not** exempt a cell from
6606    /// the walk graph — a visible hazard is still a hazard, and the router
6607    /// refuses every cell of the keep-out either way.
6608    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6609    pub shown_by: Vec<String>,
6610    /// **When this volume kills** (spec-0088): the one [`Guard`] every other
6611    /// gated object carries, verbatim. Absent, the volume is live from
6612    /// world-load to the end. Present, it is live while its gate holds —
6613    /// `requires_flags` is a pit that kills from a beat on, `forbids_flags` a
6614    /// shaft that kills until one, `requires_state` a chamber that kills while
6615    /// a party datum stands in range.
6616    ///
6617    /// A volume's liveness is a fact about the place, so the gate is a fact
6618    /// about the party: `when: {}` (a stage with no term) and a term on a
6619    /// `player`-scoped datum are refused at the document (`DW0953`). The
6620    /// navigation world holds the volume per quest configuration, and `DW0891`
6621    /// judges what shows it in every configuration a body can meet it in,
6622    /// including the last one before it goes live.
6623    #[serde(default, skip_serializing_if = "Option::is_none")]
6624    pub when: Option<Guard>,
6625}
6626
6627/// A stage-5 **loop** (spec-0086): a slab a body crosses and is returned from,
6628/// by a whole-block offset, to an earlier section that looks exactly the same —
6629/// its position inside the cell, its facing and its velocity all kept.
6630///
6631/// # It is a region with a standing property
6632///
6633/// A loop acts on whatever body enters a volume, every tick, the way a
6634/// [`LethalVolume`] does; nothing completes and nobody is addressed. So it is
6635/// declared beside the lethal volume, with the same region type
6636/// ([`StealthZone`], resolved through the one `Plan::zone_box`), and not as a
6637/// trigger with a relative teleport in it: the seamlessness proof is a property
6638/// of the region-plus-offset pair, which a compiler would otherwise have to
6639/// recognise by pattern-matching a trigger's effect list.
6640///
6641/// # The offset is derived, never typed
6642///
6643/// `to` is a [`Mark`] naming where the slab's own anchor cell lands, so the
6644/// offset is `cell(to) − cell(region.anchor)` — a whole-block vector by
6645/// construction, and a judgement about the world (*the fourth bay's anchor lands
6646/// on the second's*) rather than a vector the compiler could compute.
6647///
6648/// # The gate is the release
6649///
6650/// The loop **holds** while its gate is open and stands down while it is shut,
6651/// read against the party: flags are campaign state, and a `requires_state` term
6652/// must name a `party` datum (`DW0949`). A loop with no gate term holds forever
6653/// and is refused at the document (`DW0949`).
6654#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6655#[serde(deny_unknown_fields)]
6656pub struct Loop {
6657    /// Unique loop id (`loop/<kebab>`).
6658    pub id: LoopId,
6659    /// The slab a body crosses: an anchor-centred box (`anchor ± extent`), one
6660    /// axis of which is the crossing axis. The existing zone type, for the reason
6661    /// [`LethalVolume::region`] gives.
6662    pub region: StealthZone,
6663    /// Where the slab's own anchor cell lands: the loop's offset is
6664    /// `cell(to) − cell(region.anchor)`. Lies inside its anchor's piece
6665    /// (`DW0897`).
6666    pub to: Mark,
6667    /// Flags that must all be set for the loop to hold.
6668    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6669    pub requires_flags: Vec<FlagId>,
6670    /// Flags any one of which stands the loop down — `forbids_flags:
6671    /// [flag/the-bell-found]` is a loop that ends the moment the bell is found.
6672    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6673    pub forbids_flags: Vec<FlagId>,
6674    /// Numeric gate terms: every comparison must hold for the loop to hold. Each
6675    /// names a `party`-scoped datum (`DW0949`). The third field of the one gate,
6676    /// carried by every gate consumer.
6677    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6678    pub requires_state: Vec<StateCompare>,
6679    /// A `party`-scoped datum the loop raises by one on every move, before
6680    /// `on_cross` runs — the counter a crossing-counted release reads.
6681    #[serde(default, skip_serializing_if = "Option::is_none")]
6682    pub counts: Option<StateId>,
6683    /// The dungeon's answer to a move: effects run on every move, after the body
6684    /// is moved and the count raised, from the server command source (no acting
6685    /// player). Each effect's own `when` keys a write to a count. A `teleport`
6686    /// here is refused (`DW0949`).
6687    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6688    pub on_cross: Vec<QuestEffect>,
6689}
6690
6691// ---------------------------------------------------------------------------
6692// Stage 5 — trade and the recovery stake (DSL v0.10, spec-0032)
6693// ---------------------------------------------------------------------------
6694
6695/// A stage-5 **shop** (DSL v0.10, spec-0032): an interaction point that opens a
6696/// list of offers, each of which is a gate and a bundle of effects.
6697///
6698/// # It is the rest flow with different buttons, and that is the whole design
6699///
6700/// A bonfire is already an interaction entity, a player-interaction advancement
6701/// that supplies the acting player, a `minecraft:multi_action` dialog, buttons
6702/// that run `/trigger`, and tick dispatch. A shop is that same hardware with an
6703/// authored button list, so nothing here is new machinery — it is the machinery
6704/// spec-0016 §1 shipped, made author-visible.
6705///
6706/// # There is no price field, and that is deliberate
6707///
6708/// A price is *"may this happen yet?"*, which is the question
6709/// [`Gate`](crate::gate::Gate) answers for six other object classes. An offer is
6710/// therefore the **seventh gate consumer**: it carries `requires_flags`,
6711/// `forbids_flags` and `requires_state`, and the numeric comparison it needs is
6712/// the one spec-0031 put in the gate rather than in the verb that first asked.
6713/// A `price` field would be a second comparison surface for one meaning, which is
6714/// the defect CLAUDE.md names first.
6715///
6716/// # Villager `Offers` is excluded
6717///
6718/// Three independent reasons, any one sufficient (spec-0032): a vanilla trade
6719/// cost can only ever be an item, never a scoreboard value; right-click on a
6720/// villager body is already allocated to dialogue; and the data-driven trade
6721/// registry post-dates the pinned 1.21.11.
6722#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6723#[serde(deny_unknown_fields)]
6724pub struct Shop {
6725    /// Unique shop id (`shop/<kebab>`).
6726    pub id: ShopId,
6727    /// The prefab anchor the shop's interaction point stands on.
6728    pub anchor: AnchorId,
6729    /// The dialog's title line. Player-visible, inventoried under
6730    /// `shop.<id>.title`.
6731    pub title: String,
6732    /// The item the visible marker renders as (default `minecraft:emerald`).
6733    /// Cosmetic only: the marker is a `minecraft:item_display`, so it never
6734    /// occupies the cell or obstructs the hitbox.
6735    #[serde(default, skip_serializing_if = "Option::is_none")]
6736    pub marker_item: Option<String>,
6737    /// The offers, in declaration order — which is button order, and the order
6738    /// the `/trigger` routing values are assigned in.
6739    pub offers: Vec<ShopOffer>,
6740}
6741
6742/// One button in a [`Shop`]: a gate, a label, and the effects choosing it runs.
6743///
6744/// **Effect root R8.** The bundle hangs off an object with runtime machinery of
6745/// its own (an advancement, a dialog, a trigger objective and a tick dispatch),
6746/// which is spec-0031's stated rule for adding a root rather than desugaring.
6747///
6748/// **Refusal is authored, not a field.** An offer whose own gate is closed is not
6749/// shown at all; an offer that should be *shown and refuse* leaves its own gate
6750/// open and gates its effects instead — the purchase behind `at-least <price>`,
6751/// the apology behind `at-most <price − 1>`. Both are the ordinary per-effect
6752/// gate every effect already carries, so the engine adds no `refused` field and
6753/// no second way to say one thing.
6754#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6755#[serde(deny_unknown_fields)]
6756pub struct ShopOffer {
6757    /// The button's caption. Player-visible, inventoried under
6758    /// `shop.<id>.offer.<i>.label`, and width-checked like any dialog button.
6759    pub label: String,
6760    /// The button's hover tooltip — the natural home for a price in words.
6761    /// Player-visible, inventoried under `shop.<id>.offer.<i>.tooltip`.
6762    #[serde(default, skip_serializing_if = "Option::is_none")]
6763    pub tooltip: Option<String>,
6764    /// Flags that must all be set for this offer to be shown (the one gate).
6765    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6766    pub requires_flags: Vec<FlagId>,
6767    /// Flags whose being set hides this offer (the one gate).
6768    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6769    pub forbids_flags: Vec<FlagId>,
6770    /// Numeric gate terms (the one gate) — **this is where a price lives**.
6771    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6772    pub requires_state: Vec<StateCompare>,
6773    /// What choosing this offer does. Effect root R8; runs with the choosing
6774    /// player as the acting player, so a `player`-scoped datum is writable here.
6775    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6776    pub effects: Vec<QuestEffect>,
6777}
6778
6779impl ShopOffer {
6780    /// This offer's whole gate, as one value (DSL v0.10) — see
6781    /// [`crate::gate::Gate`].
6782    pub fn gate(&self) -> crate::gate::Gate<'_> {
6783        crate::gate::Gate::of(
6784            &self.requires_flags,
6785            &self.forbids_flags,
6786            &self.requires_state,
6787        )
6788    }
6789}
6790
6791/// How much of a datum a death forfeits into a [`Stake`] (DSL v0.10, spec-0032).
6792///
6793/// A creator who wants **no death cost at all** picks [`Forfeit::None`]; the
6794/// stake then still marks where they fell, which is the memorial case.
6795#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6796#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
6797pub enum Forfeit {
6798    /// The whole balance.
6799    All,
6800    /// A percentage of the balance, rounded toward zero (integer arithmetic —
6801    /// ADR-0006 forbids anything a second implementation could round differently).
6802    Proportion {
6803        /// 0–100.
6804        percent: u32,
6805    },
6806    /// A fixed amount, capped at the balance so a purse can never go negative.
6807    Fixed {
6808        /// The amount.
6809        amount: i32,
6810    },
6811    /// Nothing is taken. The stake is a marker, not a wager.
6812    None,
6813}
6814
6815/// What a death does when the player already holds the maximum number of live
6816/// stakes (DSL v0.10, spec-0032).
6817#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6818#[serde(rename_all = "kebab-case")]
6819pub enum OnFull {
6820    /// Retire the player's oldest live stake — its contents are lost — and place a
6821    /// new one. With `max_live: 1` this is the souls behaviour.
6822    Replace,
6823    /// Leave the existing stakes alone: the new death places nothing and forfeits
6824    /// nothing. One wager at a time.
6825    Keep,
6826}
6827
6828impl OnFull {
6829    /// The wire token (`replace` / `keep`).
6830    pub fn token(self) -> &'static str {
6831        match self {
6832            OnFull::Replace => "replace",
6833            OnFull::Keep => "keep",
6834        }
6835    }
6836}
6837
6838/// Who may collect a stake that is not theirs (DSL v0.10, spec-0032).
6839#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6840#[serde(rename_all = "kebab-case")]
6841pub enum CollectBy {
6842    /// Only the player who left it. The default.
6843    Owner,
6844    /// Anyone standing at it. The whole of every live stake at that place is
6845    /// transferred to the collector.
6846    Anyone,
6847}
6848
6849impl CollectBy {
6850    /// The wire token (`owner` / `anyone`).
6851    pub fn token(self) -> &'static str {
6852        match self {
6853            CollectBy::Owner => "owner",
6854            CollectBy::Anyone => "anyone",
6855        }
6856    }
6857}
6858
6859/// A stage-5 **recovery stake** (DSL v0.10, spec-0032): what a death leaves
6860/// behind, and the one chance to get it back.
6861///
6862/// # A mechanism, not a genre
6863///
6864/// "Souls" is one setting of this declaration and nothing here knows the word.
6865/// The mechanism is *a death forfeits some of a declared datum into a collectable
6866/// marker at a computed place, and collecting it returns exactly what was taken*.
6867/// A campaign with no death cost, a campaign with a permanent memorial at every
6868/// death site, and a campaign with the souls loop are three configurations, not
6869/// three engines.
6870///
6871/// # Where it lands is a compile-time answer
6872///
6873/// The owner's rule: *the anchor is the point, on the walkable path
6874/// from the respawn point in force at the moment of death to the death point
6875/// under the quest state in force at that moment, that minimises distance to the
6876/// death point.* Every term already has an owner in the compiler — walkability is
6877/// the navigation world the completability proof runs on, quest-state passability
6878/// is the DAG-indexed sealing `close-gate` established, and the respawn point in
6879/// force is engine state. So it is a table computed at build time and a lookup at
6880/// run time: **no runtime search, no nondeterminism.**
6881///
6882/// # It is hardware, so it inherits the hardware rules
6883///
6884/// The marker is an invisible `minecraft:interaction` for the hitbox plus a
6885/// glowing `minecraft:item_display` for the rendering — the same pair every other
6886/// affordance in the engine uses, and therefore the same invisible-affordance
6887/// (`DW0420`) and hardware-erasure (`DW0421`) proofs. It is deliberately **not**
6888/// a dropped item entity: an item despawns after 6000 ticks, burns in lava, sinks
6889/// in the void, and can be picked up by anyone.
6890#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6891#[serde(deny_unknown_fields)]
6892pub struct Stake {
6893    /// Unique stake id (`stake/<kebab>`).
6894    pub id: StakeId,
6895    /// The datum a death forfeits into this stake, and a collection returns.
6896    /// Must be declared in the stage-5 `state` list, and must be `player`-scoped:
6897    /// a stake is a personal wager, and a party-shared purse would turn one
6898    /// player's death into everyone's penalty.
6899    pub state: StateId,
6900    /// How much of [`Self::state`] a death takes. Default: the whole balance.
6901    #[serde(default, skip_serializing_if = "Option::is_none")]
6902    pub forfeit: Option<Forfeit>,
6903    /// How many live stakes one player may hold at once. Default 1; `0` means a
6904    /// death never places one (and never forfeits anything), which is the
6905    /// "no death cost" configuration.
6906    #[serde(default, skip_serializing_if = "Option::is_none")]
6907    pub max_live: Option<u32>,
6908    /// What a death does when the player is already at [`Self::max_live`].
6909    /// Default [`OnFull::Replace`].
6910    #[serde(default, skip_serializing_if = "Option::is_none")]
6911    pub on_full: Option<OnFull>,
6912    /// Who may collect. Default [`CollectBy::Owner`].
6913    #[serde(default, skip_serializing_if = "Option::is_none")]
6914    pub collect_by: Option<CollectBy>,
6915    /// The line a collection says, on the action bar. Player-visible, inventoried
6916    /// under `stake.<id>.collected`.
6917    pub collected_message: String,
6918    /// The item the glowing marker renders as (default `minecraft:soul_lantern`).
6919    #[serde(default, skip_serializing_if = "Option::is_none")]
6920    pub marker_item: Option<String>,
6921}
6922
6923impl Stake {
6924    /// The forfeit rule, with the documented default applied.
6925    pub fn forfeit(&self) -> Forfeit {
6926        self.forfeit.unwrap_or(Forfeit::All)
6927    }
6928
6929    /// How many live stakes one player may hold, with the documented default.
6930    pub fn max_live(&self) -> u32 {
6931        self.max_live.unwrap_or(1)
6932    }
6933
6934    /// The at-capacity rule, with the documented default.
6935    pub fn on_full(&self) -> OnFull {
6936        self.on_full.unwrap_or(OnFull::Replace)
6937    }
6938
6939    /// The collection rule, with the documented default.
6940    pub fn collect_by(&self) -> CollectBy {
6941        self.collect_by.unwrap_or(CollectBy::Owner)
6942    }
6943
6944    /// The item the marker renders as, with the documented default.
6945    pub fn marker_item(&self) -> &str {
6946        self.marker_item
6947            .as_deref()
6948            .unwrap_or("minecraft:soul_lantern")
6949    }
6950}
6951
6952/// The presentation channel for a [`Verb::Narrate`] (DSL v0.4).
6953#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6954#[serde(rename_all = "kebab-case")]
6955pub enum NarrateStyle {
6956    /// A chat line (default).
6957    Chat,
6958    /// A large on-screen title.
6959    Title,
6960    /// An on-screen subtitle.
6961    Subtitle,
6962    /// An "art" title rendered through the delve's custom resource-pack pixel-banner
6963    /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
6964    /// spec-0014). Text is checked at compile time against the font's glyph inventory
6965    /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
6966    /// renders in the vanilla title slot, so it is width-checked like any title
6967    /// (`DW0330`) — roughly 15 glyphs fit on screen.
6968    Art,
6969    /// The **actionbar** — the one-line strip above the hotbar (DSL v0.11).
6970    ///
6971    /// This is the channel a *reply* uses: it does not interrupt, it does not
6972    /// stack, and it is overwritten by the next one. Every reply the compiler
6973    /// itself writes has always used it — a sealed gate's answer, a checkpoint
6974    /// return, the lobby's party count — but `narrate` could not reach it, which
6975    /// is the mechanical reason `close-gate.sealed_hint` could not have been an
6976    /// ordinary `narrate` even had someone tried (capability-ownership audit
6977    /// finding 3b). A channel is a property of the message, not of the verb that
6978    /// first wanted it.
6979    ///
6980    /// Unlike a title it is never width-checked: vanilla truncates nothing and
6981    /// draws it at GUI width, and a reply is a fragment rather than a banner.
6982    Actionbar,
6983}
6984
6985impl NarrateStyle {
6986    /// The kebab tag (`chat` / `title` / `subtitle` / `art` / `actionbar`).
6987    pub fn token(self) -> &'static str {
6988        match self {
6989            NarrateStyle::Chat => "chat",
6990            NarrateStyle::Title => "title",
6991            NarrateStyle::Subtitle => "subtitle",
6992            NarrateStyle::Art => "art",
6993            NarrateStyle::Actionbar => "actionbar",
6994        }
6995    }
6996}
6997
6998/// **A mark** (spec-0066): an anchor and an integer block offset from it, the
6999/// one declaration of a point the campaign places relative to a piece.
7000///
7001/// Its cell is the anchor's resolved cell plus `offset`, in world axes after
7002/// the piece's placement. It is what a body stands on ([`Npc::offset`],
7003/// [`Actor::offset`]), where a walk ends ([`Verb::MoveNpc`], [`Verb::MoveActor`],
7004/// [`Verb::Teleport`]), where the cast ledger says a body is
7005/// ([`CastPlace::Mark`]), where a sound plays ([`SoundAt::Anchor`]), and every
7006/// camera position in a shot: a dolly waypoint (`path`), an aim target
7007/// (`look_at`) and an anchor subject (`subject`). The roles are fields; the
7008/// type is one.
7009///
7010/// A mark's cell lies inside the placed piece its anchor belongs to (`DW0897`):
7011/// an offset says *where beside this place*, never *which place*.
7012#[derive(
7013    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
7014)]
7015#[serde(deny_unknown_fields)]
7016pub struct Mark {
7017    /// The anchor the mark is relative to.
7018    pub anchor: AnchorId,
7019    /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
7020    #[serde(default, skip_serializing_if = "is_zero3")]
7021    pub offset: [i32; 3],
7022}
7023
7024impl Mark {
7025    /// A mark at `anchor` with no offset.
7026    pub fn at(anchor: AnchorId) -> Self {
7027        Mark {
7028            anchor,
7029            offset: [0, 0, 0],
7030        }
7031    }
7032
7033    /// Whether the offset is non-zero.
7034    pub fn is_offset(&self) -> bool {
7035        !is_zero3(&self.offset)
7036    }
7037
7038    /// The mark's cell, given the cell its anchor resolved to.
7039    pub fn cell(&self, anchor_cell: [i32; 3]) -> [i32; 3] {
7040        offset_cell(anchor_cell, self.offset)
7041    }
7042
7043    /// The mark as a diagnostic spells it: the anchor alone at a zero offset,
7044    /// else `anchor + [x, y, z]`.
7045    pub fn display(&self) -> String {
7046        if self.is_offset() {
7047            format!(
7048                "{} + [{}, {}, {}]",
7049                self.anchor, self.offset[0], self.offset[1], self.offset[2]
7050            )
7051        } else {
7052            self.anchor.as_str().to_string()
7053        }
7054    }
7055}
7056
7057/// `cell + offset`, componentwise: the one arithmetic a [`Mark`] adds.
7058pub fn offset_cell(cell: [i32; 3], offset: [i32; 3]) -> [i32; 3] {
7059    [
7060        cell[0] + offset[0],
7061        cell[1] + offset[1],
7062        cell[2] + offset[2],
7063    ]
7064}
7065
7066/// One shot of a [`Verb::Cutscene`] (DSL v0.6): a camera dolly with its
7067/// own duration and optional subject. A cutscene plays its shots back-to-back —
7068/// a hard cut between them — inside a single gamemode/position save-restore
7069/// bracket, so a wide establishing move can be followed by an interior close-up
7070/// without the players ever leaving the cinematic.
7071#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
7072#[serde(deny_unknown_fields)]
7073pub struct CameraShot {
7074    /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
7075    /// path is a static shot. Required without `shot_style`; with one, optional —
7076    /// an explicit `path` always overrides the style's expanded dolly.
7077    #[serde(default, skip_serializing_if = "Vec::is_empty")]
7078    pub path: Vec<Mark>,
7079    /// This shot's duration in seconds. Required without `shot_style`; with one,
7080    /// optional — the style's default duration applies (see
7081    /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
7082    #[serde(default, skip_serializing_if = "Option::is_none")]
7083    pub seconds: Option<u32>,
7084    /// Optional subject the camera keeps framed for this shot. Absent = face
7085    /// along the direction of travel (or, under a `shot_style`, the style's own
7086    /// aim at its `subject`). An explicit `look_at` always overrides a style's
7087    /// aim.
7088    #[serde(default, skip_serializing_if = "Option::is_none")]
7089    pub look_at: Option<Mark>,
7090    /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
7091    /// compiler expands the style deterministically into a camera dolly +
7092    /// per-keyframe aim from the `subject`'s resolved geometry. Requires
7093    /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
7094    /// the corresponding expanded part (`DW0348` polices the combinations).
7095    #[serde(default, skip_serializing_if = "Option::is_none")]
7096    pub shot_style: Option<ShotStyle>,
7097    /// The styled shot's subject — what the shot is *about*. Required with
7098    /// `shot_style`, rejected without one (`DW0348`).
7099    #[serde(default, skip_serializing_if = "Option::is_none")]
7100    pub subject: Option<CameraSubject>,
7101    /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
7102    #[serde(default, skip_serializing_if = "Option::is_none")]
7103    pub subject_b: Option<CameraSubject>,
7104    /// Styled shots only: the style's characteristic camera distance in blocks
7105    /// (its *start* distance for the dolly styles). Default per style — see the
7106    /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
7107    /// (`DW0348`).
7108    #[serde(default, skip_serializing_if = "Option::is_none")]
7109    pub dist: Option<f64>,
7110    /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
7111    /// default 90 (`DW0348` elsewhere or out of range).
7112    #[serde(default, skip_serializing_if = "Option::is_none")]
7113    pub degrees: Option<f64>,
7114    /// Styled shots only: placement bearing in degrees — where the camera sits
7115    /// (or starts) relative to the subject, measured like a Minecraft yaw
7116    /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
7117    /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
7118    /// `side-track` the bearing picks which side of the subject's travel the
7119    /// camera runs abeam (`0` = right of travel, `180` = left).
7120    #[serde(default, skip_serializing_if = "Option::is_none")]
7121    pub bearing: Option<f64>,
7122}
7123
7124/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
7125/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
7126/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
7127/// is **distance only** — vanilla has no in-game FOV control.
7128#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7129#[serde(rename_all = "kebab-case")]
7130pub enum ShotStyle {
7131    /// Fully static close framing of a prop/detail. The beat that always looks
7132    /// right.
7133    Insert,
7134    /// Static position; only the aim turns as the subject (ideally moving)
7135    /// passes. Rockstar's roadside "ground view".
7136    LockedOff,
7137    /// Straight dolly toward the subject along the view axis; medium → close.
7138    PushIn,
7139    /// Reverse of `push-in`: close → wide, revealing context.
7140    PullBackReveal,
7141    /// High and far, descending and closing on the subject. First sight of an
7142    /// area.
7143    EstablishingCrane,
7144    /// Constant-radius, constant-height arc around the subject.
7145    OrbitArc,
7146    /// Parallel dolly abeam a **moving** subject at constant offset — needs a
7147    /// compiler-known subject path (`DW0349`).
7148    SideTrack,
7149    /// Static placement solved so **both** subjects land on opposite thirds
7150    /// (Toric-space construction). Needs `subject_b`.
7151    TwoShot,
7152    /// Low, close, trailing a **moving** subject near ground level — needs a
7153    /// compiler-known subject path (`DW0349`).
7154    LowFollow,
7155}
7156
7157impl ShotStyle {
7158    /// The kebab token (diagnostics, digests).
7159    pub fn token(self) -> &'static str {
7160        match self {
7161            ShotStyle::Insert => "insert",
7162            ShotStyle::LockedOff => "locked-off",
7163            ShotStyle::PushIn => "push-in",
7164            ShotStyle::PullBackReveal => "pull-back-reveal",
7165            ShotStyle::EstablishingCrane => "establishing-crane",
7166            ShotStyle::OrbitArc => "orbit-arc",
7167            ShotStyle::SideTrack => "side-track",
7168            ShotStyle::TwoShot => "two-shot",
7169            ShotStyle::LowFollow => "low-follow",
7170        }
7171    }
7172
7173    /// Default shot duration in seconds, from the dossier's per-style duration
7174    /// ranges (§2, anchored on the film-editing ASL literature) — the value an
7175    /// omitted `seconds` resolves to.
7176    pub fn default_seconds(self) -> u32 {
7177        match self {
7178            ShotStyle::Insert => 2,
7179            ShotStyle::LockedOff => 6,
7180            ShotStyle::PushIn => 4,
7181            ShotStyle::PullBackReveal => 6,
7182            ShotStyle::EstablishingCrane => 8,
7183            ShotStyle::OrbitArc => 8,
7184            ShotStyle::SideTrack => 8,
7185            ShotStyle::TwoShot => 5,
7186            ShotStyle::LowFollow => 5,
7187        }
7188    }
7189
7190    /// `true` for the styles whose subject must be *moving* on a compiler-known
7191    /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
7192    /// `side-track` and `low-follow` (`DW0349` otherwise).
7193    pub fn needs_moving_subject(self) -> bool {
7194        matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
7195    }
7196}
7197
7198/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
7199/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
7200/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
7201/// **plus one block up** (torso height, so a close shot does not frame feet)
7202/// before `offset` is applied; an `anchor` subject aims at the block centre
7203/// exactly like a [`CameraTarget`].
7204/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
7205/// — serde has no variant-level `deny_unknown_fields`, so an untagged
7206/// enum with inline struct variants silently *ignores* any key it does not
7207/// recognise: `{"npc": …, "ofset": [0,1,0]}` would deserialize happily with the
7208/// offset dropped, and `{"anchor": …, "npc": …}` would quietly match `Anchor`
7209/// and discard the NPC. Lifting each variant into its own type keeps the repo-wide
7210/// deny-unknown rule for both serde and the published JSON Schema
7211/// (`additionalProperties: false`): a mistyped shot subject fails the schema
7212/// instead of rendering a shot pointed somewhere the author never asked for.
7213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7214#[serde(untagged)]
7215pub enum CameraSubject {
7216    /// A fixed world point: prefab anchor + offset.
7217    Anchor(Mark),
7218    /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
7219    /// group / sequence, else static at its declared (or spawn) anchor.
7220    Npc(NpcSubject),
7221    /// A stage-5 actor — moving if a `move-actor` for it runs in the same
7222    /// effect group / sequence, else static at its declared anchor.
7223    Actor(ActorSubject),
7224}
7225
7226/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
7227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7228#[serde(deny_unknown_fields)]
7229pub struct NpcSubject {
7230    /// The NPC (stage-2 ref).
7231    pub npc: NpcId,
7232    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
7233    #[serde(default, skip_serializing_if = "is_zero3")]
7234    pub offset: [i32; 3],
7235}
7236
7237/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
7238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7239#[serde(deny_unknown_fields)]
7240pub struct ActorSubject {
7241    /// The actor (stage-5 `actors` ref).
7242    pub actor: ActorId,
7243    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
7244    #[serde(default, skip_serializing_if = "is_zero3")]
7245    pub offset: [i32; 3],
7246}
7247
7248impl CameraSubject {
7249    /// The subject's integer offset, whichever variant.
7250    pub fn offset(&self) -> [i32; 3] {
7251        match self {
7252            CameraSubject::Anchor(s) => s.offset,
7253            CameraSubject::Npc(s) => s.offset,
7254            CameraSubject::Actor(s) => s.offset,
7255        }
7256    }
7257
7258    /// A short canonical rendering for digests/diagnostics.
7259    pub fn canon(&self) -> String {
7260        let (kind, id, o) = match self {
7261            CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
7262            CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
7263            CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
7264        };
7265        format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
7266    }
7267}
7268
7269/// `Debug` is hand-written because it is a **stable content-key rendering**:
7270/// the compiler's `payload_verb_key` (FNV over a verb's own `{:?}`) names the
7271/// generated `volley_`, `collapse_` and `teleport_` functions from it, so a shot
7272/// that uses none of the v0.6 style fields must render byte-identically to the
7273/// pre-style struct (`seconds` prints its inner value; absent style fields print
7274/// nothing) — otherwise a purely additive schema change would silently churn the
7275/// content key of every payload that carries a cutscene.
7276impl std::fmt::Debug for CameraShot {
7277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
7278        let mut d = f.debug_struct("CameraShot");
7279        d.field("path", &self.path);
7280        match self.seconds {
7281            Some(v) => d.field("seconds", &v),
7282            None => d.field("seconds", &self.seconds),
7283        };
7284        d.field("look_at", &self.look_at);
7285        if self.shot_style.is_some() {
7286            d.field("shot_style", &self.shot_style);
7287        }
7288        if self.subject.is_some() {
7289            d.field("subject", &self.subject);
7290        }
7291        if self.subject_b.is_some() {
7292            d.field("subject_b", &self.subject_b);
7293        }
7294        if self.dist.is_some() {
7295            d.field("dist", &self.dist);
7296        }
7297        if self.degrees.is_some() {
7298            d.field("degrees", &self.degrees);
7299        }
7300        if self.bearing.is_some() {
7301            d.field("bearing", &self.bearing);
7302        }
7303        d.finish()
7304    }
7305}
7306
7307impl CameraShot {
7308    /// The shot's resolved duration in seconds: explicit `seconds`, else the
7309    /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
7310    /// fallback only keeps downstream passes total).
7311    pub fn resolved_seconds(&self) -> u32 {
7312        self.seconds
7313            .or(self.shot_style.map(ShotStyle::default_seconds))
7314            .unwrap_or(1)
7315    }
7316}
7317
7318/// serde `skip_serializing_if` helper: skip a `[0, 0, 0]` offset.
7319fn is_zero3(v: &[i32; 3]) -> bool {
7320    *v == [0, 0, 0]
7321}
7322
7323impl Verb {
7324    /// The kebab-case `type` tag this verb serializes as — the verb a
7325    /// diagnostic should name so the author can find it in the JSON.
7326    pub fn tag(&self) -> &'static str {
7327        match self {
7328            Verb::OpenGate { .. } => "open-gate",
7329            Verb::CloseGate { .. } => "close-gate",
7330            Verb::CampaignComplete { .. } => "campaign-complete",
7331            Verb::GiveItem { .. } => "give-item",
7332            Verb::SetFlag { .. } => "set-flag",
7333            Verb::SetState { .. } => "set-state",
7334            Verb::AddState { .. } => "add-state",
7335            Verb::ClearState { .. } => "clear-state",
7336            Verb::DropStake { .. } => "drop-stake",
7337            Verb::SpawnWave { .. } => "spawn-wave",
7338            Verb::Narrate { .. } => "narrate",
7339            Verb::SetBlock { .. } => "set-block",
7340            Verb::FillRegion { .. } => "fill-region",
7341            Verb::ClearRegion { .. } => "clear-region",
7342            Verb::SetAtmosphere { .. } => "set-atmosphere",
7343            Verb::OpenWay { .. } => "open-way",
7344            Verb::DespawnNpc { .. } => "despawn-npc",
7345            Verb::MoveNpc { .. } => "move-npc",
7346            Verb::Cutscene { .. } => "cutscene",
7347            Verb::SetTime { .. } => "set-time",
7348            Verb::SetWeather { .. } => "set-weather",
7349            Verb::PlaySound { .. } => "play-sound",
7350            Verb::DamagePlayers { .. } => "damage-players",
7351            Verb::SetCheckpoint { .. } => "set-checkpoint",
7352            Verb::Bonfire { .. } => "bonfire",
7353            Verb::BeginStealth { .. } => "begin-stealth",
7354            Verb::EndStealth => "end-stealth",
7355            Verb::SpawnNpc { .. } => "spawn-npc",
7356            Verb::SpawnActor { .. } => "spawn-actor",
7357            Verb::DespawnActor { .. } => "despawn-actor",
7358            Verb::MoveActor { .. } => "move-actor",
7359            Verb::UnleashActor { .. } => "unleash-actor",
7360            Verb::Sequence { .. } => "sequence",
7361            Verb::Volley { .. } => "volley",
7362            Verb::Collapse { .. } => "collapse",
7363            Verb::GiveEffect { .. } => "give-effect",
7364            Verb::ClearEffect { .. } => "clear-effect",
7365            Verb::Teleport { .. } => "teleport",
7366            Verb::Firework { .. } => "firework",
7367            Verb::SpawnAssembly { .. } => "spawn-assembly",
7368            Verb::DespawnAssembly { .. } => "despawn-assembly",
7369            Verb::PlayClip { .. } => "play-clip",
7370            Verb::ArmStrikes { .. } => "arm-strikes",
7371            Verb::Particle { .. } => "particle",
7372            Verb::Lightning { .. } => "lightning",
7373        }
7374    }
7375
7376    /// **Whether the emitter addresses this verb to players** (spec-0085 §3.3) —
7377    /// whether its emitted commands name the effect's audience selector at all.
7378    ///
7379    /// A verb that answers `false` is a **party fact**: it fires once for the
7380    /// world (a flag, a gate, a block, a region, a wave, an actor, an NPC, a
7381    /// camera, the time, a checkpoint, a stealth beat, a timeline, a teleported
7382    /// volume, a rocket), so the envelope's `audience` and `in` have nothing to
7383    /// narrow and are refused on it (`DW0942`). A `player`-scoped state write
7384    /// answers `false` too: its holder is the acting player by declaration, never
7385    /// the audience.
7386    ///
7387    /// Exhaustive, so a new verb cannot be added without answering it; and
7388    /// `emit`'s own test binds this answer to the emitted bytes in both
7389    /// directions — every verb is emitted under two audiences, and its commands
7390    /// differ exactly when this says `true`.
7391    pub fn addresses_players(&self) -> bool {
7392        match self {
7393            Verb::GiveItem { .. }
7394            | Verb::Narrate { .. }
7395            | Verb::PlaySound { .. }
7396            | Verb::DamagePlayers { .. }
7397            | Verb::GiveEffect { .. }
7398            | Verb::ClearEffect { .. }
7399            | Verb::Particle { .. } => true,
7400            Verb::OpenGate { .. }
7401            | Verb::CloseGate { .. }
7402            | Verb::CampaignComplete { .. }
7403            | Verb::SetFlag { .. }
7404            | Verb::SetState { .. }
7405            | Verb::AddState { .. }
7406            | Verb::ClearState { .. }
7407            | Verb::DropStake { .. }
7408            | Verb::SpawnWave { .. }
7409            | Verb::SetBlock { .. }
7410            | Verb::FillRegion { .. }
7411            | Verb::ClearRegion { .. }
7412            | Verb::OpenWay { .. }
7413            | Verb::DespawnNpc { .. }
7414            | Verb::MoveNpc { .. }
7415            | Verb::Cutscene { .. }
7416            | Verb::SetTime { .. }
7417            | Verb::SetWeather { .. }
7418            | Verb::SetCheckpoint { .. }
7419            | Verb::Bonfire { .. }
7420            | Verb::BeginStealth { .. }
7421            | Verb::EndStealth
7422            | Verb::SpawnActor { .. }
7423            | Verb::DespawnActor { .. }
7424            | Verb::MoveActor { .. }
7425            | Verb::UnleashActor { .. }
7426            | Verb::SpawnNpc { .. }
7427            | Verb::Sequence { .. }
7428            | Verb::Volley { .. }
7429            | Verb::Collapse { .. }
7430            | Verb::Teleport { .. }
7431            | Verb::Firework { .. }
7432            // spec-0092: the thunder is the game's to send; the bolt is a world fact.
7433            | Verb::Lightning { .. }
7434            // spec-0080: a biome repaint is a world fact (`fillbiome`).
7435            | Verb::SetAtmosphere { .. }
7436            // spec-0082: an assembly is a world object.
7437            | Verb::SpawnAssembly { .. }
7438            | Verb::DespawnAssembly { .. }
7439            | Verb::PlayClip { .. }
7440            | Verb::ArmStrikes { .. } => false,
7441        }
7442    }
7443}
7444
7445/// **How a nested effect list is dispatched, relative to the bundle it sits in**
7446/// (spec-0085 §3.2) — the one statement of which command source each nesting
7447/// site runs under, read by `DW0357`/`DW0503` and by the emitter's timeline
7448/// keying alike.
7449#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7450pub enum NestedDispatch {
7451    /// Under the parent's own source: a `sequence` step. A timeline started
7452    /// where there is an acting player carries that player across its
7453    /// `schedule`s by a compiler-owned tag, and a timeline started from the
7454    /// server source has nobody to carry.
7455    Inherit,
7456    /// As one player, whatever the parent was: a `set-checkpoint`'s
7457    /// `on_respawn` (the respawning player) and a `begin-stealth`'s `on_caught`
7458    /// (the spotted player).
7459    Player,
7460    /// From the server command source, whatever the parent was: a
7461    /// `move-npc`/`move-actor` `on_arrive` (the driver's scheduled tick) and a
7462    /// `bonfire`'s `on_rest` (the party-wide rest, dispatched from the tick).
7463    Server,
7464}
7465
7466impl NestedDispatch {
7467    /// Whether a list dispatched this way, inside a bundle that does (or does
7468    /// not) have an acting player, has one.
7469    pub fn has_actor(self, parent_has_actor: bool) -> bool {
7470        match self {
7471            NestedDispatch::Inherit => parent_has_actor,
7472            NestedDispatch::Player => true,
7473            NestedDispatch::Server => false,
7474        }
7475    }
7476}
7477
7478impl QuestEffect {
7479    /// Each nested effect list with how it is dispatched ([`NestedDispatch`]) —
7480    /// the same lists, in the same order, as [`Self::nested_effect_lists`].
7481    pub fn nested_effect_dispatch(&self) -> Vec<(&[QuestEffect], NestedDispatch)> {
7482        match &self.verb {
7483            Verb::Sequence { steps } => steps
7484                .iter()
7485                .map(|s| (s.effects.as_slice(), NestedDispatch::Inherit))
7486                .collect(),
7487            Verb::SetCheckpoint { on_respawn, .. } => {
7488                vec![(on_respawn.as_slice(), NestedDispatch::Player)]
7489            }
7490            Verb::Bonfire { on_rest, .. } => vec![(on_rest.as_slice(), NestedDispatch::Server)],
7491            Verb::BeginStealth { on_caught, .. } => {
7492                vec![(on_caught.as_slice(), NestedDispatch::Player)]
7493            }
7494            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
7495                vec![(on_arrive.as_slice(), NestedDispatch::Server)]
7496            }
7497            _ => Vec::new(),
7498        }
7499    }
7500
7501    /// Whether this effect addresses players at all — [`Verb::addresses_players`].
7502    pub fn addresses_players(&self) -> bool {
7503        self.verb.addresses_players()
7504    }
7505    /// The gate anchor if this is `open-gate`.
7506    pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
7507        match &self.verb {
7508            Verb::OpenGate { anchor, .. } => Some(anchor),
7509            _ => None,
7510        }
7511    }
7512
7513    /// The gate anchor if this is `close-gate` (DSL v0.6).
7514    pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
7515        match &self.verb {
7516            Verb::CloseGate { anchor, .. } => Some(anchor),
7517            _ => None,
7518        }
7519    }
7520
7521    /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
7522    /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
7523    /// the compiler's canonical English seal line.
7524    pub fn close_gate_sealed_hint(&self) -> Option<&str> {
7525        match &self.verb {
7526            Verb::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
7527            _ => None,
7528        }
7529    }
7530
7531    /// The wave id if this is `spawn-wave` (v0.3).
7532    pub fn spawn_wave(&self) -> Option<&WaveId> {
7533        match &self.verb {
7534            Verb::SpawnWave { wave, .. } => Some(wave),
7535            _ => None,
7536        }
7537    }
7538
7539    /// The flag id if this is `set-flag` (v0.3).
7540    pub fn set_flag(&self) -> Option<&FlagId> {
7541        match &self.verb {
7542            Verb::SetFlag { flag, .. } => Some(flag),
7543            _ => None,
7544        }
7545    }
7546
7547    /// The item id if this is `give-item` (v0.3).
7548    pub fn give_item(&self) -> Option<&str> {
7549        match &self.verb {
7550            Verb::GiveItem { item, .. } => Some(item),
7551            _ => None,
7552        }
7553    }
7554
7555    /// `true` if this is a `give-item` carrying a v0.4 display `name`.
7556    pub fn give_item_named(&self) -> bool {
7557        matches!(self.verb, Verb::GiveItem { name: Some(_), .. })
7558    }
7559
7560    /// The declared `carrier` if this is a `give-item` that states one (v0.6,
7561    /// spec-0018). `None` for any other effect **and** for a `give-item` that
7562    /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
7563    /// matters only for the pre-0.6 reserved-field gate.
7564    pub fn give_carrier(&self) -> Option<Carrier> {
7565        match &self.verb {
7566            Verb::GiveItem { carrier, .. } => *carrier,
7567            _ => None,
7568        }
7569    }
7570
7571    /// Does this `give-item` hand a single copy to the acting player (v0.6,
7572    /// spec-0018)? `false` for every other effect and for the party-wide default.
7573    pub fn gives_to_one(&self) -> bool {
7574        matches!(self.give_carrier(), Some(Carrier::One))
7575    }
7576
7577    /// The `set-block` block id if this is a v0.4 `set-block` effect.
7578    pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
7579        match &self.verb {
7580            Verb::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
7581            _ => None,
7582        }
7583    }
7584
7585    /// The NPC id if this is a v0.4 `despawn-npc` effect.
7586    pub fn despawn_npc(&self) -> Option<&NpcId> {
7587        match &self.verb {
7588            Verb::DespawnNpc { npc, .. } => Some(npc),
7589            _ => None,
7590        }
7591    }
7592
7593    /// `(npc, to)` if this is a v0.4 `move-npc` effect.
7594    pub fn move_npc(&self) -> Option<(&NpcId, &Mark)> {
7595        match &self.verb {
7596            Verb::MoveNpc { npc, to, .. } => Some((npc, to)),
7597            _ => None,
7598        }
7599    }
7600
7601    /// The v0.3 effect name if this effect is one introduced in DSL v0.3
7602    /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
7603    ///.
7604    pub fn v03_effect(&self) -> Option<&'static str> {
7605        match &self.verb {
7606            Verb::GiveItem { .. } => Some("give-item"),
7607            Verb::SetFlag { .. } => Some("set-flag"),
7608            Verb::SpawnWave { .. } => Some("spawn-wave"),
7609            Verb::OpenGate { .. }
7610            | Verb::CloseGate { .. }
7611            | Verb::CampaignComplete { .. } => None,
7612            // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
7613            // are not v0.3 verbs.
7614            Verb::Narrate { .. }
7615            | Verb::SetBlock { .. }
7616            | Verb::DespawnNpc { .. }
7617            | Verb::MoveNpc { .. }
7618            | Verb::Cutscene { .. }
7619            | Verb::SetTime { .. }
7620            | Verb::SetWeather { .. }
7621            | Verb::PlaySound { .. }
7622            | Verb::DamagePlayers { .. }
7623            | Verb::SetCheckpoint { .. }
7624            | Verb::Bonfire { .. }
7625            | Verb::BeginStealth { .. }
7626            | Verb::EndStealth
7627            | Verb::SpawnActor { .. }
7628            | Verb::DespawnActor { .. }
7629            | Verb::MoveActor { .. }
7630            | Verb::UnleashActor { .. }
7631            | Verb::SpawnNpc { .. }
7632            | Verb::Sequence { .. }
7633            // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
7634            | Verb::Volley { .. }
7635            | Verb::Collapse { .. }
7636            // spec-0031's state verbs, region writes, status effects and teleport,
7637            // and spec-0032's `drop-stake`, are all v0.10 — they report via
7638            // `v10_effect`.
7639            | Verb::SetState { .. }
7640            | Verb::AddState { .. }
7641            | Verb::ClearState { .. }
7642            | Verb::FillRegion { .. }
7643            | Verb::ClearRegion { .. }
7644            | Verb::SetAtmosphere { .. }
7645            // spec-0042's `open-way` is v0.12 — it reports via `v12_effect`.
7646            | Verb::OpenWay { .. }
7647            | Verb::GiveEffect { .. }
7648            | Verb::ClearEffect { .. }
7649            | Verb::Teleport { .. }
7650            // spec-0068's `firework` is v0.29.
7651            | Verb::Firework { .. }
7652            // spec-0082's assembly verbs.
7653            | Verb::SpawnAssembly { .. }
7654            | Verb::DespawnAssembly { .. }
7655            | Verb::PlayClip { .. }
7656            // spec-0094's `arm-strikes`.
7657            | Verb::ArmStrikes { .. }
7658            // spec-0085's `particle`.
7659            | Verb::Particle { .. }
7660            // spec-0092's `lightning`.
7661            | Verb::Lightning { .. }
7662            | Verb::DropStake { .. } => None,
7663        }
7664    }
7665
7666    /// The v0.4 effect name if this effect is one introduced in DSL v0.4
7667    /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
7668    /// in v0.4 campaigns.
7669    pub fn v04_effect(&self) -> Option<&'static str> {
7670        match &self.verb {
7671            Verb::Narrate { .. } => Some("narrate"),
7672            Verb::SetBlock { .. } => Some("set-block"),
7673            Verb::DespawnNpc { .. } => Some("despawn-npc"),
7674            Verb::MoveNpc { .. } => Some("move-npc"),
7675            Verb::Cutscene { .. } => Some("cutscene"),
7676            _ => None,
7677        }
7678    }
7679
7680    /// The v0.5 effect name if this effect is one introduced in DSL v0.5
7681    /// (`set-time`/`set-weather`, spec-0010).
7682    pub fn v05_effect(&self) -> Option<&'static str> {
7683        match &self.verb {
7684            Verb::SetTime { .. } => Some("set-time"),
7685            Verb::SetWeather { .. } => Some("set-weather"),
7686            _ => None,
7687        }
7688    }
7689
7690    /// The v0.6 effect name if this effect is one introduced in DSL v0.6
7691    /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
7692    /// `play-sound`, spec-0014; the scripted-actor staging verbs
7693    /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
7694    /// spec-0014). These validate in v0.6 campaigns
7695    /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
7696    /// — see [`QuestEffect::narrate_art`] — not a new effect.)
7697    pub fn v06_effect(&self) -> Option<&'static str> {
7698        match &self.verb {
7699            Verb::CloseGate { .. } => Some("close-gate"),
7700            Verb::SetCheckpoint { .. } => Some("set-checkpoint"),
7701            Verb::Bonfire { .. } => Some("bonfire"),
7702            Verb::BeginStealth { .. } => Some("begin-stealth"),
7703            Verb::EndStealth => Some("end-stealth"),
7704            Verb::PlaySound { .. } => Some("play-sound"),
7705            Verb::DamagePlayers { .. } => Some("damage-players"),
7706            Verb::SpawnActor { .. } => Some("spawn-actor"),
7707            Verb::DespawnActor { .. } => Some("despawn-actor"),
7708            Verb::MoveActor { .. } => Some("move-actor"),
7709            Verb::UnleashActor { .. } => Some("unleash-actor"),
7710            Verb::Sequence { .. } => Some("sequence"),
7711            Verb::SpawnNpc { .. } => Some("spawn-npc"),
7712            // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
7713            Verb::Volley { .. } => Some("volley"),
7714            Verb::Collapse { .. } => Some("collapse"),
7715            _ => None,
7716        }
7717    }
7718
7719    /// The v0.10 effect name if this effect is one introduced in DSL v0.10
7720    /// (`set-state`/`add-state`/`clear-state` and the region writes
7721    /// `fill-region`/`clear-region`, spec-0031). These validate in v0.10
7722    /// campaigns.
7723    pub fn v10_effect(&self) -> Option<&'static str> {
7724        match &self.verb {
7725            Verb::SetState { .. } => Some("set-state"),
7726            Verb::AddState { .. } => Some("add-state"),
7727            Verb::ClearState { .. } => Some("clear-state"),
7728            Verb::FillRegion { .. } => Some("fill-region"),
7729            Verb::ClearRegion { .. } => Some("clear-region"),
7730            Verb::GiveEffect { .. } => Some("give-effect"),
7731            Verb::ClearEffect { .. } => Some("clear-effect"),
7732            Verb::Teleport { .. } => Some("teleport"),
7733            Verb::DropStake { .. } => Some("drop-stake"),
7734            _ => None,
7735        }
7736    }
7737
7738    /// The v0.12 effect name if this effect is one introduced in DSL v0.12
7739    /// (`open-way`, spec-0042).
7740    pub fn v12_effect(&self) -> Option<&'static str> {
7741        match &self.verb {
7742            Verb::OpenWay { .. } => Some("open-way"),
7743            _ => None,
7744        }
7745    }
7746
7747    /// **The way this effect opens** (DSL v0.12, spec-0042): the placed piece and
7748    /// the name of one way that piece's spatial contract exports.
7749    ///
7750    /// The third spelling of the one region write, beside
7751    /// [`QuestEffect::region_write`] (the author's own box) and
7752    /// [`QuestEffect::gate_region_write`] (a gate anchor's box). It answers with a
7753    /// *reference* and never with geometry, because the geometry is not the
7754    /// campaign's to state: the cells, the block and the direction all live in the
7755    /// piece's metadata, and the compiler resolves them there
7756    /// (`compiler::ways`). A region-shaped accessor here would be the second
7757    /// authority this surface exists to avoid.
7758    pub fn way_write(&self) -> Option<(&PrefabId, &str)> {
7759        match &self.verb {
7760            Verb::OpenWay { piece, way, .. } => Some((piece, way.as_str())),
7761            _ => None,
7762        }
7763    }
7764
7765    /// **The one region write**, whichever verb spelled it (DSL v0.10,
7766    /// spec-0031): the box to write and the block to write it with — `None` for
7767    /// the block meaning *clear to air*.
7768    ///
7769    /// This is the accessor the capability belongs to. It answers for
7770    /// `fill-region` / `clear-region`, which name their own box; `open-gate` /
7771    /// `close-gate` are the same operation over a box a prefab gate anchor
7772    /// declares, so they answer through
7773    /// [`QuestEffect::gate_region_write`](Self::gate_region_write) — the anchor
7774    /// is theirs, the *operation* is shared.
7775    pub fn region_write(&self) -> Option<(&StealthZone, Option<&str>)> {
7776        match &self.verb {
7777            Verb::FillRegion { region, block, .. } => Some((region, Some(block.as_str()))),
7778            Verb::ClearRegion { region, .. } => Some((region, None)),
7779            _ => None,
7780        }
7781    }
7782
7783    /// The gate anchor this effect writes, and whether the write **fills** it:
7784    /// `Some((anchor, true))` for `close-gate`, `Some((anchor, false))` for
7785    /// `open-gate`, `None` for everything else.
7786    ///
7787    /// The gate half of [`QuestEffect::region_write`]: same operation, but the box
7788    /// and the fill block come from the prefab's gate anchor rather than from the
7789    /// author. Everything that reasons about runtime region writes reads both
7790    /// accessors and nothing else.
7791    pub fn gate_region_write(&self) -> Option<(&AnchorId, bool)> {
7792        match &self.verb {
7793            Verb::CloseGate { anchor, .. } => Some((anchor, true)),
7794            Verb::OpenGate { anchor, .. } => Some((anchor, false)),
7795            _ => None,
7796        }
7797    }
7798
7799    /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
7800    /// `volley` (spec-0022), with the documented defaults already applied.
7801    pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
7802        match &self.verb {
7803            Verb::Volley {
7804                projectile,
7805                from_anchor,
7806                kill_zone,
7807                salvos,
7808                interval,
7809                ..
7810            } => Some((
7811                projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
7812                from_anchor,
7813                kill_zone,
7814                salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
7815                interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
7816            )),
7817            _ => None,
7818        }
7819    }
7820
7821    /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
7822    /// (spec-0022), with the documented default already applied.
7823    pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
7824        match &self.verb {
7825            Verb::Collapse {
7826                region_anchor,
7827                falling_block,
7828                then_floor,
7829                ..
7830            } => Some((
7831                region_anchor,
7832                falling_block
7833                    .as_deref()
7834                    .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
7835                then_floor.as_deref(),
7836            )),
7837            _ => None,
7838        }
7839    }
7840
7841    /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
7842    /// [`QuestEffect::despawn_npc`]).
7843    pub fn spawn_npc(&self) -> Option<&NpcId> {
7844        match &self.verb {
7845            Verb::SpawnNpc { npc, .. } => Some(npc),
7846            _ => None,
7847        }
7848    }
7849
7850    /// **The body this effect puts into the world**, by id, for every body class
7851    /// alike ([`BodyRef`]).
7852    ///
7853    /// `spawn-npc` and `spawn-actor` are the two, and they are answered in one
7854    /// place so a rule about a body's lifetime quantifies over bodies rather
7855    /// than over the verb that first needed it. A body's OTHER entry — standing
7856    /// on its mark from world init — is not an effect at all and is
7857    /// [`BodyRef::at_world_init`].
7858    ///
7859    /// `unleash-actor` is deliberately not an entry: it puts no new body on the
7860    /// mark, it replaces the one already standing there (see [`Self::body_exit`]).
7861    pub fn body_entry(&self) -> Option<&str> {
7862        match &self.verb {
7863            Verb::SpawnNpc { npc, .. } => Some(npc.as_str()),
7864            Verb::SpawnActor { actor, .. } => Some(actor.as_str()),
7865            _ => None,
7866        }
7867    }
7868
7869    /// **The body this effect takes out of the world**, by id, for every body
7870    /// class alike.
7871    ///
7872    /// `despawn-npc` and `despawn-actor` remove the body outright.
7873    /// `unleash-actor` is the third: it kills the staged puppet and stands a
7874    /// real-AI twin in its place, and from that moment the compiler makes no
7875    /// claim about where that body is or whether it is still alive — the twin
7876    /// walks, fights and dies under vanilla AI. Answering all three here is what
7877    /// keeps "can this body still be standing?" from being decided one verb at a
7878    /// time.
7879    ///
7880    /// Deliberately NOT an exit: `move-npc` / `move-actor`. A walked body is
7881    /// still in the world, and its declared mark is still the cell the engine
7882    /// summoned it onto — a mark two live bodies share is shared whether or not
7883    /// one of them has since walked off it.
7884    pub fn body_exit(&self) -> Option<&str> {
7885        match &self.verb {
7886            Verb::DespawnNpc { npc, .. } => Some(npc.as_str()),
7887            Verb::DespawnActor { actor, .. } | Verb::UnleashActor { actor, .. } => {
7888                Some(actor.as_str())
7889            }
7890            _ => None,
7891        }
7892    }
7893
7894    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
7895    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
7896        match &self.verb {
7897            Verb::SetCheckpoint { anchor, on_respawn } => Some((anchor, on_respawn.as_slice())),
7898            _ => None,
7899        }
7900    }
7901
7902    /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
7903    pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
7904        match &self.verb {
7905            Verb::Bonfire {
7906                anchor, on_rest, ..
7907            } => Some((anchor, on_rest.as_slice())),
7908            _ => None,
7909        }
7910    }
7911
7912    /// The bonfire's authored rest-dialog strings, each `None` when unauthored
7913    /// (the compiler then bakes its canonical English). `None` for every other
7914    /// effect (spec-0016 §1).
7915    pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
7916        match &self.verb {
7917            Verb::Bonfire {
7918                prompt,
7919                rest_label,
7920                save_label,
7921                rest_tooltip,
7922                save_tooltip,
7923                ..
7924            } => Some(BonfireLabels {
7925                prompt: prompt.as_deref(),
7926                rest_label: rest_label.as_deref(),
7927                save_label: save_label.as_deref(),
7928                rest_tooltip: rest_tooltip.as_deref(),
7929                save_tooltip: save_tooltip.as_deref(),
7930            }),
7931            _ => None,
7932        }
7933    }
7934
7935    /// The `within` filter zone if this is a v0.6 `damage-players` effect that
7936    /// declares one (the `in` spatial scope). `None` for an unscoped
7937    /// `damage-players` and for every other effect.
7938    pub fn damage_within(&self) -> Option<&StealthZone> {
7939        match &self.verb {
7940            Verb::DamagePlayers { .. } => self.within.as_ref(),
7941            _ => None,
7942        }
7943    }
7944
7945    /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
7946    pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
7947        match &self.verb {
7948            Verb::BeginStealth {
7949                zones,
7950                on_caught,
7951                grace_ticks,
7952            } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
7953            _ => None,
7954        }
7955    }
7956
7957    /// The target time if this is a v0.5 `set-time` effect.
7958    pub fn set_time(&self) -> Option<WorldTime> {
7959        match &self.verb {
7960            Verb::SetTime { time, .. } => Some(*time),
7961            _ => None,
7962        }
7963    }
7964
7965    /// The target weather if this is a v0.5 `set-weather` effect.
7966    pub fn set_weather(&self) -> Option<WorldWeather> {
7967        match &self.verb {
7968            Verb::SetWeather { weather, .. } => Some(*weather),
7969            _ => None,
7970        }
7971    }
7972
7973    /// The effect lists nested one level inside this effect (DSL v0.6): a
7974    /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
7975    /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
7976    /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
7977    ///
7978    /// This is the **single authority** on effect nesting. Every deep traversal —
7979    /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
7980    /// string inventory, and emission — walks the tree through it (see
7981    /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
7982    /// once and no walker can silently miss a list (the class of bug where a
7983    /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
7984    pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
7985        match &self.verb {
7986            Verb::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
7987            Verb::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
7988            Verb::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
7989            Verb::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
7990            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
7991                vec![on_arrive.as_slice()]
7992            }
7993            _ => Vec::new(),
7994        }
7995    }
7996
7997    /// Visit `self` and every transitively nested effect (depth-first, pre-order),
7998    /// descending through [`Self::nested_effect_lists`].
7999    pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
8000        f(self);
8001        for list in self.nested_effect_lists() {
8002            for e in list {
8003                e.visit_deep(f);
8004            }
8005        }
8006    }
8007
8008    /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
8009    /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
8010    /// exposed mutably so the localization pass can rewrite nested player-visible
8011    /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
8012    /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
8013    /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
8014    /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
8015    /// derived segments make every derived key deterministic and stable across
8016    /// builds (ADR-0006 byte-identity).
8017    pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
8018        match &mut self.verb {
8019            Verb::Sequence { steps } => steps
8020                .iter_mut()
8021                .enumerate()
8022                .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
8023                .collect(),
8024            Verb::SetCheckpoint { on_respawn, .. } => {
8025                vec![("respawn".to_string(), on_respawn.as_mut_slice())]
8026            }
8027            Verb::Bonfire { on_rest, .. } => {
8028                vec![("rest".to_string(), on_rest.as_mut_slice())]
8029            }
8030            Verb::BeginStealth { on_caught, .. } => {
8031                vec![("caught".to_string(), on_caught.as_mut_slice())]
8032            }
8033            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
8034                vec![("arrive".to_string(), on_arrive.as_mut_slice())]
8035            }
8036            _ => Vec::new(),
8037        }
8038    }
8039
8040    /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
8041    /// exposes the **JSON-pointer path segment** for each nested list, so a deep
8042    /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
8043    /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
8044    /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
8045    /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
8046    /// (same lists, same order): the l10n key segments match
8047    /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
8048    /// `arrive`), and the path segments name the real fields
8049    /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
8050    pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
8051        match &self.verb {
8052            Verb::Sequence { steps } => steps
8053                .iter()
8054                .enumerate()
8055                .map(|(s, st)| {
8056                    (
8057                        format!("steps/{s}/effects"),
8058                        format!("seq.{s}"),
8059                        st.effects.as_slice(),
8060                    )
8061                })
8062                .collect(),
8063            Verb::SetCheckpoint { on_respawn, .. } => vec![(
8064                "on_respawn".to_string(),
8065                "respawn".to_string(),
8066                on_respawn.as_slice(),
8067            )],
8068            Verb::Bonfire { on_rest, .. } => vec![(
8069                "on_rest".to_string(),
8070                "rest".to_string(),
8071                on_rest.as_slice(),
8072            )],
8073            Verb::BeginStealth { on_caught, .. } => vec![(
8074                "on_caught".to_string(),
8075                "caught".to_string(),
8076                on_caught.as_slice(),
8077            )],
8078            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
8079                vec![(
8080                    "on_arrive".to_string(),
8081                    "arrive".to_string(),
8082                    on_arrive.as_slice(),
8083                )]
8084            }
8085            _ => Vec::new(),
8086        }
8087    }
8088
8089    /// Every world anchor this effect names **at this node** — the single
8090    /// authority on the anchor-bearing effect surface, the referential sibling of
8091    /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
8092    /// where the suffix is appended to the effect's own JSON pointer
8093    /// (`anchor`, `to/anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
8094    /// `shots/<i>/path/<j>/anchor`, …).
8095    ///
8096    /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
8097    /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
8098    /// reference scan, the compiler's build-time resolution seal (`DW0360`) —
8099    /// goes through here, so a new anchor-bearing variant (or a new anchor field
8100    /// on an existing one) is picked up by all of them at once. That closes the
8101    /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
8102    /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
8103    /// found no match) while every shallow validator looked straight past it.
8104    ///
8105    /// # The third element is the SHAPE the site demands
8106    ///
8107    /// A reference is not only a name: `close-gate` fills and clears a region and
8108    /// `bonfire` seats a body, and until spec-0052 those two sat in one match arm
8109    /// with nothing recording the difference. The demand rides with the reference
8110    /// so that a new anchor-bearing variant cannot be added without stating what
8111    /// it does with the anchor — the same reason this is one authority and not
8112    /// three walks.
8113    ///
8114    /// **A demand is stated only where the shape is structurally required**, and
8115    /// the line is drawn where this engine's own `DW0845` already draws it — *a
8116    /// region is not a place to stand*:
8117    ///
8118    /// * [`StationKind::Gate`] where the verb cannot function without a region
8119    ///   that seals and clears: the two gate verbs. A point is not a gate, and
8120    ///   `gate_region_block_any` finds nothing for one.
8121    /// * [`StationKind::Point`] where a **body is put**: a checkpoint seat, a
8122    ///   bonfire, a `move-npc`/`move-actor` destination, a `teleport`
8123    ///   destination. A body cannot stand inside bars.
8124    /// * `None` wherever a reference merely names a **location** — every
8125    ///   anchor-centred volume's centre, every camera field, a `set-block`, a
8126    ///   `play-sound`. A gate region's own corner is a perfectly good answer
8127    ///   there, and refusing it would refuse correct content.
8128    ///
8129    /// That last case is not a gap left for later. An earlier draft of this rule
8130    /// demanded a point at every non-gate site, and the gallery refused to
8131    /// compile: `trigger/east-door-wrong-side` is a `use` trigger sitting on the
8132    /// very `anchor/seam-…` of a barred door so that it can say "the east door
8133    /// does not open from this side". A check that resolves against a smaller
8134    /// world than the campaign has refuses CONTENT, which is the lesson `DW0343`
8135    /// carries three files away.
8136    pub fn anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
8137        // The envelope's `in` box (spec-0085 §3.2) is one capability of every
8138        // player-facing verb, so it registers once, here, before the verb's own.
8139        let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = Vec::new();
8140        if let Some(zone) = &self.within {
8141            out.push(("in/anchor".to_string(), &zone.anchor, None));
8142        }
8143        out.extend(self.verb_anchor_refs());
8144        out
8145    }
8146
8147    /// The anchors the VERB names at this node — [`Self::anchor_refs`] without the
8148    /// envelope.
8149    fn verb_anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
8150        // Every camera field is a point: a shot flies through cells and looks at
8151        // one.
8152        /// `(suffix, anchor, kind)` for a shot's own anchor-bearing fields, under `base`.
8153        fn shot_refs<'a>(
8154            base: &str,
8155            shot: &'a CameraShot,
8156        ) -> Vec<(String, &'a AnchorId, Option<StationKind>)> {
8157            let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shot
8158                .path
8159                .iter()
8160                .enumerate()
8161                .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor, None))
8162                .collect();
8163            if let Some(t) = &shot.look_at {
8164                out.push((format!("{base}look_at/anchor"), &t.anchor, None));
8165            }
8166            for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
8167                if let Some(CameraSubject::Anchor(s)) = subject {
8168                    out.push((format!("{base}{field}/anchor"), &s.anchor, None));
8169                }
8170            }
8171            out
8172        }
8173        match &self.verb {
8174            // The two gate verbs address a REGION that seals and clears; the
8175            // three below them seat or write at a cell. They shared an arm until
8176            // the demand had somewhere to be written down.
8177            Verb::OpenGate { anchor, .. } | Verb::CloseGate { anchor, .. } => {
8178                vec![("anchor".to_string(), anchor, Some(StationKind::Gate))]
8179            }
8180            // A checkpoint and a bonfire are **respawn seats** — a body is put
8181            // there, so a region is not one. `set-block` writes a block at a
8182            // cell, which names a location and seats nothing.
8183            Verb::SetCheckpoint { anchor, .. } | Verb::Bonfire { anchor, .. } => {
8184                vec![("anchor".to_string(), anchor, Some(StationKind::Point))]
8185            }
8186            Verb::SetBlock { anchor, .. } => {
8187                vec![("anchor".to_string(), anchor, None)]
8188            }
8189            Verb::MoveNpc { to, .. } | Verb::MoveActor { to, .. } => {
8190                vec![(
8191                    "to/anchor".to_string(),
8192                    &to.anchor,
8193                    Some(StationKind::Point),
8194                )]
8195            }
8196            // Both of a `teleport`'s anchors are load-bearing — the source volume
8197            // decides WHAT moves and the destination decides WHERE — so a typo in
8198            // either is a dangling reference (`DW0142`), never a silently
8199            // zero-cell volume or a dropped command.
8200            Verb::Teleport { from, to, .. } => vec![
8201                ("from/anchor".to_string(), &from.anchor, None),
8202                (
8203                    "to/anchor".to_string(),
8204                    &to.anchor,
8205                    Some(StationKind::Point),
8206                ),
8207            ],
8208            Verb::BeginStealth { zones, .. } => zones
8209                .iter()
8210                .enumerate()
8211                .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor, None))
8212                .collect(),
8213            Verb::PlaySound {
8214                at: Some(SoundAt::Anchor { anchor, .. }),
8215                ..
8216            } => vec![("at/anchor".to_string(), anchor, None)],
8217            // A firework is launched from a point and seats nothing, so it names
8218            // a location in the same shape `play-sound` does.
8219            Verb::Firework { at, .. } => vec![("at/anchor".to_string(), &at.anchor, None)],
8220            // A bolt strikes a point and seats nothing, the same shape.
8221            Verb::Lightning { at } => vec![("at/anchor".to_string(), &at.anchor, None)],
8222            // A particle at a mark names a location the same way; at `players`
8223            // it names none.
8224            Verb::Particle {
8225                at: ParticleAt::Mark(m),
8226                ..
8227            } => vec![("at/anchor".to_string(), &m.anchor, None)],
8228            // spec-0022 trap-payload verbs. Both anchors of a `volley` are
8229            // load-bearing for the coverage proof, so both register here — a
8230            // typo'd `kill_zone` must be a dangling-reference error, never a
8231            // silently zero-cell (and therefore vacuously "covered") volley.
8232            Verb::Volley {
8233                from_anchor,
8234                kill_zone,
8235                ..
8236            } => vec![
8237                ("from_anchor".to_string(), from_anchor, None),
8238                ("kill_zone/anchor".to_string(), &kill_zone.anchor, None),
8239            ],
8240            Verb::Collapse { region_anchor, .. } => {
8241                vec![(
8242                    "region_anchor/anchor".to_string(),
8243                    &region_anchor.anchor,
8244                    None,
8245                )]
8246            }
8247            // The v0.10 region writes: the box's anchor is load-bearing for both
8248            // the emission and the completability model, so a typo'd one must be a
8249            // dangling-reference error (`DW0142`/`DW0360`), never a silently
8250            // unwritten — and therefore vacuously proven — region.
8251            Verb::FillRegion { region, .. } | Verb::ClearRegion { region, .. } => {
8252                vec![("region/anchor".to_string(), &region.anchor, None)]
8253            }
8254            // A repaint's box centre names a location, exactly as a region
8255            // write's does.
8256            Verb::SetAtmosphere {
8257                region: Some(region),
8258                ..
8259            } => vec![("region/anchor".to_string(), &region.anchor, None)],
8260            // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
8261            // multi-shot list, or the v0.4 single-shot fields flattened at the
8262            // effect's own level.
8263            Verb::Cutscene {
8264                shots,
8265                path,
8266                look_at,
8267                ..
8268            } => {
8269                let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shots
8270                    .iter()
8271                    .enumerate()
8272                    .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
8273                    .collect();
8274                out.extend(
8275                    path.iter()
8276                        .enumerate()
8277                        .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor, None)),
8278                );
8279                if let Some(t) = look_at {
8280                    out.push(("look_at/anchor".to_string(), &t.anchor, None));
8281                }
8282                out
8283            }
8284            _ => Vec::new(),
8285        }
8286    }
8287
8288    /// **Every object of a subject kind this effect names at this node** — the
8289    /// `npc/`, `actor/`, `wave/` and `anchor/` ids [`Happening::subject`] may
8290    /// name, in a fixed order, deduplicated (spec-0071 §3).
8291    ///
8292    /// The bodies come from [`Self::body_entry`] / [`Self::body_exit`] and the
8293    /// move verbs; the anchors come from [`Self::anchor_refs`], the single
8294    /// authority on the anchor-bearing surface, so a verb that gains an anchor
8295    /// gains it here too. Not recursive: a `sequence`'s steps each answer for
8296    /// themselves.
8297    ///
8298    /// This is a question about the **object class** a beat can be about, not
8299    /// about a list of verbs somebody maintains — which is why `unleash-actor`
8300    /// (one actor), `set-block` (one anchor) and `fill-region` (one anchor)
8301    /// answer it without being named anywhere, and why `move-actor` (an actor
8302    /// AND a destination anchor) and `teleport` (two anchors) answer with two.
8303    pub fn subject_objects(&self) -> Vec<&str> {
8304        fn add<'a>(id: &'a str, out: &mut Vec<&'a str>) {
8305            if !out.contains(&id) {
8306                out.push(id);
8307            }
8308        }
8309        let mut out: Vec<&str> = Vec::new();
8310        match &self.verb {
8311            Verb::SpawnNpc { npc, .. }
8312            | Verb::DespawnNpc { npc, .. }
8313            | Verb::MoveNpc { npc, .. } => add(npc.as_str(), &mut out),
8314            Verb::SpawnWave { wave, .. } => add(wave.as_str(), &mut out),
8315            _ => {}
8316        }
8317        if let Some(actor) = self.actor_ref() {
8318            add(actor.as_str(), &mut out);
8319        }
8320        for (_, anchor, _) in self.anchor_refs() {
8321            add(anchor.as_str(), &mut out);
8322        }
8323        out
8324    }
8325
8326    /// **What this effect's `happening` is about**: the subject the branch
8327    /// chronicle records and the contradiction proof (`DW0485`) reasons over
8328    /// (spec-0071 §3).
8329    ///
8330    /// A stated [`Happening::subject`] always wins — the caller knows more. An
8331    /// absent one resolves to the effect's own object when it has exactly one
8332    /// ([`Self::subject_objects`]), because the beat that opens a gate is about
8333    /// that gate and the id is otherwise typed twice, two keys apart. An effect
8334    /// with several objects, or none, resolves nothing: naming one of them would
8335    /// be the compiler guessing which.
8336    ///
8337    /// **The one derivation.** The namespace check (`dsl::validate`) and the
8338    /// chronicle writer (`delvec::compiler::branch`) both read the subject
8339    /// through here, so a beat cannot be about one thing for the proof and
8340    /// another for the account a reader is handed.
8341    pub fn happening_subject(&self) -> Option<HappeningSubject<'_>> {
8342        let h = self.happening.as_ref()?;
8343        if let Some(stated) = h.subject.as_deref() {
8344            return Some(HappeningSubject {
8345                id: stated,
8346                derived: false,
8347            });
8348        }
8349        let objects = self.subject_objects();
8350        match objects.as_slice() {
8351            [only] => Some(HappeningSubject {
8352                id: only,
8353                derived: true,
8354            }),
8355            _ => None,
8356        }
8357    }
8358
8359    /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
8360    /// the v0.6 `look_at` field.
8361    pub fn cutscene_look_at(&self) -> Option<&Mark> {
8362        match &self.verb {
8363            Verb::Cutscene { look_at, .. } => look_at.as_ref(),
8364            _ => None,
8365        }
8366    }
8367
8368    /// Whether this `cutscene` shows the party's bodies (spec-0095): the stated
8369    /// `party`, or `present` when none is stated. `None` for any other effect.
8370    pub fn cutscene_party(&self) -> Option<CutsceneParty> {
8371        match &self.verb {
8372            Verb::Cutscene { party, .. } => Some(party.unwrap_or_default()),
8373            _ => None,
8374        }
8375    }
8376
8377    /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
8378    ///.
8379    pub fn cutscene_multi_shot(&self) -> bool {
8380        matches!(&self.verb, Verb::Cutscene { shots, .. } if !shots.is_empty())
8381    }
8382
8383    /// The normalized shot list of a `cutscene`, whichever spelling was used: the
8384    /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
8385    /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
8386    /// whose shape is invalid (`DW0199` reports that).
8387    pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
8388        match &self.verb {
8389            Verb::Cutscene {
8390                shots,
8391                path,
8392                seconds,
8393                look_at,
8394                ..
8395            } => {
8396                if !shots.is_empty() {
8397                    return Some(shots.clone());
8398                }
8399                match seconds {
8400                    Some(secs) => Some(vec![CameraShot {
8401                        path: path.clone(),
8402                        seconds: Some(*secs),
8403                        look_at: look_at.clone(),
8404                        shot_style: None,
8405                        subject: None,
8406                        subject_b: None,
8407                        dist: None,
8408                        degrees: None,
8409                        bearing: None,
8410                    }]),
8411                    None => Some(Vec::new()),
8412                }
8413            }
8414            _ => None,
8415        }
8416    }
8417
8418    /// `true` if this is a `narrate` carrying the `art` style (glyph-checked
8419    /// `DW0328`).
8420    pub fn narrate_art(&self) -> bool {
8421        matches!(
8422            &self.verb,
8423            Verb::Narrate {
8424                style: Some(NarrateStyle::Art),
8425                ..
8426            }
8427        )
8428    }
8429
8430    /// The `narrate` line's text if this is a `narrate` with the `art` style.
8431    pub fn narrate_art_text(&self) -> Option<&str> {
8432        match &self.verb {
8433            Verb::Narrate {
8434                text,
8435                style: Some(NarrateStyle::Art),
8436                ..
8437            } => Some(text.as_str()),
8438            _ => None,
8439        }
8440    }
8441
8442    /// The `narrate` line's style and text if this is a `narrate` rendered **on
8443    /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
8444    /// styles vanilla draws centred and unwrapped, so their rendered width is
8445    /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
8446    /// is exempt.
8447    pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
8448        match &self.verb {
8449            Verb::Narrate {
8450                text,
8451                style: Some(s),
8452                ..
8453            } if matches!(
8454                s,
8455                NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
8456            ) =>
8457            {
8458                Some((*s, text.as_str()))
8459            }
8460            _ => None,
8461        }
8462    }
8463
8464    /// Every vanilla sound-event id this effect references, for registry
8465    /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
8466    /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
8467    /// within the effect (e.g. `sound`).
8468    pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
8469        match &self.verb {
8470            Verb::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
8471            Verb::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
8472            _ => Vec::new(),
8473        }
8474    }
8475
8476    /// The `play-sound` `at: actor` id, if this effect is a `play-sound`
8477    /// targeting an actor (rejected `DW0335`).
8478    pub fn play_sound_actor(&self) -> Option<&str> {
8479        match &self.verb {
8480            Verb::PlaySound {
8481                at: Some(SoundAt::Actor { actor }),
8482                ..
8483            } => Some(actor.as_str()),
8484            _ => None,
8485        }
8486    }
8487
8488    /// The per-effect flag gate: flags that must ALL be set (per party) for this
8489    /// effect to fire. Empty for an ungated effect. Read off the one [`Guard`], so
8490    /// **every** verb answers it — the staging and souls vocabulary included.
8491    pub fn requires_flags(&self) -> &[FlagId] {
8492        self.when.as_ref().map_or(&[][..], |g| &g.requires_flags)
8493    }
8494
8495    /// The per-effect **negative** flag gate: flags whose being set suppresses this
8496    /// effect — the dual of [`QuestEffect::requires_flags`], on the same guard.
8497    pub fn forbids_flags(&self) -> &[FlagId] {
8498        self.when.as_ref().map_or(&[][..], |g| &g.forbids_flags)
8499    }
8500
8501    /// The numeric gate terms (spec-0031) — the third axis of the same guard, so
8502    /// "which verbs are gatable" has exactly one answer.
8503    pub fn requires_state(&self) -> &[StateCompare] {
8504        self.when.as_ref().map_or(&[][..], |g| &g.requires_state)
8505    }
8506
8507    /// The datum this effect writes and how, if it is one of the DSL v0.10 state
8508    /// verbs (`set-state` / `add-state` / `clear-state`).
8509    pub fn writes_state(&self) -> Option<(&StateId, StateWrite)> {
8510        match &self.verb {
8511            Verb::SetState { state, value, .. } => Some((state, StateWrite::Set(*value))),
8512            Verb::AddState { state, amount, .. } => Some((state, StateWrite::Add(*amount))),
8513            Verb::ClearState { state, .. } => Some((state, StateWrite::Clear)),
8514            _ => None,
8515        }
8516    }
8517
8518    /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
8519    /// parity with `move-actor`). `None` for a bare `move-npc` and every other
8520    /// effect.
8521    pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
8522        match &self.verb {
8523            Verb::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => Some(on_arrive.as_slice()),
8524            _ => None,
8525        }
8526    }
8527
8528    /// The actor id this effect targets, if it is one of the actor staging effects
8529    /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
8530    /// no single actor (its nested effects each carry their own).
8531    pub fn actor_ref(&self) -> Option<&ActorId> {
8532        match &self.verb {
8533            Verb::SpawnActor { actor, .. }
8534            | Verb::DespawnActor { actor, .. }
8535            | Verb::MoveActor { actor, .. }
8536            | Verb::UnleashActor { actor, .. } => Some(actor),
8537            _ => None,
8538        }
8539    }
8540
8541    /// Every vanilla **status-effect** id this effect names, for registry
8542    /// validation (`DW0192`) — the sibling of [`Self::sound_refs`] and the single
8543    /// authority on the status-effect-bearing verb surface. `(subpath, id)`
8544    /// pairs; empty for a `clear-effect` that names none (which clears all).
8545    pub fn status_effect_refs(&self) -> Vec<(&'static str, &str)> {
8546        match &self.verb {
8547            Verb::GiveEffect { effect, .. } => vec![("effect", effect.as_str())],
8548            Verb::ClearEffect {
8549                effect: Some(e), ..
8550            } => vec![("effect", e.as_str())],
8551            _ => Vec::new(),
8552        }
8553    }
8554
8555    /// `(effect, seconds, amplifier, hide_particles, in)` if this is a
8556    /// `give-effect` (DSL v0.10), with the documented defaults already applied.
8557    pub fn give_effect(&self) -> Option<(&str, u32, u32, bool, Option<&StealthZone>)> {
8558        match &self.verb {
8559            Verb::GiveEffect {
8560                effect,
8561                seconds,
8562                amplifier,
8563                hide_particles,
8564                ..
8565            } => Some((
8566                effect.as_str(),
8567                *seconds,
8568                amplifier.unwrap_or(0),
8569                hide_particles.unwrap_or(false),
8570                self.within.as_ref(),
8571            )),
8572            _ => None,
8573        }
8574    }
8575
8576    /// `(effect, in)` if this is a `clear-effect` (DSL v0.10). The effect is
8577    /// `None` for the clear-everything form, exactly as vanilla spells it.
8578    pub fn clear_effect(&self) -> Option<(Option<&str>, Option<&StealthZone>)> {
8579        match &self.verb {
8580            Verb::ClearEffect { effect, .. } => Some((effect.as_deref(), self.within.as_ref())),
8581            _ => None,
8582        }
8583    }
8584
8585    /// `(from, to)` if this is a `teleport` (DSL v0.10): the source volume and
8586    /// the destination mark.
8587    pub fn teleport(&self) -> Option<(&StealthZone, &Mark)> {
8588        match &self.verb {
8589            Verb::Teleport { from, to, .. } => Some((from, to)),
8590            _ => None,
8591        }
8592    }
8593}
8594
8595// ---------------------------------------------------------------------------
8596// Stage 7 — world-edits (the map editor edit script, DSL v0.6, spec-0017)
8597// ---------------------------------------------------------------------------
8598
8599/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
8600///
8601/// The artifact of record for L3 world detailing: an ordered list of edit
8602/// batches the compiler replays deterministically **after** world assembly.
8603/// The world files are never truth — same DSL + same edits + same seed →
8604/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
8605/// builds byte-identically to one from before this stage existed.
8606#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8607#[serde(deny_unknown_fields)]
8608pub struct WorldEditsContent {
8609    /// Ordered edit batches, replayed in order. After every batch the post-edit
8610    /// invariants re-prove (walkability, sealing + relight, boundary safety),
8611    /// so each batch is a valid, snapshot-reviewable world state.
8612    pub batches: Vec<EditBatch>,
8613}
8614
8615/// One edit batch: an ordered group of edit verbs applied to a single area,
8616/// checked and snapshot-rendered as a unit.
8617#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8618#[serde(deny_unknown_fields)]
8619pub struct EditBatch {
8620    /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
8621    /// snapshot name and seed-stream label — renaming a batch deliberately
8622    /// reseeds its noise.
8623    pub id: EditBatchId,
8624    /// The stage-1 area this batch edits. Frames and regions resolve against
8625    /// this area's placed pieces and anchors.
8626    pub area: AreaId,
8627    /// Authoring context (why this batch exists). Machine-ignored; **excluded**
8628    /// from l10n like `theme`/`premise`.
8629    #[serde(default, skip_serializing_if = "Option::is_none")]
8630    pub note: Option<String>,
8631    /// Ordered edit verbs. `select` defines named regions; later verbs in the
8632    /// same batch refer back to them (strictly backward, like every DSL ref).
8633    pub edits: Vec<WorldEdit>,
8634}
8635
8636/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
8637/// seeded verb derives its noise stream from the campaign seed + its script
8638/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
8639#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8640#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
8641pub enum WorldEdit {
8642    /// Define a named region (`region/<kebab>`) for later verbs in this batch.
8643    Select {
8644        /// The region name being defined (unique within the batch).
8645        name: RegionId,
8646        /// The region's shape (box, surface band, palette match, or a
8647        /// composition of earlier regions).
8648        shape: RegionShape,
8649    },
8650    /// Fill every cell of a region from a seeded palette recipe (value-noise
8651    /// keyed, so picks cluster into patches — never a uniform fill).
8652    Fill {
8653        /// The region (an earlier `select` in this batch) to fill.
8654        region: RegionId,
8655        /// The palette recipe to fill with.
8656        recipe: PaletteRecipe,
8657    },
8658    /// Like `fill`, but only rewrites cells whose current block matches one of
8659    /// `matching` (base ids; blockstate suffixes are ignored when matching).
8660    Replace {
8661        /// The region (an earlier `select` in this batch) to edit.
8662        region: RegionId,
8663        /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
8664        matching: Vec<String>,
8665        /// The palette recipe to rewrite them with.
8666        recipe: PaletteRecipe,
8667    },
8668    /// Clear a region to air. Sealing-aware: the carved region re-enters the
8669    /// sealing + relight passes and every walkability invariant re-proves.
8670    Carve {
8671        /// The region (an earlier `select` in this batch) to clear.
8672        region: RegionId,
8673    },
8674    /// Reshape terrain surface within a region (raise / lower / smooth).
8675    Morph {
8676        /// The region (an earlier `select` in this batch) whose columns to
8677        /// reshape. The region defines the footprint and where each column's
8678        /// surface is read (the highest occupied cell in the region's y-range);
8679        /// `raise`/`smooth` may add cells above the region's top — reshaping
8680        /// upward is the point — while removal only touches region cells.
8681        region: RegionId,
8682        /// The surface operation.
8683        op: MorphOp,
8684    },
8685    /// Seeded dressing scatter (spec-0017): drop weighted single-block
8686    /// dressing (flora, rocks, props) onto standable cells of a region —
8687    /// air cells with an occupied cell directly below — honoring keep-clear
8688    /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
8689    /// speckle, not the fill verbs' clustered patches), deterministic from the
8690    /// campaign seed + script position.
8691    Scatter {
8692        /// The region (an earlier `select` in this batch) to dress.
8693        region: RegionId,
8694        /// Weighted dressing blocks (blockstate suffixes allowed).
8695        items: Vec<PaletteBlock>,
8696        /// Per-candidate placement probability in `(0, 1]`.
8697        density: f64,
8698        /// Keep-clear envelopes: earlier regions whose cells (and columns —
8699        /// matched by `(x, z)`) never receive dressing.
8700        #[serde(default, skip_serializing_if = "Vec::is_empty")]
8701        avoid: Vec<RegionId>,
8702        /// Optional minimum spacing: when set, candidates are taken in
8703        /// descending noise order and one is rejected while another accepted
8704        /// candidate is closer than this on **both** horizontal axes (the
8705        /// generators' spread rule).
8706        #[serde(default, skip_serializing_if = "Option::is_none")]
8707        spacing: Option<u32>,
8708        /// Optional cap on how many items are placed (highest-noise first).
8709        #[serde(default, skip_serializing_if = "Option::is_none")]
8710        limit: Option<u32>,
8711    },
8712    /// Structural flora (spec-0017): plant hand-shaped trees via the
8713    /// lean-or-grow canopy rules — a canopy that would reach a
8714    /// keep-clear (`avoid`) column first leans one block away from it; if that
8715    /// still covers the corridor the tree grows tall instead, arching its
8716    /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
8717    Plant {
8718        /// The region (an earlier `select` in this batch) to plant in. Trunk
8719        /// cells are standable region cells (air over an occupied cell).
8720        region: RegionId,
8721        /// The tree species (canopy shape rules are per-species).
8722        tree: TreeKind,
8723        /// How many trees to plant (≥ 1; highest-noise candidates first).
8724        count: u32,
8725        /// Keep-clear envelopes: trunks never stand in these columns and
8726        /// canopies lean/grow to clear them.
8727        #[serde(default, skip_serializing_if = "Vec::is_empty")]
8728        avoid: Vec<RegionId>,
8729        /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
8730        #[serde(default, skip_serializing_if = "Option::is_none")]
8731        spacing: Option<u32>,
8732    },
8733    /// Stamp a prefab fragment (spec-0017): copy a library prefab's
8734    /// non-air cells into the world at a frame-resolved position. The fragment
8735    /// is a first-class library prefab — its provenance/license metadata is
8736    /// recorded and validated exactly like any placed prefab (ADR-0013);
8737    /// nothing outside the library can be stamped.
8738    Fragment {
8739        /// The library prefab to stamp.
8740        prefab: PrefabId,
8741        /// The frame `at` resolves in.
8742        frame: EditFrame,
8743        /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
8744        at: [i32; 3],
8745        /// Placement rotation (default `none`), the same quarter-turn set as
8746        /// `/place template`.
8747        #[serde(default, skip_serializing_if = "Option::is_none")]
8748        rotation: Option<FragmentRotation>,
8749    },
8750    /// Explicit region relight (spec-0017, spec-0010 machinery): run the
8751    /// deterministic fixture-placement pass over ONE region and bake the
8752    /// resulting fixtures into the edit script's writes — authorial control of
8753    /// where fixtures land, instead of the whole-area pass's greedy siting.
8754    /// (The whole-area relight still re-proves after every batch either way.)
8755    Relight {
8756        /// The region (an earlier `select` in this batch) to relight: its
8757        /// reachable walkable cells are brought to `min_light`.
8758        region: RegionId,
8759        /// Fixture override; default = the area's declared `lighting.fixture`.
8760        #[serde(default, skip_serializing_if = "Option::is_none")]
8761        fixture: Option<Fixture>,
8762        /// Target light override (1..=14); default = the area's declared
8763        /// `lighting.min_light`. Required (with `fixture`) when the area
8764        /// declares no `lighting`.
8765        #[serde(default, skip_serializing_if = "Option::is_none")]
8766        min_light: Option<u8>,
8767    },
8768    /// L2 massing (spec-0017): replace a placed piece with another
8769    /// library prefab that re-mates every currently-mated socket at its exact
8770    /// world pose (any rotation; overlap-checked). Applied at **plan** time —
8771    /// the whole downstream ladder (anchors, gate reachability, assembly,
8772    /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
8773    /// verbs live in massing-only batches, ordered before every detailing
8774    /// batch.
8775    SwapPiece {
8776        /// The piece's placement index in its area's solved layout (0-based,
8777        /// entry first).
8778        piece: u32,
8779        /// The prefab the indexed piece must currently be (drift guard).
8780        prefab: PrefabId,
8781        /// The library prefab to swap in.
8782        with: PrefabId,
8783    },
8784    /// L2 massing (spec-0017): attach a new piece at a specific **open**
8785    /// (unmated) socket of an existing piece — the targeted form of the
8786    /// solver's frontier attach. The socket opens (its seal becomes a
8787    /// passage); the new piece's other sockets seal.
8788    InsertPiece {
8789        /// The host piece's placement index.
8790        at_piece: u32,
8791        /// The prefab the host piece must currently be (drift guard).
8792        prefab: PrefabId,
8793        /// The host's connector index (prefab metadata `connectors` order).
8794        socket: u32,
8795        /// The library prefab to attach.
8796        insert: PrefabId,
8797    },
8798    /// L2 massing (spec-0017): remove a **leaf** piece (exactly one
8799    /// mated socket; never the entry piece). The neighbour's socket unmates
8800    /// and re-seals. Removal shifts later placement indices — order removals
8801    /// before other index-referencing massing verbs.
8802    RemovePiece {
8803        /// The piece's placement index.
8804        piece: u32,
8805        /// The prefab the indexed piece must currently be (drift guard).
8806        prefab: PrefabId,
8807    },
8808    /// L2 massing (spec-0017): override one socket's seal — `open`
8809    /// clears the opening to a passage, `sealed` walls it up — independent of
8810    /// its mated state (sealing a mated doorway makes a wall between joined
8811    /// pieces; opening an unmated exterior socket exposes the outside, which
8812    /// the boundary-safety proof then judges).
8813    RewireSocket {
8814        /// The piece's placement index.
8815        piece: u32,
8816        /// The prefab the indexed piece must currently be (drift guard).
8817        prefab: PrefabId,
8818        /// The connector index (prefab metadata `connectors` order).
8819        socket: u32,
8820        /// The socket's new state.
8821        state: SocketState,
8822    },
8823    /// L2 massing (spec-0017): re-pick this piece from its area pool's
8824    /// compatible members (weighted, seeded from the campaign seed + this
8825    /// verb's script position — moving the verb deliberately re-rolls). The
8826    /// current prefab is excluded, so a reseed always changes the piece or
8827    /// errors loudly.
8828    ReseedPiece {
8829        /// The piece's placement index.
8830        piece: u32,
8831        /// The prefab the indexed piece must currently be (drift guard).
8832        prefab: PrefabId,
8833    },
8834}
8835
8836/// A socket seal state for `rewire-socket` (spec-0017).
8837#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8838#[serde(rename_all = "kebab-case")]
8839pub enum SocketState {
8840    /// The opening is cleared to a passage.
8841    Open,
8842    /// The opening is walled up.
8843    Sealed,
8844}
8845
8846/// A tree species for the `plant` verb (spec-0017). One species per
8847/// canopy-rule implementation; the shipped rule set is the lean-or-grow oak.
8848#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8849#[serde(rename_all = "kebab-case")]
8850pub enum TreeKind {
8851    /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
8852    /// lean-or-grow corridor rules.
8853    Oak,
8854}
8855
8856/// A `fragment` stamp rotation (spec-0017) — the `/place template`
8857/// quarter-turn set.
8858#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8859#[serde(rename_all = "kebab-case")]
8860pub enum FragmentRotation {
8861    /// No rotation.
8862    None,
8863    /// 90° clockwise.
8864    Clockwise90,
8865    /// 180°.
8866    Clockwise180,
8867    /// 90° counterclockwise.
8868    Counterclockwise90,
8869}
8870
8871/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
8872/// frame; compositions combine earlier regions of the same batch.
8873#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8874#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8875pub enum RegionShape {
8876    /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
8877    Box {
8878        /// The coordinate frame `min`/`max` resolve in.
8879        frame: EditFrame,
8880        /// Inclusive minimum corner (frame coordinates).
8881        min: [i32; 3],
8882        /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
8883        max: [i32; 3],
8884    },
8885    /// The band of cells at `from..=to` blocks relative to each column's
8886    /// terrain surface (the highest non-air cell of the column) within an
8887    /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
8888    /// surface; `from: 0, to: 0` is the surface cells themselves; negative
8889    /// offsets reach below the surface.
8890    SurfaceBand {
8891        /// The earlier region whose columns are scanned.
8892        over: RegionId,
8893        /// Inclusive band start, relative to each column's surface y.
8894        from: i32,
8895        /// Inclusive band end (≥ `from`), relative to each column's surface y.
8896        to: i32,
8897    },
8898    /// The cells of an earlier region whose current block matches one of
8899    /// `blocks` (base ids; blockstate suffixes ignored when matching).
8900    PaletteMatch {
8901        /// The earlier region to filter.
8902        within: RegionId,
8903        /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
8904        blocks: Vec<String>,
8905    },
8906    /// The union of earlier regions.
8907    Union {
8908        /// The earlier regions to unite (≥ 2).
8909        of: Vec<RegionId>,
8910    },
8911    /// The intersection of earlier regions.
8912    Intersect {
8913        /// The earlier regions to intersect (≥ 2).
8914        of: Vec<RegionId>,
8915    },
8916    /// An earlier region minus other earlier regions.
8917    Subtract {
8918        /// The earlier region to start from.
8919        base: RegionId,
8920        /// The earlier regions to remove from it (≥ 1).
8921        remove: Vec<RegionId>,
8922    },
8923}
8924
8925/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
8926/// piece-local or anchor-relative — never raw world coordinates, so an edit
8927/// script survives a layout's world placement moving.
8928#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8929#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8930pub enum EditFrame {
8931    /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
8932    /// the piece's structure origin, axes as authored (the compiler applies
8933    /// the piece's placed rotation).
8934    PieceLocal {
8935        /// The piece's placement index in the area's solved layout (0-based,
8936        /// entry piece first — the order `delvec snapshot`'s manifest lists).
8937        piece: u32,
8938        /// The prefab the indexed piece must be (a drift guard: if a re-solve
8939        /// changed the layout, the mismatch is a loud compile error, never a
8940        /// silently misplaced edit).
8941        prefab: PrefabId,
8942    },
8943    /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
8944    /// anchor cell, axes world-aligned.
8945    AnchorRelative {
8946        /// The anchor (prefab metadata, resolved by the compiler).
8947        anchor: AnchorId,
8948    },
8949}
8950
8951/// A surface operation for the `morph` verb (spec-0017).
8952#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8953#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
8954pub enum MorphOp {
8955    /// Raise each column's surface by up to `by` blocks, drawing the added
8956    /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
8957    /// as natural strata, not an extruded slab).
8958    Raise {
8959        /// How many blocks to raise each column (≥ 1).
8960        by: u32,
8961        /// The palette recipe for the added cells.
8962        recipe: PaletteRecipe,
8963    },
8964    /// Lower each column's surface by up to `by` blocks (carving the topmost
8965    /// solid cells to air).
8966    Lower {
8967        /// How many blocks to lower each column (≥ 1).
8968        by: u32,
8969    },
8970    /// Relax each column's surface toward the mean of its cardinal neighbours
8971    /// (one block per pass), turning steps into slopes. Added cells draw from
8972    /// `recipe`; removed cells carve to air. Deterministic double-buffered
8973    /// passes in fixed scan order.
8974    Smooth {
8975        /// Relaxation passes (≥ 1).
8976        passes: u32,
8977        /// The palette recipe for cells a pass adds.
8978        recipe: PaletteRecipe,
8979    },
8980}
8981
8982/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
8983/// smooth value-noise sample, the island/cave generators' proven primitive —
8984/// picks cluster into strata/patches instead of per-cell speckle, and a
8985/// single-entry recipe is the degenerate (discouraged) uniform case.
8986#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8987#[serde(deny_unknown_fields)]
8988pub struct PaletteRecipe {
8989    /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
8990    pub blocks: Vec<PaletteBlock>,
8991    /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
8992    /// across). Larger = smaller patches. Must be finite and > 0.
8993    #[serde(default, skip_serializing_if = "Option::is_none")]
8994    pub scale: Option<f64>,
8995}
8996
8997/// One weighted entry of a [`PaletteRecipe`].
8998#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8999#[serde(deny_unknown_fields)]
9000pub struct PaletteBlock {
9001    /// Vanilla block id (validated against the pinned 1.21.11 registry), with
9002    /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
9003    pub block: String,
9004    /// Relative weight (finite, > 0).
9005    pub weight: f64,
9006}
9007
9008/// Where in the campaign one effect sits — the attribution every per-branch
9009/// proof and the branch chronicle need (spec-0025).
9010#[derive(Clone, Debug, PartialEq, Eq)]
9011pub enum EffectSite {
9012    /// A quest's `on_objective_complete[<objective>]` bundle.
9013    Objective {
9014        /// The owning quest.
9015        quest: String,
9016        /// The objective whose completion fires the bundle.
9017        objective: String,
9018    },
9019    /// A quest's `on_complete` bundle.
9020    QuestComplete {
9021        /// The owning quest.
9022        quest: String,
9023    },
9024    /// An environment trigger's `effects` bundle — ambient, no DAG position.
9025    Trigger {
9026        /// The trigger id.
9027        trigger: String,
9028    },
9029    /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
9030    Trap {
9031        /// The trap id.
9032        trap: String,
9033    },
9034    /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
9035    /// DAG position, and the only site that does not live in the quests stage.
9036    ///
9037    /// This variant did not exist until the effect-root sweep, and its absence was
9038    /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
9039    /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
9040    /// (`combat::actor_beats`, `validate::difficulty_checks`,
9041    /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
9042    /// even if their authors had thought of it. Widening the type is what let the
9043    /// walk widen.
9044    DialogueRespawn {
9045        /// The NPC whose dialogue tree hosts the option.
9046        npc: String,
9047        /// The node the option sits under.
9048        node: String,
9049    },
9050    /// A `shortcuts[].on_unlock` bundle (spec-0016 §2) — ambient, no DAG
9051    /// position, and the sixth root: representable here only since spec-0031, for
9052    /// exactly the reason [`EffectSite::DialogueRespawn`] records above.
9053    ShortcutUnlock {
9054        /// The shortcut id.
9055        shortcut: String,
9056    },
9057    /// A `shops[].offers[].effects` bundle (DSL v0.10, spec-0032) — ambient, no
9058    /// DAG position: nobody is forced to buy anything.
9059    ShopOffer {
9060        /// The shop id.
9061        shop: String,
9062        /// The offer's index within that shop, which is also its button order.
9063        offer: usize,
9064    },
9065    /// The campaign's `on_death` bundle (spec-0031) — ambient, no DAG position,
9066    /// and no owning object: there is one per campaign.
9067    OnDeath,
9068    /// A wave's or an actor's `on_kill` bundle (spec-0074) — ambient, no DAG
9069    /// position: nobody is forced to be credited with a kill.
9070    OnKill {
9071        /// The fight's id (`wave/<kebab>` or `actor/<kebab>`).
9072        fight: String,
9073    },
9074    /// An assembly strike step's `on_land` bundle (spec-0082) — ambient, no
9075    /// DAG position: nobody is forced to stand where a blow lands.
9076    AssemblyLand {
9077        /// The assembly id.
9078        assembly: String,
9079        /// The step's index within the pattern.
9080        step: usize,
9081    },
9082    /// A loop's `on_cross` bundle (spec-0086) — no DAG position of its own: it
9083    /// runs when a body crosses the holding slab.
9084    LoopCross {
9085        /// The loop id (`loop/<kebab>`).
9086        r#loop: String,
9087    },
9088}
9089
9090impl EffectSite {
9091    /// The quest this site belongs to, if it has a DAG position at all.
9092    ///
9093    /// **This `Option` is the capability, and it is on the enum rather than on the
9094    /// variants that happen to have a quest.** Only two of the eight sites name a
9095    /// quest, because only two of the eight roots HAVE a DAG position: an ambient
9096    /// root — a trigger, a trap payload, a dialogue `on_respawn`, a shortcut's
9097    /// `on_unlock`, the campaign's `on_death`, a shop offer — fires at a moment no
9098    /// static model can order, and inventing a quest for one would be exactly the
9099    /// over-attribution the completability model must not make. Asking the question
9100    /// of every variant and getting an honest `None` is the lift;
9101    /// `tools/ci/check-capability-ownership.py` check D asked for it while the field
9102    /// was still cross-cutting, and spec-0032's eighth site took it below that
9103    /// threshold, so the reasoning lives here now rather than in an exemption.
9104    pub fn quest(&self) -> Option<&str> {
9105        match self {
9106            EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
9107                Some(quest)
9108            }
9109            EffectSite::Trigger { .. }
9110            | EffectSite::Trap { .. }
9111            | EffectSite::DialogueRespawn { .. }
9112            | EffectSite::ShortcutUnlock { .. }
9113            | EffectSite::ShopOffer { .. }
9114            | EffectSite::OnDeath
9115            | EffectSite::OnKill { .. }
9116            | EffectSite::AssemblyLand { .. }
9117            | EffectSite::LoopCross { .. } => None,
9118        }
9119    }
9120}
9121
9122/// Visit **every** effect the compiler can lower — at every one of the five
9123/// effect roots, top-level and transitively nested — in a fixed deterministic
9124/// order, invoking `f(json_pointer, site, effect)`.
9125///
9126/// The roots come from [`crate::effects::for_each_effect_root`], the single
9127/// enumeration; nesting is descended through the single
9128/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
9129/// enumerated here, which is the point: this walk used to hand-list four of the
9130/// five roots (it had no `EffectSite` variant for the fifth), so every proof
9131/// defined in terms of it inherited that blind spot.
9132pub fn for_each_campaign_effect<'a>(
9133    c: &'a crate::envelope::Campaign,
9134    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
9135) {
9136    crate::effects::for_each_effect_root(c, &mut |root, list| {
9137        let site = match root.owner {
9138            crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
9139                EffectSite::Objective {
9140                    quest: quest.id.as_str().to_string(),
9141                    objective: objective.to_string(),
9142                }
9143            }
9144            crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
9145                quest: quest.id.as_str().to_string(),
9146            },
9147            crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
9148                trigger: t.id.as_str().to_string(),
9149            },
9150            crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
9151                trap: t.id.as_str().to_string(),
9152            },
9153            crate::effects::EffectRootOwner::DialogueRespawn => {
9154                // The npc and node are in the root's path; parse them back rather
9155                // than widening the root walk's owner for one consumer.
9156                let seg = |n: usize| -> String {
9157                    root.path.split('/').nth(n).unwrap_or_default().to_string()
9158                };
9159                EffectSite::DialogueRespawn {
9160                    npc: seg(3),
9161                    node: seg(5),
9162                }
9163            }
9164            crate::effects::EffectRootOwner::ShortcutUnlock(s) => EffectSite::ShortcutUnlock {
9165                shortcut: s.id.as_str().to_string(),
9166            },
9167            crate::effects::EffectRootOwner::OnDeath => EffectSite::OnDeath,
9168            crate::effects::EffectRootOwner::ShopOffer(h) => EffectSite::ShopOffer {
9169                shop: h.id.as_str().to_string(),
9170                // The offer index is in the root's path
9171                // (`/content/shops/<h>/offers/<i>/effects`), parsed back rather
9172                // than widening the owner for one consumer — the same call the
9173                // dialogue arm above makes. Segment 5 is the index: segment 4 is
9174                // the literal `offers`, which parses as nothing and reported
9175                // every offer as the shop's first.
9176                offer: root
9177                    .path
9178                    .split('/')
9179                    .nth(5)
9180                    .and_then(|n| n.parse().ok())
9181                    .unwrap_or(0),
9182            },
9183            crate::effects::EffectRootOwner::OnKill(f) => EffectSite::OnKill {
9184                fight: f.id().to_string(),
9185            },
9186            crate::effects::EffectRootOwner::AssemblyLand(m) => EffectSite::AssemblyLand {
9187                assembly: m.id.as_str().to_string(),
9188                // `/content/assemblies/<m>/strikes/pattern/<s>/on_land`: segment
9189                // 6 is the step index, parsed back as the shop arm does.
9190                step: root
9191                    .path
9192                    .split('/')
9193                    .nth(6)
9194                    .and_then(|n| n.parse().ok())
9195                    .unwrap_or(0),
9196            },
9197            crate::effects::EffectRootOwner::LoopCross(l) => EffectSite::LoopCross {
9198                r#loop: l.id.as_str().to_string(),
9199            },
9200        };
9201        for (i, eff) in list.iter().enumerate() {
9202            campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
9203        }
9204    });
9205}
9206
9207fn campaign_effect_deep<'a>(
9208    eff: &'a QuestEffect,
9209    path: &str,
9210    site: &EffectSite,
9211    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
9212) {
9213    f(path, site, eff);
9214    for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
9215        for (j, inner) in list.iter().enumerate() {
9216            campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
9217        }
9218    }
9219}
9220
9221// ---------------------------------------------------------------------------
9222// The spine authority (`QuestPlanContent::spine`)
9223// ---------------------------------------------------------------------------
9224
9225#[cfg(test)]
9226mod spine_tests {
9227    use super::QuestPlanContent;
9228
9229    /// Build a plan from `(id, deps)` pairs plus a finale. JSON rather than a
9230    /// struct literal on purpose: a field added to `PlannedQuest` later must not
9231    /// red these tests for a reason that has nothing to do with the spine.
9232    fn plan(finale: &str, quests: &[(&str, &[&str])]) -> QuestPlanContent {
9233        let quests: Vec<serde_json::Value> = quests
9234            .iter()
9235            .map(|(id, deps)| {
9236                serde_json::json!({
9237                    "id": id,
9238                    "goal": "g",
9239                    "area": "area/keep",
9240                    "npcs": [],
9241                    "depends_on": deps,
9242                    "mandatory": true,
9243                    "act": 1,
9244                })
9245            })
9246            .collect();
9247        serde_json::from_value(serde_json::json!({
9248            "finale": finale,
9249            "quests": quests,
9250        }))
9251        .expect("plan fixture parses")
9252    }
9253
9254    fn sorted(p: &QuestPlanContent) -> Vec<String> {
9255        p.spine().into_iter().map(str::to_owned).collect()
9256    }
9257
9258    #[test]
9259    fn a_chain_is_wholly_spine() {
9260        let p = plan(
9261            "quest/c",
9262            &[
9263                ("quest/a", &[]),
9264                ("quest/b", &["quest/a"]),
9265                ("quest/c", &["quest/b"]),
9266            ],
9267        );
9268        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c"]);
9269    }
9270
9271    #[test]
9272    fn a_quest_the_finale_does_not_depend_on_is_off_the_spine() {
9273        // Exactly the `DW0132` shape: the plan does not converge, and the spine
9274        // is the half that does. The authority answers, it does not refuse — the
9275        // refusal is `validate`'s, built on this answer.
9276        let p = plan("quest/end", &[("quest/end", &[]), ("quest/side-trip", &[])]);
9277        assert_eq!(sorted(&p), ["quest/end"]);
9278    }
9279
9280    #[test]
9281    fn a_diamond_counts_the_join_once() {
9282        let p = plan(
9283            "quest/d",
9284            &[
9285                ("quest/a", &[]),
9286                ("quest/b", &["quest/a"]),
9287                ("quest/c", &["quest/a"]),
9288                ("quest/d", &["quest/b", "quest/c"]),
9289            ],
9290        );
9291        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c", "quest/d"]);
9292    }
9293
9294    #[test]
9295    fn a_cycle_terminates_and_yields_a_set() {
9296        // `DW0130` refuses this plan, but the authority is asked before that
9297        // verdict is known (the layout binding prints on an erroring campaign),
9298        // so it must terminate rather than hang.
9299        let p = plan(
9300            "quest/b",
9301            &[("quest/a", &["quest/b"]), ("quest/b", &["quest/a"])],
9302        );
9303        assert_eq!(sorted(&p), ["quest/a", "quest/b"]);
9304    }
9305
9306    #[test]
9307    fn a_dangling_dependency_is_reported_and_expands_no_further() {
9308        // The deliberate difference between the two derivations this function
9309        // replaced. `validate` pruned undeclared ids before walking; the
9310        // authority does not, because pruning them would make the set disagree
9311        // with the document, and naming an id the plan does not declare is
9312        // `DW0112`'s finding rather than the spine's.
9313        let p = plan("quest/end", &[("quest/end", &["quest/ghost"])]);
9314        assert_eq!(sorted(&p), ["quest/end", "quest/ghost"]);
9315    }
9316
9317    #[test]
9318    fn an_undeclared_finale_is_the_whole_spine() {
9319        // `DW0131`'s shape. The answer is honest about the document: nothing the
9320        // plan declares is on the spine of a finale it never declared.
9321        let p = plan(
9322            "quest/ghost",
9323            &[("quest/a", &[]), ("quest/b", &["quest/a"])],
9324        );
9325        assert_eq!(sorted(&p), ["quest/ghost"]);
9326    }
9327}
9328
9329// ---------------------------------------------------------------------------
9330// The derived happening subject (spec-0071 §3)
9331// ---------------------------------------------------------------------------
9332
9333#[cfg(test)]
9334mod happening_subject_tests {
9335    use super::QuestEffect;
9336
9337    /// An effect with a `happening` that states no subject.
9338    fn beat(verb: serde_json::Value) -> QuestEffect {
9339        let mut v = verb;
9340        v["happening"] = serde_json::json!({ "verb": "opens", "text": "It gives." });
9341        serde_json::from_value(v).expect("effect fixture parses")
9342    }
9343
9344    fn subject(v: serde_json::Value) -> Option<String> {
9345        beat(v).happening_subject().map(|s| {
9346            assert!(s.derived, "the fixture states no subject");
9347            s.id.to_string()
9348        })
9349    }
9350
9351    /// **The census, over every verb the DSL declares.** Which verbs resolve a
9352    /// subject is a fact about the objects an effect names, so the answer is
9353    /// computed here rather than listed anywhere: the table below is held to the
9354    /// variant set of the exported schema, and a thirty-ninth verb reds this
9355    /// test until somebody answers for it.
9356    ///
9357    /// Run with `--nocapture` to print the census a document quotes.
9358    #[test]
9359    fn every_verb_answers_the_census() {
9360        // One minimal instance per verb, in the schema's own order.
9361        let table: Vec<(&str, serde_json::Value, Option<&str>)> = vec![
9362            (
9363                "open-gate",
9364                serde_json::json!({"type":"open-gate","anchor":"anchor/door"}),
9365                Some("anchor/door"),
9366            ),
9367            (
9368                "close-gate",
9369                serde_json::json!({"type":"close-gate","anchor":"anchor/door"}),
9370                Some("anchor/door"),
9371            ),
9372            (
9373                "campaign-complete",
9374                serde_json::json!({"type":"campaign-complete"}),
9375                None,
9376            ),
9377            (
9378                "give-item",
9379                serde_json::json!({"type":"give-item","item":"minecraft:bread","count":1}),
9380                None,
9381            ),
9382            (
9383                "set-flag",
9384                serde_json::json!({"type":"set-flag","flag":"flag/heard"}),
9385                None,
9386            ),
9387            (
9388                "set-state",
9389                serde_json::json!({"type":"set-state","state":"state/purse","value":1}),
9390                None,
9391            ),
9392            (
9393                "add-state",
9394                serde_json::json!({"type":"add-state","state":"state/purse","amount":-1}),
9395                None,
9396            ),
9397            (
9398                "drop-stake",
9399                serde_json::json!({"type":"drop-stake","stake":"stake/purse"}),
9400                None,
9401            ),
9402            (
9403                "clear-state",
9404                serde_json::json!({"type":"clear-state","state":"state/purse"}),
9405                None,
9406            ),
9407            (
9408                "spawn-wave",
9409                serde_json::json!({"type":"spawn-wave","wave":"wave/muster"}),
9410                Some("wave/muster"),
9411            ),
9412            (
9413                "narrate",
9414                serde_json::json!({"type":"narrate","text":"The hall answers."}),
9415                None,
9416            ),
9417            (
9418                "set-block",
9419                serde_json::json!({"type":"set-block","anchor":"anchor/altar","block":"minecraft:stone"}),
9420                Some("anchor/altar"),
9421            ),
9422            (
9423                "fill-region",
9424                serde_json::json!({"type":"fill-region","region":{"anchor":"anchor/pit","extent":[2,2,2]},"block":"minecraft:water"}),
9425                Some("anchor/pit"),
9426            ),
9427            (
9428                "set-atmosphere",
9429                serde_json::json!({"type":"set-atmosphere","atmosphere":"atmosphere/wrong","region":{"anchor":"anchor/hall","extent":[4,2,4]}}),
9430                Some("anchor/hall"),
9431            ),
9432            (
9433                "clear-region",
9434                serde_json::json!({"type":"clear-region","region":{"anchor":"anchor/pit","extent":[2,2,2]}}),
9435                Some("anchor/pit"),
9436            ),
9437            (
9438                "open-way",
9439                serde_json::json!({"type":"open-way","piece":"prefab/span","way":"gap"}),
9440                None,
9441            ),
9442            (
9443                "despawn-npc",
9444                serde_json::json!({"type":"despawn-npc","npc":"npc/keeper"}),
9445                Some("npc/keeper"),
9446            ),
9447            (
9448                "move-npc",
9449                serde_json::json!({"type":"move-npc","npc":"npc/keeper","to":{"anchor":"anchor/door"}}),
9450                None,
9451            ),
9452            (
9453                "cutscene",
9454                serde_json::json!({"type":"cutscene","path":[{"anchor":"anchor/hall"},{"anchor":"anchor/door"}],"seconds":4}),
9455                None,
9456            ),
9457            (
9458                "set-time",
9459                serde_json::json!({"type":"set-time","time":"noon"}),
9460                None,
9461            ),
9462            (
9463                "set-weather",
9464                serde_json::json!({"type":"set-weather","weather":"clear"}),
9465                None,
9466            ),
9467            (
9468                "play-sound",
9469                serde_json::json!({"type":"play-sound","sound":"minecraft:block.bell.use","at":{"at":"anchor","anchor":"anchor/bell"}}),
9470                Some("anchor/bell"),
9471            ),
9472            (
9473                "damage-players",
9474                serde_json::json!({"type":"damage-players","amount":2}),
9475                None,
9476            ),
9477            (
9478                "set-checkpoint",
9479                serde_json::json!({"type":"set-checkpoint","anchor":"anchor/seat"}),
9480                Some("anchor/seat"),
9481            ),
9482            (
9483                "bonfire",
9484                serde_json::json!({"type":"bonfire","anchor":"anchor/seat"}),
9485                Some("anchor/seat"),
9486            ),
9487            (
9488                "begin-stealth",
9489                serde_json::json!({"type":"begin-stealth","zones":[{"anchor":"anchor/yard","extent":[4,2,4]}]}),
9490                Some("anchor/yard"),
9491            ),
9492            (
9493                "end-stealth",
9494                serde_json::json!({"type":"end-stealth"}),
9495                None,
9496            ),
9497            (
9498                "spawn-npc",
9499                serde_json::json!({"type":"spawn-npc","npc":"npc/keeper"}),
9500                Some("npc/keeper"),
9501            ),
9502            (
9503                "spawn-actor",
9504                serde_json::json!({"type":"spawn-actor","actor":"actor/giant"}),
9505                Some("actor/giant"),
9506            ),
9507            (
9508                "despawn-actor",
9509                serde_json::json!({"type":"despawn-actor","actor":"actor/giant","style":"vanish"}),
9510                Some("actor/giant"),
9511            ),
9512            (
9513                "move-actor",
9514                serde_json::json!({"type":"move-actor","actor":"actor/giant","to":{"anchor":"anchor/door"}}),
9515                None,
9516            ),
9517            (
9518                "unleash-actor",
9519                serde_json::json!({"type":"unleash-actor","actor":"actor/giant"}),
9520                Some("actor/giant"),
9521            ),
9522            (
9523                "sequence",
9524                serde_json::json!({"type":"sequence","steps":[]}),
9525                None,
9526            ),
9527            (
9528                "volley",
9529                serde_json::json!({"type":"volley","from_anchor":"anchor/slot","kill_zone":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9530                None,
9531            ),
9532            (
9533                "collapse",
9534                serde_json::json!({"type":"collapse","region_anchor":{"anchor":"anchor/roof","extent":[3,1,3]}}),
9535                Some("anchor/roof"),
9536            ),
9537            (
9538                "give-effect",
9539                serde_json::json!({"type":"give-effect","effect":"minecraft:speed","seconds":10}),
9540                None,
9541            ),
9542            (
9543                "clear-effect",
9544                serde_json::json!({"type":"clear-effect"}),
9545                None,
9546            ),
9547            (
9548                "teleport",
9549                serde_json::json!({"type":"teleport","from":{"anchor":"anchor/hall","extent":[4,2,4]},"to":{"anchor":"anchor/door"}}),
9550                None,
9551            ),
9552            (
9553                "firework",
9554                serde_json::json!({"type":"firework","at":{"anchor":"anchor/court"},"explosions":[{"shape":"star","colors":["#ffd700"]}]}),
9555                Some("anchor/court"),
9556            ),
9557            // spec-0082: an assembly is not a story subject (no `happening` is
9558            // demanded of its verbs), so none of the three resolves one.
9559            (
9560                "spawn-assembly",
9561                serde_json::json!({"type":"spawn-assembly","assembly":"assembly/limb"}),
9562                None,
9563            ),
9564            (
9565                "despawn-assembly",
9566                serde_json::json!({"type":"despawn-assembly","assembly":"assembly/limb"}),
9567                None,
9568            ),
9569            (
9570                "play-clip",
9571                serde_json::json!({"type":"play-clip","assembly":"assembly/limb","clip":"idle"}),
9572                None,
9573            ),
9574            (
9575                "arm-strikes",
9576                serde_json::json!({"type":"arm-strikes","assembly":"assembly/limb"}),
9577                None,
9578            ),
9579            (
9580                "particle",
9581                serde_json::json!({"type":"particle","particle":"minecraft:soul","at":{"anchor":"anchor/well"}}),
9582                Some("anchor/well"),
9583            ),
9584            (
9585                "lightning",
9586                serde_json::json!({"type":"lightning","at":{"anchor":"anchor/court"}}),
9587                Some("anchor/court"),
9588            ),
9589        ];
9590        // The binding: the table answers for every verb the schema declares, and
9591        // for no name the schema does not.
9592        let schema = crate::schema::stage_schema(crate::envelope::Stage::Quests);
9593        let declared: std::collections::BTreeSet<String> = schema["$defs"]["QuestEffect"]["oneOf"]
9594            .as_array()
9595            .expect("QuestEffect is a tagged union")
9596            .iter()
9597            .map(|b| {
9598                b["properties"]["type"]["const"]
9599                    .as_str()
9600                    .expect("a tag")
9601                    .to_string()
9602            })
9603            .collect();
9604        let answered: std::collections::BTreeSet<String> =
9605            table.iter().map(|(t, _, _)| (*t).to_string()).collect();
9606        assert_eq!(
9607            answered, declared,
9608            "the census answers for exactly the verbs the DSL declares"
9609        );
9610        for (tag, effect, want) in &table {
9611            let got = subject(effect.clone());
9612            assert_eq!(
9613                got.as_deref(),
9614                *want,
9615                "`{tag}` resolves {want:?}; it names {:?}",
9616                beat(effect.clone()).subject_objects()
9617            );
9618            println!(
9619                "| `{tag}` | {} | {} |",
9620                want.unwrap_or("—"),
9621                beat(effect.clone()).subject_objects().join(", ")
9622            );
9623        }
9624    }
9625
9626    /// **Some verbs answer per instance, not per verb**, because the object is
9627    /// in an optional field: an `in` filter, a second stealth zone, a camera
9628    /// path of one waypoint. The rule is over the objects an effect names, so
9629    /// the same verb resolves in one document and not in another — and that is
9630    /// the answer, not a gap in it.
9631    #[test]
9632    fn a_verb_whose_object_is_optional_answers_per_instance() {
9633        for (what, effect, want) in [
9634            (
9635                "damage-players with an `in` filter",
9636                serde_json::json!({"type":"damage-players","amount":2,"in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9637                Some("anchor/lane"),
9638            ),
9639            (
9640                "give-effect with an `in` filter",
9641                serde_json::json!({"type":"give-effect","effect":"minecraft:speed","seconds":10,"in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9642                Some("anchor/lane"),
9643            ),
9644            (
9645                "clear-effect with an `in` filter",
9646                serde_json::json!({"type":"clear-effect","in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
9647                Some("anchor/lane"),
9648            ),
9649            (
9650                "play-sound with no `at`",
9651                serde_json::json!({"type":"play-sound","sound":"minecraft:block.bell.use"}),
9652                None,
9653            ),
9654            (
9655                "begin-stealth over two zones",
9656                serde_json::json!({"type":"begin-stealth","zones":[
9657                    {"anchor":"anchor/yard","extent":[4,2,4]},
9658                    {"anchor":"anchor/lane","extent":[4,2,4]}]}),
9659                None,
9660            ),
9661            (
9662                "a cutscene whose path names one cell",
9663                serde_json::json!({"type":"cutscene","path":[{"anchor":"anchor/hall"}],"seconds":4}),
9664                Some("anchor/hall"),
9665            ),
9666        ] {
9667            assert_eq!(subject(effect.clone()).as_deref(), want, "{what}");
9668        }
9669    }
9670
9671    /// The four the spec illustrates, beside the ones the rule covers without
9672    /// naming them.
9673    #[test]
9674    fn an_effect_with_one_object_is_about_that_object() {
9675        for (effect, want) in [
9676            (
9677                serde_json::json!({ "type": "open-gate", "anchor": "anchor/door" }),
9678                "anchor/door",
9679            ),
9680            (
9681                serde_json::json!({ "type": "close-gate", "anchor": "anchor/door" }),
9682                "anchor/door",
9683            ),
9684            (
9685                serde_json::json!({ "type": "spawn-actor", "actor": "actor/giant" }),
9686                "actor/giant",
9687            ),
9688            (
9689                serde_json::json!({ "type": "despawn-actor", "actor": "actor/giant", "style": "vanish" }),
9690                "actor/giant",
9691            ),
9692            (
9693                serde_json::json!({ "type": "unleash-actor", "actor": "actor/giant" }),
9694                "actor/giant",
9695            ),
9696            (
9697                serde_json::json!({ "type": "spawn-wave", "wave": "wave/muster" }),
9698                "wave/muster",
9699            ),
9700            (
9701                serde_json::json!({ "type": "spawn-npc", "npc": "npc/keeper" }),
9702                "npc/keeper",
9703            ),
9704            (
9705                serde_json::json!({ "type": "despawn-npc", "npc": "npc/keeper" }),
9706                "npc/keeper",
9707            ),
9708            (
9709                serde_json::json!({ "type": "set-block", "anchor": "anchor/altar", "block": "minecraft:stone" }),
9710                "anchor/altar",
9711            ),
9712        ] {
9713            assert_eq!(subject(effect.clone()).as_deref(), Some(want), "{effect}");
9714        }
9715    }
9716
9717    /// Several objects, or none, resolve nothing: naming one of them would be
9718    /// the compiler guessing which the beat is about. A `move-actor` walks an
9719    /// actor to an anchor and names both.
9720    #[test]
9721    fn an_effect_with_no_single_object_resolves_nothing() {
9722        for effect in [
9723            serde_json::json!({ "type": "move-actor", "actor": "actor/giant", "to": { "anchor": "anchor/door" } }),
9724            serde_json::json!({ "type": "move-npc", "npc": "npc/keeper", "to": { "anchor": "anchor/door" } }),
9725            serde_json::json!({ "type": "teleport", "from": { "anchor": "anchor/hall", "extent": [2, 2, 2] }, "to": { "anchor": "anchor/door" } }),
9726            serde_json::json!({ "type": "narrate", "text": "The hall answers." }),
9727            serde_json::json!({ "type": "set-flag", "flag": "flag/heard" }),
9728        ] {
9729            assert_eq!(subject(effect.clone()), None, "{effect}");
9730        }
9731    }
9732
9733    /// A stated subject wins, and says so.
9734    #[test]
9735    fn a_stated_subject_is_not_derived() {
9736        let e: QuestEffect = serde_json::from_value(serde_json::json!({
9737            "type": "open-gate",
9738            "anchor": "anchor/door",
9739            "happening": { "verb": "opens", "text": "He lifts it.", "subject": "npc/keeper" }
9740        }))
9741        .unwrap();
9742        let s = e.happening_subject().expect("a stated subject");
9743        assert_eq!((s.id, s.derived), ("npc/keeper", false));
9744    }
9745
9746    /// No `happening`, no subject: the derivation is about a beat, not about an
9747    /// effect.
9748    #[test]
9749    fn an_effect_with_no_happening_has_no_subject() {
9750        let e: QuestEffect = serde_json::from_value(
9751            serde_json::json!({ "type": "open-gate", "anchor": "anchor/door" }),
9752        )
9753        .unwrap();
9754        assert!(e.happening_subject().is_none());
9755        assert_eq!(e.subject_objects(), vec!["anchor/door"]);
9756    }
9757}