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