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 (npc `role`,
4//! objective/effect types) parse successfully but are rejected by validation
5//! ([`crate::validate`]) with code `DW0141`.
6
7use std::collections::BTreeMap;
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::ids::{
13 ActorId, AmbushId, AnchorId, AreaId, BranchId, BranchPointId, ClassId, DialogueId, EditBatchId,
14 EndingId, FlagId, LootId, NpcId, ObjectiveId, PoolId, PrefabId, QuestId, RegionId, ShortcutId,
15 TimedGateId, TrapId, TriggerId, WaveId,
16};
17
18/// serde default helper: `true` (used by DSL v0.4 `trigger.once`).
19fn default_true() -> bool {
20 true
21}
22
23/// serde `skip_serializing_if` helper: skip a `false` bool (DSL v0.4
24/// `objective.stealth`), keeping older campaigns byte-identical.
25fn is_false(b: &bool) -> bool {
26 !*b
27}
28
29// ---------------------------------------------------------------------------
30// Stage 1 — world
31// ---------------------------------------------------------------------------
32
33/// Stage 1 payload: setting, seed and the areas that make up the delve.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
35#[serde(deny_unknown_fields)]
36pub struct WorldContent {
37 /// Player-facing delve title.
38 pub title: String,
39 /// One-line thematic description.
40 pub theme: String,
41 /// Short narrative premise.
42 pub premise: String,
43 /// The single downstream randomness source (ADR-0006).
44 pub seed: u64,
45 /// Informational pacing target in minutes (v0: not enforced).
46 pub target_minutes: u32,
47 /// 1..N areas; each binds exactly one of `prefab` / `prefab_pool`.
48 pub areas: Vec<Area>,
49 /// Additional author-declared translation languages (BCP-47-style codes, e.g.
50 /// `["zh-cn"]`). English (`en`) is implicit, always canonical, and is **never**
51 /// listed here (spec-0001 i18n addendum). Absent or empty = English-only. Every
52 /// declared language must ship a fully-covering `l10n/<code>.json` sidecar
53 /// (`DW0180`/`DW0181`). Stage docs themselves stay pure English.
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub languages: Vec<String>,
56 /// Declared initial world time (DSL v0.5, spec-0010). Dimension-global; frozen
57 /// by environment sealing (`advance_time false`) so the set state persists.
58 /// Absent = `noon` (the v0 default). Affects sky attenuation in the compiler's
59 /// assembled-light model.
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub time: Option<WorldTime>,
62 /// Declared initial weather (DSL v0.5, spec-0010). Dimension-global; frozen by
63 /// environment sealing (`advance_weather false`). Absent = `clear`. Rain and
64 /// thunder attenuate effective sky brightness in the assembled-light model.
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub weather: Option<WorldWeather>,
67 /// Declared combat difficulty (DSL v0.6, owner ruling 2026-08-03). Absent =
68 /// the compiler's historical derivation — `easy` when the campaign fields any
69 /// wave, `peaceful` when it fields none — which is what keeps every campaign
70 /// written before this field byte-identical. Declaring it overrides the
71 /// derivation for **both** the shipped `server.properties` and a `/difficulty`
72 /// in the sealing baseline, so the declaration also holds when the datapack is
73 /// dropped into somebody else's world.
74 ///
75 /// `peaceful` is rejected (`DW0468`). Raising difficulty changes the damage
76 /// players take — easy halves it — so combat arithmetic tuned under the old
77 /// implicit `easy` must be redone.
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub difficulty: Option<WorldDifficulty>,
80 /// Scenic horizon (DSL v0.6, spec-0013). Absent or `void` = the void world
81 /// (byte-identical to v0.5). `ocean` swaps the world generator for a
82 /// deterministic superflat sea (bedrock/stone/water, sea level y=62) and drops
83 /// the area datum to y=60 (`sea_level-2`) so island pieces meet the sea at their
84 /// authored waterline. No structures or mobs either way.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub horizon: Option<Horizon>,
87 /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
88 /// derives a region from the placed geometry and a per-second clock returns any
89 /// player who leaves it to the last checkpoint. Required when `horizon` is
90 /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub boundary: Option<Boundary>,
93 /// The closing line on the campaign-completion advancement — the last
94 /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
95 /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
96 /// finale quest's `goal`, which is already both campaign-derived and
97 /// inventoried; the description was previously the hardcoded English
98 /// "You left the keep." on *every* delve, whatever its theme or language.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub outro: Option<String>,
101 /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
102 /// party of one is always legal and every pre-0.6 campaign keeps that reading.
103 /// A design whose beats genuinely need `n` players — two rooms whose switches
104 /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
105 /// lobby then refuses to start below it: the class-selection dialog stays shut
106 /// and the waiting players get a party-count actionbar instead.
107 ///
108 /// Progression is party state either way (spec-0018), so this is a *declaration
109 /// of intent*, not a mechanism: it makes a mandatory-n design first-class
110 /// (owner amendment 2026-08-02) and turns on the analyzer's n-agent division
111 /// proof. Out of `1..=4` is `DW0370`.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub min_players: Option<u8>,
114}
115
116/// Who a granted item goes to (DSL v0.6, spec-0018).
117///
118/// Progression is a fact about the party, so the default for every `give-item` and
119/// every class-kit entry is **all**: a quest beat that arms the party arms all of
120/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
121/// the stake): exactly one copy enters the party, handed to the player whose action
122/// earned it, and the party passes it around physically.
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
124#[serde(rename_all = "kebab-case")]
125pub enum Carrier {
126 /// Every party member receives the item (the default).
127 #[default]
128 All,
129 /// Exactly one copy, to the player whose action fired the effect.
130 One,
131}
132
133/// A declared world time state (DSL v0.5, spec-0010). The sole difference from
134/// vanilla is that the daylight cycle is frozen (`advance_time false`), so a set
135/// state persists for the whole delve until a `set-time` effect cuts to another.
136///
137/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
138/// tick count, and the tick form is the general one — so the states worth naming
139/// for a delve's pacing are not limited to the four keywords. `dusk` and `dawn`
140/// (owner ruling, 2026-08-03) are the tick form exposed first-class, per the
141/// no-hack rule: the DSL names the beat, the compiler emits `/time set <ticks>`.
142/// Every keyword-to-tick mapping lives in exactly one table ([`WorldTime::spec`]),
143/// and the four vanilla keywords still emit their keyword verbatim, so existing
144/// campaigns are byte-identical.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
146#[serde(rename_all = "kebab-case")]
147pub enum WorldTime {
148 /// Morning daylight (`/time set day`, 1000 ticks).
149 Day,
150 /// Midday, brightest (`/time set noon`, 6000 ticks) — the default.
151 #[default]
152 Noon,
153 /// Sunset — the sky visibly going orange and the day ending
154 /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
155 /// has finished setting, which is what the `night` keyword already sets, so
156 /// 13000 would make `dusk` a synonym rather than its own beat.
157 Dusk,
158 /// Night, sun fully down (`/time set night`, 13000 ticks).
159 Night,
160 /// Deep night, darkest (`/time set midnight`, 18000 ticks).
161 Midnight,
162 /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
163 /// `sunrise` is accepted as a synonym on input.
164 #[serde(alias = "sunrise")]
165 Dawn,
166}
167
168impl WorldTime {
169 /// The single keyword/tick table: `(the /time set argument, daytime ticks)`.
170 ///
171 /// A state vanilla names keeps its keyword — the argument the compiler has
172 /// always emitted — so no shipped campaign's bytes move. A state vanilla does
173 /// not name emits the equivalent tick count, which is the same primitive.
174 const fn spec(self) -> (&'static str, i64) {
175 match self {
176 WorldTime::Day => ("day", 1000),
177 WorldTime::Noon => ("noon", 6000),
178 WorldTime::Dusk => ("12000", 12000),
179 WorldTime::Night => ("night", 13000),
180 WorldTime::Midnight => ("midnight", 18000),
181 WorldTime::Dawn => ("23000", 23000),
182 }
183 }
184
185 /// The vanilla `/time set` argument — a keyword for the four states vanilla
186 /// names, a tick count otherwise.
187 pub fn token(self) -> &'static str {
188 self.spec().0
189 }
190
191 /// The `daytime` tick value this state sets (the `time query daytime`
192 /// read-back). Vanilla constants: day=1000, noon=6000, dusk=12000 (sunset
193 /// onset), night=13000, midnight=18000, dawn=23000.
194 pub fn daytime_ticks(self) -> i64 {
195 self.spec().1
196 }
197}
198
199/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
200/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202#[serde(rename_all = "kebab-case")]
203pub enum WorldWeather {
204 /// Clear sky (`/weather clear`) — the default.
205 #[default]
206 Clear,
207 /// Rain (`/weather rain`).
208 Rain,
209 /// Thunderstorm (`/weather thunder`).
210 Thunder,
211}
212
213impl WorldWeather {
214 /// The vanilla `/weather` keyword.
215 pub fn token(self) -> &'static str {
216 match self {
217 WorldWeather::Clear => "clear",
218 WorldWeather::Rain => "rain",
219 WorldWeather::Thunder => "thunder",
220 }
221 }
222}
223
224/// The declared combat difficulty of the delve (DSL v0.6, owner ruling
225/// 2026-08-03). Values are the vanilla `/difficulty` keywords.
226///
227/// Difficulty is the single largest lever on how hard a delve *feels*, and until
228/// this field existed the compiler chose it: `easy` for any campaign with a wave,
229/// `peaceful` for one without. Easy **halves incoming player damage** —
230/// `min(dmg / 2 + 1, dmg)` — so every combat number in a pre-0.6 campaign was
231/// tuned against a halved world without anyone declaring it. A campaign that
232/// raises this must redo that arithmetic.
233///
234/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
235/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
236/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
237/// campaign would silently vanish. It is a variant only so the compiler can say
238/// that in a diagnostic instead of a serde "unknown variant" parse error.
239#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
240#[serde(rename_all = "kebab-case")]
241pub enum WorldDifficulty {
242 /// `/difficulty easy` — the compiler's historical choice for a wave
243 /// campaign, and the default reading of an absent field. Incoming player
244 /// damage is halved (`min(dmg / 2 + 1, dmg)`).
245 #[default]
246 Easy,
247 /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
248 Normal,
249 /// `/difficulty hard` — amplified damage, and zombies reinforce.
250 Hard,
251 /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
252 /// the rejection can be a diagnostic with a rationale.
253 Peaceful,
254}
255
256impl WorldDifficulty {
257 /// The vanilla `/difficulty` keyword.
258 pub fn token(self) -> &'static str {
259 match self {
260 WorldDifficulty::Peaceful => "peaceful",
261 WorldDifficulty::Easy => "easy",
262 WorldDifficulty::Normal => "normal",
263 WorldDifficulty::Hard => "hard",
264 }
265 }
266
267 /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
268 /// `/difficulty` query command returns — the only vanilla read-back path for
269 /// the setting, and so what the generated PackTest asserts on.
270 pub fn id(self) -> i32 {
271 match self {
272 WorldDifficulty::Peaceful => 0,
273 WorldDifficulty::Easy => 1,
274 WorldDifficulty::Normal => 2,
275 WorldDifficulty::Hard => 3,
276 }
277 }
278}
279
280/// A scenic horizon (DSL v0.6, spec-0013). `void` is the default and is
281/// byte-identical to v0.5 (empty-layer superflat, `minecraft:the_void` biome);
282/// `ocean` selects a pinned bedrock/stone/water superflat with sea level y=62,
283/// pure backdrop with no structures or mobs. The compiler owns the exact
284/// generator-settings; this enum only picks which one.
285#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286#[serde(rename_all = "kebab-case")]
287pub enum Horizon {
288 /// The void world (default) — no sky-filling geometry.
289 #[default]
290 Void,
291 /// A superflat sea backdrop; areas are placed on the sea-level datum (y=60) so
292 /// island pieces read as land ringed by the ocean.
293 Ocean,
294}
295
296/// The default boundary `margin` (blocks of horizontal breathing room added
297/// around the derived region). Separate function so `serde(default = …)` and the
298/// documented literal cannot drift.
299fn default_margin() -> u16 {
300 16
301}
302
303/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
304/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
305/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
306/// placed block − 8) — never authored — so "every anchor is inside" is structural.
307/// Enforcement is a per-second clock that returns any player outside the region to
308/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
309/// damage, no items lost.
310#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
311#[serde(deny_unknown_fields)]
312pub struct Boundary {
313 /// Horizontal breathing room in blocks added around the derived region on
314 /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
315 #[serde(default = "default_margin")]
316 pub margin: u16,
317 /// Actionbar message shown on return. Absent = the compiler's English default.
318 /// When set, it is inventoried under l10n key `world.boundary.message` and is
319 /// translated like every other player-facing string.
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub message: Option<String>,
322}
323
324/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
325/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
326/// compiler owns the placement rule and block-light emission.
327#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
328#[serde(rename_all = "kebab-case")]
329pub enum Fixture {
330 /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
331 Torch,
332 /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
333 Lantern,
334 /// Floor campfire (block light 15); never on or adjacent to a required path
335 /// cell (it is a damage source).
336 Campfire,
337 /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
338 Shroomlight,
339}
340
341impl Fixture {
342 /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
343 pub fn token(self) -> &'static str {
344 match self {
345 Fixture::Torch => "torch",
346 Fixture::Lantern => "lantern",
347 Fixture::Campfire => "campfire",
348 Fixture::Shroomlight => "shroomlight",
349 }
350 }
351}
352
353/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
354/// presence puts the area on the relight path: the compiler guarantees every
355/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
356/// with `DW0211` if it cannot.
357#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
358#[serde(deny_unknown_fields)]
359pub struct AreaLighting {
360 /// The fixture the relight pass places.
361 pub fixture: Fixture,
362 /// The minimum block+sky light guaranteed on reachable walkable cells
363 /// (1..=14, default 7). Validated by `DW0141`-adjacent bounds checking.
364 #[serde(default = "default_min_light")]
365 pub min_light: u8,
366}
367
368/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
369fn default_min_light() -> u8 {
370 7
371}
372
373/// A per-area **darkness mitigation** declaration (DSL v0.6).
374///
375/// The first-class answer to "this area is meant to be dark, and the players are
376/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
377/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
378/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
379/// gap between the check and the feature.
380///
381/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
382/// for `night vision`: that accepted a renamed water bottle, so the gate passed
383/// while nothing in the world granted night vision (owner, island QA).
384#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
385#[serde(rename_all = "kebab-case")]
386pub enum AreaMitigation {
387 /// Every player inside the area's placed bounds is kept under
388 /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
389 NightVision,
390}
391
392/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
393///
394/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
395/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
396/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
397/// full jigsaw layout semantics land in M2 task #9 (spec-0002).
398#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
399#[serde(deny_unknown_fields)]
400pub struct Area {
401 /// Unique area id.
402 pub id: AreaId,
403 /// Player-facing area name.
404 pub name: String,
405 /// The single prefab bound to this area (mutually exclusive with
406 /// `prefab_pool`).
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub prefab: Option<PrefabId>,
409 /// The jigsaw prefab pool bound to this area (mutually exclusive with
410 /// `prefab`); requires `pieces`.
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub prefab_pool: Option<PoolId>,
413 /// Jigsaw piece-count bounds (only with `prefab_pool`).
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub pieces: Option<Pieces>,
416 /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
417 /// present, the compiler's relight pass guarantees `min_light` on every
418 /// reachable walkable cell of this area by placing the declared fixture, or
419 /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
420 /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub lighting: Option<AreaLighting>,
423 /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
424 /// the compiler emit a clocked `effect give` over this area's placed bounds and
425 /// is the (only) declaration that satisfies `DW0210` without `lighting`.
426 /// Independent of `lighting`: an area may declare both (fixtures *and* the
427 /// effect), either, or neither.
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub mitigation: Option<AreaMitigation>,
430}
431
432/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
433#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
434#[serde(deny_unknown_fields)]
435pub struct Pieces {
436 /// Minimum number of pieces to assemble.
437 pub min: u32,
438 /// Maximum number of pieces to assemble.
439 pub max: u32,
440}
441
442// ---------------------------------------------------------------------------
443// Stage 2 — npcs
444// ---------------------------------------------------------------------------
445
446/// Stage 2 payload: the campaign's NPCs (casting sheets).
447#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
448#[serde(deny_unknown_fields)]
449pub struct NpcsContent {
450 /// All NPCs in the campaign.
451 pub npcs: Vec<Npc>,
452}
453
454/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
455///
456/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
457/// contract the stage-6 `dialogue` tree must honor.
458#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
459#[serde(deny_unknown_fields)]
460pub struct Npc {
461 /// Unique NPC id.
462 pub id: NpcId,
463 /// Player-facing name.
464 pub name: String,
465 /// NPC role.
466 pub role: Role,
467 /// The area this NPC stands in (stage-1 ref).
468 pub area: AreaId,
469 /// The prefab anchor this NPC stands on.
470 pub anchor: AnchorId,
471 /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
472 pub base_entity: String,
473 /// The structured persona (character contract for stage 6).
474 pub persona: Persona,
475 /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
476 /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
477 /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
478 /// Non-skinned NPCs are byte-identical to v0.3.
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub skin: Option<NpcSkin>,
481 /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
482 /// world init — its body and interaction hitbox only appear when a
483 /// [`QuestEffect::SpawnNpc`] fires, at this same `anchor`. The dual of
484 /// `despawn-npc`: a character with a scripted entrance must not stand at its
485 /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
486 /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
487 /// init, byte-identical to pre-0.6.
488 #[serde(default, skip_serializing_if = "is_false")]
489 pub deferred: bool,
490}
491
492/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG ships in the
493/// per-delve resource pack at `assets/delvewright/textures/npc/<texture_id>.png`
494/// (sourced from the campaign dir's `skins/<texture_id>.png`); the mannequin's
495/// `profile.texture` resolves to `delvewright:npc/<texture_id>`.
496#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
497#[serde(deny_unknown_fields)]
498pub struct NpcSkin {
499 /// Skin id: the PNG basename under `skins/` and the resource-pack texture
500 /// path segment (a bare kebab token; validated by `DW0190`).
501 pub texture_id: String,
502 /// Player model. **Required** (spec-0009): an omitted model renders slim, so
503 /// a wide skin on a slim model is distorted — the compiler always emits it.
504 pub model: SkinModel,
505}
506
507/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
508/// Alex). Emitted verbatim into the mannequin `profile.model`.
509#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
510#[serde(rename_all = "kebab-case")]
511pub enum SkinModel {
512 /// Classic 4-pixel arms (Steve).
513 Wide,
514 /// Slim 3-pixel arms (Alex).
515 Slim,
516}
517
518impl SkinModel {
519 /// The vanilla `profile.model` token.
520 pub fn token(self) -> &'static str {
521 match self {
522 SkinModel::Wide => "wide",
523 SkinModel::Slim => "slim",
524 }
525 }
526}
527
528/// A structured casting sheet (owner decision 2026-07-30). Structure lives in the
529/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
530/// are required; the rest are optional.
531#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
532#[serde(deny_unknown_fields)]
533pub struct Persona {
534 /// One-line character archetype (required).
535 pub archetype: String,
536 /// How the NPC speaks — register, tics, formality (required).
537 pub speech_style: String,
538 /// Emotional bearing toward the player (optional).
539 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub demeanor: Option<String>,
541 /// What the NPC wants (required).
542 pub motivation: String,
543 /// Something the NPC hides (optional).
544 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub secret: Option<String>,
546 /// Backstory colour (optional).
547 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub backstory: Option<String>,
549 /// Attitudes toward other same-stage NPCs (optional; refs validated).
550 #[serde(default, skip_serializing_if = "Vec::is_empty")]
551 pub relationships: Vec<Relationship>,
552}
553
554/// One persona relationship: an attitude toward another same-stage NPC.
555#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
556#[serde(deny_unknown_fields)]
557pub struct Relationship {
558 /// The other NPC (stage-2 ref, validated within stage 2).
559 pub npc: NpcId,
560 /// Free-text attitude toward that NPC.
561 pub attitude: String,
562}
563
564/// NPC role. `vendor` and `boss` are reserved (rejected in v0, `DW0141`).
565#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
566#[serde(rename_all = "kebab-case")]
567pub enum Role {
568 /// Gives and advances quests.
569 QuestGiver,
570 /// Flavor only.
571 Flavor,
572 /// Reserved (M2).
573 Vendor,
574 /// Reserved (M2).
575 Boss,
576}
577
578impl Role {
579 /// The reserved value name if this role is not implemented in v0.
580 pub fn reserved(self) -> Option<&'static str> {
581 match self {
582 Role::Vendor => Some("vendor"),
583 Role::Boss => Some("boss"),
584 Role::QuestGiver | Role::Flavor => None,
585 }
586 }
587}
588
589// ---------------------------------------------------------------------------
590// Stage 6 — dialogue
591// ---------------------------------------------------------------------------
592
593/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
594#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
595#[serde(deny_unknown_fields)]
596pub struct DialogueContent {
597 /// One tree per NPC (1:1 with stage 2, both directions).
598 pub dialogues: Vec<NpcDialogue>,
599}
600
601impl DialogueContent {
602 /// The dialogue tree for an NPC id, if present.
603 pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
604 self.dialogues.iter().find(|t| t.npc.as_str() == npc)
605 }
606}
607
608/// One NPC's dialogue tree: a root node plus a set of nodes.
609#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
610#[serde(deny_unknown_fields)]
611pub struct NpcDialogue {
612 /// The NPC this tree belongs to (stage-2 ref).
613 pub npc: NpcId,
614 /// The entry node id; every node must be reachable from it.
615 pub root: DialogueId,
616 /// The dialogue nodes.
617 pub nodes: Vec<DialogueNode>,
618}
619
620/// One dialogue node: text plus branching options.
621#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
622#[serde(deny_unknown_fields)]
623pub struct DialogueNode {
624 /// Node id (unique within this NPC's dialogue).
625 pub id: DialogueId,
626 /// The line the NPC speaks.
627 pub text: String,
628 /// Branching options; empty closes the dialog.
629 #[serde(default, skip_serializing_if = "Vec::is_empty")]
630 pub options: Vec<DialogueOption>,
631}
632
633/// One selectable dialogue option.
634#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
635#[serde(deny_unknown_fields)]
636pub struct DialogueOption {
637 /// Button label.
638 pub label: String,
639 /// The full line this button is the caption of (DSL v0.8, owner design
640 /// 2026-08-04; reserved `DW0141` earlier). Vanilla's dialog button codec
641 /// (`CommonButtonData`) carries an optional `tooltip` component beside
642 /// `label`, and the client hangs it on the button as a real hover tooltip —
643 /// so a caption on the button and the sentence the character actually says
644 /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
645 /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
646 /// scrolls. Player-visible, so it translates like the label
647 /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub tooltip: Option<String>,
650 /// Next node; omitted closes the dialog.
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub next: Option<DialogueId>,
653 /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
654 /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
655 /// gated option is hidden until every referenced flag has been set by a
656 /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
657 /// option cannot make a critical-path node unreachable (`DW0191`).
658 #[serde(default, skip_serializing_if = "Vec::is_empty")]
659 pub requires_flags: Vec<FlagId>,
660 /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the option is
661 /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
662 /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
663 /// option counts as *gated* for the `DW0191` deadlock guard: it can be
664 /// suppressed at any point, so it cannot be the only completing path.
665 #[serde(default, skip_serializing_if = "Vec::is_empty")]
666 pub forbids_flags: Vec<FlagId>,
667 /// Effects fired when this option is chosen.
668 #[serde(default, skip_serializing_if = "Vec::is_empty")]
669 pub effects: Vec<DialogueEffect>,
670 /// What choosing this option does to the story (DSL v0.8, spec-0025;
671 /// reserved `DW0141` earlier). Required at 0.8.0 for a **story-weight** beat
672 /// — an option carrying a `set-flag` effect, which is how a player's choice
673 /// forks the world (`DW0481`). An option that only walks the tree or
674 /// completes an objective needs none: the objective already declares one.
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub happening: Option<Happening>,
677}
678
679/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
680/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
681/// dialogue choice, enabling flag-gated options/objectives/triggers).
682#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
683#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
684pub enum DialogueEffect {
685 /// Marks a stage-5 objective complete.
686 CompleteObjective {
687 /// The objective to complete (resolved at the stage-5 boundary).
688 objective: ObjectiveId,
689 },
690 /// Sets a campaign flag (DSL v0.4), mirroring [`QuestEffect::SetFlag`].
691 SetFlag {
692 /// The flag to set.
693 flag: FlagId,
694 },
695 /// Cuts the world time (DSL v0.5), mirroring [`QuestEffect::SetTime`].
696 SetTime {
697 /// The time state to cut to.
698 time: WorldTime,
699 },
700 /// Cuts the weather (DSL v0.5), mirroring [`QuestEffect::SetWeather`].
701 SetWeather {
702 /// The weather state to cut to.
703 weather: WorldWeather,
704 },
705 /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
706 /// [`QuestEffect::SetCheckpoint`] — usable from a dialogue outcome.
707 SetCheckpoint {
708 /// The prefab checkpoint anchor the party respawns at.
709 anchor: AnchorId,
710 /// Per-player effects re-run on respawn while this checkpoint is active
711 /// (scene reset). Empty = no hook.
712 #[serde(default, skip_serializing_if = "Vec::is_empty")]
713 on_respawn: Vec<QuestEffect>,
714 },
715 /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
716 /// [`QuestEffect::SpawnNpc`] — a character who walks in mid-conversation.
717 SpawnNpc {
718 /// The NPC (stage-2 ref) to summon.
719 npc: NpcId,
720 },
721}
722
723impl DialogueEffect {
724 /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
725 /// (`set-time`/`set-weather`, spec-0010).
726 pub fn v05_effect(&self) -> Option<&'static str> {
727 match self {
728 DialogueEffect::SetTime { .. } => Some("set-time"),
729 DialogueEffect::SetWeather { .. } => Some("set-weather"),
730 _ => None,
731 }
732 }
733
734 /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
735 /// (`set-checkpoint`, spec-0012; `spawn-npc`). Reserved (`DW0141`) in an
736 /// earlier campaign.
737 pub fn v06_effect(&self) -> Option<&'static str> {
738 match self {
739 DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
740 DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
741 _ => None,
742 }
743 }
744
745 /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
746 pub fn spawn_npc(&self) -> Option<&NpcId> {
747 match self {
748 DialogueEffect::SpawnNpc { npc } => Some(npc),
749 _ => None,
750 }
751 }
752
753 /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
754 pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
755 match self {
756 DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
757 Some((anchor, on_respawn.as_slice()))
758 }
759 _ => None,
760 }
761 }
762
763 /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
764 /// **effect root 5** ([`crate::effects`]). Named separately from
765 /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
766 /// same accessor modulo mutability, which is what lets one macro body generate
767 /// both.
768 pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
769 match self {
770 DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
771 _ => None,
772 }
773 }
774
775 /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
776 /// mutably so the localization pass can rewrite the player-visible strings
777 /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
778 /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
779 /// that reaches it read-only needs a way to reach it writable too.
780 pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
781 match self {
782 DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
783 _ => None,
784 }
785 }
786}
787
788impl DialogueEffect {
789 /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
790 pub fn set_flag(&self) -> Option<&FlagId> {
791 match self {
792 DialogueEffect::SetFlag { flag } => Some(flag),
793 _ => None,
794 }
795 }
796
797 /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
798 /// (`set-flag`). Reserved (`DW0141`) in an earlier campaign.
799 pub fn v04_effect(&self) -> Option<&'static str> {
800 match self {
801 DialogueEffect::SetFlag { .. } => Some("set-flag"),
802 _ => None,
803 }
804 }
805
806 /// The target time if this is a v0.5 `set-time` dialogue effect.
807 pub fn set_time(&self) -> Option<WorldTime> {
808 match self {
809 DialogueEffect::SetTime { time } => Some(*time),
810 _ => None,
811 }
812 }
813
814 /// The target weather if this is a v0.5 `set-weather` dialogue effect.
815 pub fn set_weather(&self) -> Option<WorldWeather> {
816 match self {
817 DialogueEffect::SetWeather { weather } => Some(*weather),
818 _ => None,
819 }
820 }
821}
822
823// ---------------------------------------------------------------------------
824// Stage 3 — classes
825// ---------------------------------------------------------------------------
826
827/// Stage 3 payload: 1..4 selectable classes.
828#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
829#[serde(deny_unknown_fields)]
830pub struct ClassesContent {
831 /// The selectable classes.
832 pub classes: Vec<Class>,
833}
834
835/// A player class with a starting kit.
836#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
837#[serde(deny_unknown_fields)]
838pub struct Class {
839 /// Unique class id.
840 pub id: ClassId,
841 /// Player-facing name.
842 pub name: String,
843 /// Selection-screen blurb.
844 pub blurb: String,
845 /// Granted items.
846 pub kit: Vec<KitItem>,
847}
848
849/// One item in a class kit.
850///
851/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
852/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
853/// document using them is rejected as an unknown field (`DW0100`).
854#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
855#[serde(deny_unknown_fields)]
856pub struct KitItem {
857 /// Vanilla item id, validated against the pinned 1.21.11 registry.
858 pub item: String,
859 /// Stack count.
860 pub count: u32,
861 /// Optional display name.
862 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub name: Option<String>,
864 /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
865 /// per-player gear by construction — every player who picks the class gets the
866 /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
867 /// enters the party, given to the first player to pick this class.
868 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub carrier: Option<Carrier>,
870 /// **The flask** (DSL v0.8, spec-0016 §1, owner ruling 2026-08-03): this kit
871 /// entry is the class's recovery item, and resting at a bonfire replenishes
872 /// it to exactly `count`. A campaign that places a `bonfire` and declares no
873 /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
874 /// dying an investment, so a souls campaign without one is a build error, not
875 /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
876 #[serde(default, skip_serializing_if = "is_false")]
877 pub flask: bool,
878 /// **What is in the bottle** (DSL v0.8, spec-0016 §1, owner directive
879 /// 2026-08-03): the vanilla `minecraft:potion_contents` component of a
880 /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
881 /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
882 /// nothing — which is exactly the placeholder flask this field exists to
883 /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
884 /// no `contents` is `DW0487`.
885 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub contents: Option<PotionContents>,
887}
888
889/// The items whose vanilla item definition carries a `minecraft:potion_contents`
890/// component — the only items a kit `contents` may be declared on (`DW0486`).
891///
892/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
893/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
894/// misode/mcmeta ref `crates/compiler/data/PROVENANCE.md` pins): exactly these
895/// four items declare the component, and on any other item the game drops the
896/// data on the floor.
897pub const POTION_BEARING_ITEMS: &[&str] = &[
898 "minecraft:lingering_potion",
899 "minecraft:potion",
900 "minecraft:splash_potion",
901 "minecraft:tipped_arrow",
902];
903
904/// True if `item_id` (optionally un-namespaced) is one of the four
905/// [`POTION_BEARING_ITEMS`].
906pub fn is_potion_bearing_item(item_id: &str) -> bool {
907 let norm = if item_id.contains(':') {
908 item_id.to_string()
909 } else {
910 format!("minecraft:{item_id}")
911 };
912 POTION_BEARING_ITEMS.contains(&norm.as_str())
913}
914
915/// The two **instantaneous** status effects: they are applied once, on the tick
916/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
917/// `isInstantenous` before any effect instance is ever added), so a `duration` on
918/// one is a statement the game never reads — `DW0486` says so rather than letting
919/// an author believe they wrote a thirty-second heal.
920pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
921
922/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
923/// modelled field for field on vanilla rather than invented: a **named** potion,
924/// a list of **custom effects**, or both, plus the bottle-colour override.
925///
926/// Vanilla resolves a drink as the named potion's effects followed by the custom
927/// ones, and derives the bottle colour from those effects unless `color`
928/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
929/// Potion of Healing II a player would brew, not an approximation of one.
930#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
931#[serde(deny_unknown_fields)]
932pub struct PotionContents {
933 /// A named vanilla potion id (`minecraft:strong_healing`,
934 /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
935 /// `potion` registry (`DW0486`).
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub potion: Option<String>,
938 /// Custom effects applied on top of (or instead of) the named potion — the
939 /// escape hatch for a recovery item vanilla has no brew for.
940 #[serde(default, skip_serializing_if = "Vec::is_empty")]
941 pub effects: Vec<PotionEffect>,
942 /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
943 /// colour vanilla derives from the effects themselves.
944 #[serde(default, skip_serializing_if = "Option::is_none")]
945 pub color: Option<String>,
946}
947
948/// One entry of a potion's `custom_effects` list.
949#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
950#[serde(deny_unknown_fields)]
951pub struct PotionEffect {
952 /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
953 /// against the pinned registry (`DW0486`).
954 pub effect: String,
955 /// How long it lasts, in **ticks** (20 = one second). Required for every
956 /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
957 #[serde(default, skip_serializing_if = "Option::is_none")]
958 pub duration: Option<u32>,
959 /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
960 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub amplifier: Option<u32>,
962}
963
964impl PotionEffect {
965 /// True if this effect is applied once on drinking rather than over time.
966 pub fn is_instant(&self) -> bool {
967 let norm = if self.effect.contains(':') {
968 self.effect.clone()
969 } else {
970 format!("minecraft:{}", self.effect)
971 };
972 INSTANT_EFFECTS.contains(&norm.as_str())
973 }
974}
975
976/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
977/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
978/// need is refused — while a duration typed in *milliseconds*, or one that would
979/// overflow vanilla's int, is caught (`DW0486`).
980pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
981
982/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
983/// unsigned byte, so 255 is not a policy but the end of the field.
984pub const MAX_POTION_AMPLIFIER: u32 = 255;
985
986/// The canonical English title of the bonfire rest dialog (owner ruling
987/// 2026-08-03). Baked at emit time when the campaign authors no `prompt`, in the
988/// `world.boundary.message` tradition: a compiler default is not inventoried, an
989/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
990pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
991/// The canonical English label of the **rest and save** option.
992pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
993/// The canonical English label of the **save only** option.
994pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
995
996/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
997/// leaves the compiler's canonical English in place
998/// ([`QuestEffect::bonfire_labels`]).
999#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1000pub struct BonfireLabels<'a> {
1001 /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
1002 pub prompt: Option<&'a str>,
1003 /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
1004 pub rest_label: Option<&'a str>,
1005 /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
1006 pub save_label: Option<&'a str>,
1007}
1008
1009impl BonfireLabels<'_> {
1010 /// The dialog title actually emitted.
1011 pub fn prompt_or_default(&self) -> &str {
1012 self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
1013 }
1014 /// The **rest and save** label actually emitted.
1015 pub fn rest_or_default(&self) -> &str {
1016 self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
1017 }
1018 /// The **save only** label actually emitted.
1019 pub fn save_or_default(&self) -> &str {
1020 self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
1021 }
1022}
1023
1024// ---------------------------------------------------------------------------
1025// Stage 4 — quest-plan
1026// ---------------------------------------------------------------------------
1027
1028/// Stage 4 payload: the quest dependency plan.
1029#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1030#[serde(deny_unknown_fields)]
1031pub struct QuestPlanContent {
1032 /// Planned quests (expanded in stage 5).
1033 pub quests: Vec<PlannedQuest>,
1034 /// The quest whose completion ends the campaign.
1035 pub finale: QuestId,
1036 /// The campaign's declared **story forks** (DSL v0.8, spec-0025; reserved
1037 /// `DW0141` earlier). Empty/absent = a campaign that claims to have no
1038 /// branch — which the compiler then *verifies* rather than assumes: any flag
1039 /// that gates casts, staging or structure and is set on some playthroughs and
1040 /// not others belongs to no declared point and is `DW0480`.
1041 ///
1042 /// Enumerated branches are the **product of the declared points**, so the
1043 /// branch set is authored and small — never a combinatorial sweep of every
1044 /// flag in the campaign.
1045 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1046 pub branch_points: Vec<BranchPoint>,
1047}
1048
1049/// One declared story fork (DSL v0.8, spec-0025).
1050///
1051/// A branch point names the flag set the story forks on, the quest at which the
1052/// fork opens, and every branch it offers. Each branch pins the point's whole
1053/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
1054/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
1055/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
1056#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1057#[serde(deny_unknown_fields)]
1058pub struct BranchPoint {
1059 /// Unique branch-point id.
1060 pub id: BranchPointId,
1061 /// The quest at which the fork opens — every branch's divergent content is at
1062 /// or after it in the stage-4 DAG.
1063 pub opens_at: QuestId,
1064 /// The flag set the story forks on. Every branch's `flags` is a subset of
1065 /// this, and the flags it does not list are pinned **unset** on that branch.
1066 pub forks_on: Vec<FlagId>,
1067 /// The alternatives (≥ 2).
1068 pub branches: Vec<BranchDecl>,
1069}
1070
1071/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
1072#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1073#[serde(deny_unknown_fields)]
1074pub struct BranchDecl {
1075 /// Unique branch id (campaign-wide — it names the emitted chronicle file).
1076 pub id: BranchId,
1077 /// The subset of the point's `forks_on` that is SET on this branch. The rest
1078 /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
1079 /// option" branch — as long as it is genuinely reachable.
1080 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1081 pub flags: Vec<FlagId>,
1082 /// Where this branch goes: either the `quest/<kebab>` the branches converge
1083 /// at, or the `ending/<kebab>` this branch runs to.
1084 ///
1085 /// One field, not two mutually exclusive ones, because the **id prefix
1086 /// already says which it is** — the same convention every cross-stage
1087 /// reference in the DSL uses. That is what makes "exactly one of them" an
1088 /// unrepresentable state rather than a rule some diagnostic has to police: a
1089 /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
1090 /// the ordinary malformed-id `DW0110`, and one that names nothing is the
1091 /// ordinary dangling-reference `DW0112`.
1092 pub leads_to: String,
1093}
1094
1095impl BranchDecl {
1096 /// The convergence quest, if [`Self::leads_to`] names one.
1097 pub fn converges_at(&self) -> Option<QuestId> {
1098 let q = QuestId(self.leads_to.clone());
1099 q.is_valid_syntax().then_some(q)
1100 }
1101
1102 /// The ending, if [`Self::leads_to`] names one.
1103 pub fn ending(&self) -> Option<EndingId> {
1104 let e = EndingId(self.leads_to.clone());
1105 e.is_valid_syntax().then_some(e)
1106 }
1107}
1108
1109/// What a story node does to the story (DSL v0.8, spec-0025).
1110///
1111/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
1112/// design that never got written down node by node cannot compile. It is
1113/// **node-local on purpose** — there is no parallel per-branch script document
1114/// that could itself drift from the graph.
1115///
1116/// `text` is authoring/validation metadata, never shown to a player, so it is
1117/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
1118/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
1119/// chronicle is assembled from.
1120#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1121#[serde(deny_unknown_fields)]
1122pub struct Happening {
1123 /// The structured event verb — the machine-decidable part.
1124 pub verb: HappeningVerb,
1125 /// One line of prose stating what this node does to the story.
1126 pub text: String,
1127 /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
1128 /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
1129 /// story token the campaign tracks by hand. Optional, because not every beat
1130 /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
1131 /// over the beats that name one.
1132 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub subject: Option<String>,
1134}
1135
1136/// The structured event vocabulary (DSL v0.8, spec-0025).
1137///
1138/// Deliberately small and closed. These ten verbs are what make a subset of
1139/// narrative errors machine-decidable per branch (`DW0485`); everything else a
1140/// beat means lives in [`Happening::text`], which the compiler never interprets.
1141/// Extend only when a real campaign cannot state its beat with what is here.
1142#[derive(
1143 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1144)]
1145#[serde(rename_all = "kebab-case")]
1146pub enum HappeningVerb {
1147 /// The subject is killed / destroyed. Terminal: nothing the subject does may
1148 /// follow it on the same branch.
1149 Dies,
1150 /// The subject comes through alive — the explicit counterpart of `dies`,
1151 /// which is what lets a branch state that somebody *did not* die.
1152 Survives,
1153 /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
1154 Departs,
1155 /// The subject enters the stage.
1156 Arrives,
1157 /// Somebody learns a fact — the true-information beat.
1158 Learns,
1159 /// Somebody comes to believe something (whether or not it is true) — the beat
1160 /// that carries a wrong belief forward, which is where branch drift shows.
1161 Believes,
1162 /// The party (or the subject) gains a thing.
1163 Gains,
1164 /// The party (or the subject) loses a thing. A second `loses` with no
1165 /// intervening `gains` is spending what is already spent (`DW0485`).
1166 Loses,
1167 /// A way is opened.
1168 Opens,
1169 /// A way is sealed. Cleared by `opens`.
1170 Seals,
1171}
1172
1173/// One planned quest (dependency-graph node).
1174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1175#[serde(deny_unknown_fields)]
1176pub struct PlannedQuest {
1177 /// Unique quest id.
1178 pub id: QuestId,
1179 /// Human-readable goal.
1180 pub goal: String,
1181 /// Area this quest takes place in (stage-1 ref).
1182 pub area: AreaId,
1183 /// NPCs involved (stage-2 refs).
1184 pub npcs: Vec<NpcId>,
1185 /// Prerequisite quests; edges must form a DAG.
1186 pub depends_on: Vec<QuestId>,
1187 /// v0 requires `true`; optional quests are reserved (`DW0133`).
1188 pub mandatory: bool,
1189 /// Act number (informational).
1190 pub act: u32,
1191}
1192
1193// ---------------------------------------------------------------------------
1194// Stage 5 — quests
1195// ---------------------------------------------------------------------------
1196
1197/// Stage 5 payload: quest expansions.
1198#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1199#[serde(deny_unknown_fields)]
1200pub struct QuestsContent {
1201 /// The expanded quests (1:1 with stage 4).
1202 pub quests: Vec<Quest>,
1203 /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
1204 /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
1205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1206 pub waves: Vec<Wave>,
1207 /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
1208 /// watches an anchor for a strike / use / approach event and fires a bundle
1209 /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
1210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1211 pub triggers: Vec<EnvTrigger>,
1212 /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
1213 /// compiler-emitted per-tick teleport (task #46). Distinct from stage-2 NPCs
1214 /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
1215 /// staging effects. Empty/absent before v0.6.
1216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1217 pub actors: Vec<Actor>,
1218 /// Traps (DSL v0.6, spec-0011): redstone-native environmental hazards bound to
1219 /// `anchor/trap` prefab markers. Each gives mute trap hardware meaning — its
1220 /// trigger, dispenser payload, lethality, disarm, and reset. Empty/absent in
1221 /// pre-0.6 campaigns (reserved `DW0141`), so a v0.5-or-earlier campaign that
1222 /// declares none stays byte-identical.
1223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1224 pub traps: Vec<Trap>,
1225 /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
1226 /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
1227 /// campaigns (reserved `DW0141`), so a campaign that declares none stays
1228 /// byte-identical.
1229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1230 pub shortcuts: Vec<Shortcut>,
1231 /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
1232 /// springs them". Empty/absent in pre-0.6 campaigns (reserved `DW0141`).
1233 ///
1234 /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
1235 /// each ambush into `triggers`, so the canonical form of a campaign is its
1236 /// **desugared** form. That is what keeps the canonical round-trip idempotent
1237 /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
1238 /// second time and duplicate the trigger ids), and it means the sugar exists
1239 /// at exactly one layer boundary — the authored `.json` — with nothing
1240 /// downstream needing to know it was ever there. The list itself is kept in
1241 /// memory so diagnostics can name the ambush the author wrote.
1242 /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
1243 /// Empty/absent in pre-0.6 campaigns (reserved `DW0141`).
1244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1245 pub timed_gates: Vec<TimedGate>,
1246 /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
1247 /// given contents at world init. Empty/absent in pre-0.6 campaigns
1248 /// (reserved `DW0141`).
1249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1250 pub loot: Vec<Loot>,
1251 #[serde(default, skip_serializing)]
1252 pub ambushes: Vec<Ambush>,
1253 /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
1254 /// authored `ambushes` are deliberately KEPT after expansion so validation
1255 /// and the counterplay proof can attribute diagnostics to the ambush the
1256 /// author actually wrote; this flag is what makes a second expansion a no-op.
1257 #[serde(skip)]
1258 pub ambushes_expanded: bool,
1259}
1260
1261impl QuestsContent {
1262 /// Every environment trigger this stage produces: the authored `triggers`
1263 /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
1264 /// declared order.
1265 ///
1266 /// **This is the single trigger authority.** Validation, the l10n
1267 /// inventory, the flag/wave producer scans, the nav proofs and emission all
1268 /// read triggers through it, so an ambush behaves exactly like the trigger
1269 /// an author would otherwise hand-write — there is no second code path for
1270 /// the sugar to drift down. Deterministic (declaration order, no hashing).
1271 pub fn all_triggers(&self) -> Vec<EnvTrigger> {
1272 let mut out = self.triggers.clone();
1273 out.extend(self.ambushes.iter().map(Ambush::to_trigger));
1274 out
1275 }
1276
1277 /// Desugar every `ambush` into a real environment trigger and clear the
1278 /// ambush list (spec-0016 §3). Called once, by
1279 /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
1280 /// (a second call sees no ambushes left to expand).
1281 pub fn expand_ambushes(&mut self) {
1282 if self.ambushes_expanded || self.ambushes.is_empty() {
1283 return;
1284 }
1285 self.triggers = self.all_triggers();
1286 self.ambushes_expanded = true;
1287 }
1288}
1289
1290/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
1291/// barrel the prefab already placed.
1292///
1293/// The container is **hardware the prefab authored**, exactly like a trap's
1294/// dispenser: this declaration gives an already-placed, already-lit, already
1295/// composed piece of furniture its contents. The compiler never places the
1296/// container itself — if the anchor's cell does not already hold one, that is a
1297/// content defect and a build error (`DW0431`), not something to paper over by
1298/// setblock-ing a chest into a wall.
1299#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1300#[serde(deny_unknown_fields)]
1301pub struct Loot {
1302 /// Unique loot id (`loot/<kebab>`).
1303 pub id: LootId,
1304 /// The anchor whose cell holds the container to fill.
1305 pub anchor: AnchorId,
1306 /// Contents, in declaration order. Slot assignment is positional and
1307 /// deterministic — the first entry lands in `container.0`, the second in
1308 /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
1309 pub items: Vec<LootItem>,
1310}
1311
1312/// One stack inside a [`Loot`] container.
1313#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1314#[serde(deny_unknown_fields)]
1315pub struct LootItem {
1316 /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
1317 /// 1.21.11 item registry (`DW0143`).
1318 pub item: String,
1319 /// Stack size. Defaults to 1.
1320 #[serde(default = "one_u32")]
1321 pub count: u32,
1322 /// Optional custom item name. Enters the l10n string inventory exactly like
1323 /// a class kit item's name.
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1325 pub name: Option<String>,
1326 /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
1327 /// 1.21 `minecraft:enchantments` item component.
1328 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1329 pub enchantments: BTreeMap<String, u32>,
1330}
1331
1332fn one_u32() -> u32 {
1333 1
1334}
1335
1336/// A stage-5 trap (DSL v0.6, spec-0011): a redstone-native environmental hazard.
1337/// The trap *mechanism* — the trigger (pressure plate / tripwire / trapped chest)
1338/// wired to a pre-placed empty dispenser — lives in the `.nbt` prefab at an
1339/// `anchor/trap` marker; this declaration gives the mute hardware meaning. The
1340/// compiler fills the dispenser payload, models the trigger cell as a hazard for
1341/// the completability proofs (`DW0342`), and — for a disarmable trap — emits the
1342/// disarm affordance. No detection is emitted for the harm itself: the redstone
1343/// fires it ("harm is redstone-native", spec-0011). Player-vs-mob distinguishing
1344/// matters in a sealed box-garden with controlled mobs, so `trapped-chest` (opened
1345/// by a player) is called out as the only player-distinct trigger.
1346#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1347#[serde(deny_unknown_fields)]
1348pub struct Trap {
1349 /// Unique trap id (`trap/<kebab>`).
1350 pub id: TrapId,
1351 /// The `anchor/trap` marker this trap binds to. Its cell is the trigger/hazard
1352 /// cell the compiler models; the anchor also carries the dispenser socket the
1353 /// payload fills.
1354 pub at: AnchorId,
1355 /// What springs the trap (all redstone-native).
1356 pub trigger: TrapTrigger,
1357 /// The **legacy** redstone consequence (spec-0011): a static dispenser
1358 /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
1359 /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
1360 /// so existing campaigns build unchanged. Optional since spec-0022; a trap
1361 /// must declare `effect`, `payload`, or both (`DW0440`).
1362 #[serde(default, skip_serializing_if = "Option::is_none")]
1363 pub effect: Option<TrapEffect>,
1364 /// The **command payload** (spec-0022): an ordered effect list in the same
1365 /// vocabulary quests use, run when the trigger fires. This is where a trap's
1366 /// consequence lives now — the compiler owns the detection tick and the
1367 /// effect vocabulary, so a trap's payload is authored like any other effect
1368 /// bundle rather than built out of dust and repeaters. Expressiveness moves
1369 /// from "what dust can carry" to "what the effect vocabulary can say":
1370 /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
1371 /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
1372 ///
1373 /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
1374 /// emitted before (byte-identical).
1375 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1376 pub payload: Vec<QuestEffect>,
1377 /// How dangerous the trap is. A `lethal` trap on the forced critical path
1378 /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
1379 /// carry none. Defaults to `harmful`.
1380 #[serde(default)]
1381 pub lethality: Lethality,
1382 /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
1383 /// turn the trap off — setting a flag and emptying the dispenser — before the
1384 /// trap cell is forced.
1385 #[serde(default, skip_serializing_if = "Option::is_none")]
1386 pub disarm: Option<TrapDisarm>,
1387 /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
1388 /// spent — the survivability path); `rearm` = re-fires each trigger (default).
1389 #[serde(default)]
1390 pub reset: TrapReset,
1391 /// Flags that must be set before the trap is considered active (mirrors
1392 /// [`EnvTrigger::requires_flags`]). Default empty.
1393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1394 pub requires_flags: Vec<FlagId>,
1395 /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
1396 /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
1397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398 pub forbids_flags: Vec<FlagId>,
1399}
1400
1401impl Trap {
1402 /// `(item, count)` if this trap declares a legacy `dispense` effect.
1403 pub fn dispense(&self) -> Option<(&str, u32)> {
1404 match &self.effect {
1405 Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
1406 None => None,
1407 }
1408 }
1409
1410 /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
1411 /// forced critical path).
1412 pub fn is_lethal(&self) -> bool {
1413 matches!(self.lethality, Lethality::Lethal)
1414 }
1415}
1416
1417/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
1418/// redstone-native — the hardware fires without any command — so the compiler
1419/// emits no detection for them; it only models the trigger cell as a hazard and
1420/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
1421/// is deliberately *not* a trap trigger: it is already fully expressible as an
1422/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
1423#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1424#[serde(rename_all = "kebab-case")]
1425pub enum TrapTrigger {
1426 /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
1427 PressurePlate,
1428 /// A tripwire line: any entity crossing it. Auto-rearms.
1429 Tripwire,
1430 /// A trapped chest: a *player opening it* (comparator pulse). The only
1431 /// player-distinct trigger — a controlled mob cannot spring it.
1432 TrappedChest,
1433}
1434
1435impl TrapTrigger {
1436 /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
1437 pub fn kind(&self) -> &'static str {
1438 match self {
1439 TrapTrigger::PressurePlate => "pressure-plate",
1440 TrapTrigger::Tripwire => "tripwire",
1441 TrapTrigger::TrappedChest => "trapped-chest",
1442 }
1443 }
1444}
1445
1446/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
1447/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
1448/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
1449/// block-destroying and unmodeled effects out of the schema by construction
1450/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
1451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1452#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1453pub enum TrapEffect {
1454 /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
1455 /// arrows, splash potions). The redstone fires it; terrain is untouched
1456 /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
1457 /// `Items` NBT — a deterministic, static payload.
1458 Dispense {
1459 /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
1460 item: String,
1461 /// How many to load into the dispenser stack.
1462 count: u32,
1463 },
1464}
1465
1466/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
1467/// forced-critical-path completability obligation (`DW0342`).
1468#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1469#[serde(rename_all = "kebab-case")]
1470pub enum Lethality {
1471 /// Can kill a full-health player — carries the `DW0342` obligation on the path.
1472 Lethal,
1473 /// Hurts but is not designed to kill (the default).
1474 #[default]
1475 Harmful,
1476 /// Cosmetic / trivial (a stumble, a scare).
1477 Nonlethal,
1478}
1479
1480/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
1481#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1482#[serde(rename_all = "kebab-case")]
1483pub enum TrapReset {
1484 /// Fires once, then is spent — the survivability path for a forced lethal trap
1485 /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
1486 /// soft-loop).
1487 Once,
1488 /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
1489 /// trap must be avoidable or disarmable, else `DW0342`.
1490 #[default]
1491 Rearm,
1492}
1493
1494/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
1495/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
1496/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
1497/// before the trap cell is forced. Discharges the `DW0342` obligation when the
1498/// affordance is reachable ahead of the trap without crossing it.
1499#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1500#[serde(deny_unknown_fields)]
1501pub struct TrapDisarm {
1502 /// The anchor the player interacts with to disarm.
1503 pub via: AnchorId,
1504 /// The flag set when the trap is disarmed (a new flag this trap produces; other
1505 /// objectives/triggers may read it via `requires_flags`).
1506 pub sets_flag: FlagId,
1507}
1508
1509/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
1510/// deterministic open/close clock, so passage is a timing read rather than a
1511/// permanent state.
1512///
1513/// **The proof is deliberately NOT all-phase passability** (owner ruling
1514/// 2026-08-02) — a gate that punishes bad timing is the entire point. What the
1515/// compiler requires is that the gate is *readable*: the set of entry phases from
1516/// which a walking player clears the span before it shuts must cover **≥ 20% of
1517/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
1518/// amount of learning the level makes it fair.
1519#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1520#[serde(deny_unknown_fields)]
1521pub struct TimedGate {
1522 /// Unique timed-gate id (`timed-gate/<kebab>`).
1523 pub id: TimedGateId,
1524 /// The gate anchor the clock drives. Its prefab metadata must declare a fill
1525 /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
1526 pub gate: AnchorId,
1527 /// Ticks the gate stays OPEN each cycle (> 0).
1528 pub open_ticks: u32,
1529 /// Ticks the gate stays CLOSED each cycle (> 0).
1530 pub closed_ticks: u32,
1531 /// Ticks after world init before the first open window begins (default 0) —
1532 /// how two gates in the same room are put out of step with each other. Must
1533 /// be less than the full cycle.
1534 #[serde(default, skip_serializing_if = "is_zero")]
1535 pub phase: u32,
1536 /// Whether the gate **kills** a player caught inside its region when it
1537 /// shuts (owner directive 2026-08-03, spec-0016 §4 addendum). A portcullis
1538 /// that merely shoves you aside teaches nothing; mistiming the crossing is
1539 /// supposed to be a death you learn from, which is the whole point of §4's
1540 /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
1541 /// absolute.
1542 ///
1543 /// This is a real judgement issued by command on the closing tick, not
1544 /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
1545 /// escapable, so it would make the portcullis a suggestion. `damage` at the
1546 /// closing edge is exact and unarguable.
1547 ///
1548 /// **Defaults to `false`**, so every campaign authored before this field
1549 /// existed compiles byte-identically; a delve opts its portcullis in.
1550 #[serde(default, skip_serializing_if = "is_false")]
1551 pub crush: bool,
1552 /// Optional **disarm** affordance (task #184, souls dossier §5.2): the third
1553 /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
1554 /// The real games' best timed hazards can be removed for good (Smouldering
1555 /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
1556 /// dance with is one rung short of the vocabulary.
1557 ///
1558 /// Interacting with the affordance suppresses the clock **permanently, with
1559 /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
1560 /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
1561 /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
1562 ///
1563 /// **Defaults to absent**, so every campaign authored before this field
1564 /// existed compiles byte-identically; a delve opts its portcullis in.
1565 #[serde(default, skip_serializing_if = "Option::is_none")]
1566 pub disarm: Option<TimedGateDisarm>,
1567}
1568
1569/// A [`TimedGate`]'s disarm affordance (task #184, souls dossier §5.2) — the
1570/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
1571/// grammar for every mechanism the party can switch off.
1572///
1573/// The player acts on the `via` anchor (a compiler-emitted interaction entity
1574/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
1575/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
1576/// put the gate back.
1577#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1578#[serde(deny_unknown_fields)]
1579pub struct TimedGateDisarm {
1580 /// The anchor the player interacts with to jam the gate. Must be an anchor
1581 /// some area's prefab provides, and never the gate anchor itself — the
1582 /// mechanism belongs beside the doorway, not inside the span that crushes.
1583 pub via: AnchorId,
1584 /// The flag set when the gate is disarmed (a new flag this gate produces;
1585 /// other objectives/triggers may read it via `requires_flags`).
1586 pub sets_flag: FlagId,
1587}
1588
1589/// serde `skip_serializing_if` helper: skip a `0`.
1590fn is_zero(n: &u32) -> bool {
1591 *n == 0
1592}
1593
1594/// A stage-5 **ambush** (spec-0016 §3) — one declaration for a beat that
1595/// otherwise takes a deferred actor set plus a hand-wired trigger.
1596///
1597/// **`telegraph` is optional, and that is a design ruling, not an oversight**
1598/// (owner, 2026-08-02). The un-telegraphed ambush — the shove off the cliff you
1599/// could not have known about — is core souls vocabulary: 初见杀 is how the level
1600/// teaches. The engine does not sand that edge off.
1601///
1602/// What the engine *does* owe the player is **counterplay on the retry**: having
1603/// died once, an informed player must have something to do about it. Determinism
1604/// guarantees the second attempt meets the same ambushers in the same cells; the
1605/// compiler adds the missing half — `DW0376` proves the trigger cell is not a
1606/// sealed pocket, i.e. that with every ambusher standing where it will stand,
1607/// a route out still exists (a retreat, luring ground, a positioning line).
1608/// Dying uninformed is a lesson; dying with no play available is a broken beat.
1609#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1610#[serde(deny_unknown_fields)]
1611pub struct Ambush {
1612 /// Unique ambush id (`ambush/<kebab>`).
1613 pub id: AmbushId,
1614 /// The anchor the trigger watches — the corner, doorway or ledge the player
1615 /// walks into.
1616 pub at: AnchorId,
1617 /// The stage-5 actors that spring (1..N). Each is summoned at its own
1618 /// declared anchor and immediately unleashed to real AI.
1619 pub actors: Vec<ActorId>,
1620 /// What springs it (`approach{range}` / `strike` / `use`), exactly the v0.4
1621 /// environment-trigger vocabulary.
1622 pub trigger: TriggerOn,
1623 /// The **optional** tell, fired at the trigger before the ambushers exist:
1624 /// a sound, a shadow, a line of narration. Empty = un-telegraphed, which is
1625 /// fully legal (owner ruling 2026-08-02).
1626 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1627 pub telegraph: Vec<QuestEffect>,
1628}
1629
1630impl Ambush {
1631 /// The environment trigger this ambush desugars to (spec-0016 §3): a
1632 /// one-shot trigger at `at` whose effects are the telegraph, then a
1633 /// `spawn-actor` + `unleash-actor` per listed actor, in declared order.
1634 ///
1635 /// This is the **single** expansion authority. Every consumer — validation,
1636 /// the l10n inventory, the flag/wave producer scans, nav, emission — reads
1637 /// triggers through [`QuestsContent::all_triggers`], so the sugar cannot
1638 /// diverge from a hand-written equivalent, and an ambush is exactly as
1639 /// debuggable as the trigger an author would otherwise type.
1640 pub fn to_trigger(&self) -> EnvTrigger {
1641 let mut effects = self.telegraph.clone();
1642 for a in &self.actors {
1643 effects.push(QuestEffect::SpawnActor {
1644 actor: a.clone(),
1645 happening: None,
1646 });
1647 }
1648 for a in &self.actors {
1649 effects.push(QuestEffect::UnleashActor {
1650 actor: a.clone(),
1651 happening: None,
1652 });
1653 }
1654 EnvTrigger {
1655 id: TriggerId(format!(
1656 "trigger/{}",
1657 crate::l10n::local_id(self.id.as_str())
1658 )),
1659 at: Some(self.at.clone()),
1660 on: self.trigger.clone(),
1661 requires_flags: Vec::new(),
1662 forbids_flags: Vec::new(),
1663 once: true,
1664 effects,
1665 }
1666 }
1667}
1668
1669/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
1670///
1671/// The owner's definition of the pattern: between two rest points there are two
1672/// routes. The **short** one starts sealed and holds nothing; the **long** one is
1673/// full of enemies and mechanisms. You earn the far side the hard way, pull one
1674/// mechanism, and the short route opens **forever**. That moment is the design.
1675///
1676/// The compiler owns three obligations, none of them optional:
1677/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
1678/// long route genuinely exists (`DW0373`);
1679/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
1680/// pays nothing is a leak, not a shortcut (`DW0360`);
1681/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
1682/// (`DW0372`). There is no re-sealing verb to reach for.
1683///
1684/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
1685/// untouched by this — the two verbs are deliberately disjoint.
1686#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1687#[serde(deny_unknown_fields)]
1688pub struct Shortcut {
1689 /// Unique shortcut id (`shortcut/<kebab>`).
1690 pub id: ShortcutId,
1691 /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
1692 /// carries the physical fill), and its metadata must declare the fill `block`
1693 /// the compiler clears — the same requirement `close-gate` has.
1694 pub gate: AnchorId,
1695 /// The FAR-side anchor whose interaction fires the permanent open. The
1696 /// compiler summons the affordance there and polls it, reusing the v0.4
1697 /// interaction-entity `use` primitive.
1698 pub unlock: AnchorId,
1699 /// Effects fired once, when the shortcut opens — the bar lifting, the
1700 /// elevator descending, the sound of a door you will never have to earn
1701 /// again. Emitted server-source-safe (the poll lives on the tick).
1702 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1703 pub on_unlock: Vec<QuestEffect>,
1704}
1705
1706/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
1707/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
1708/// entity's attack/interaction records; `approach` is a `distance` selector on
1709/// the tick. Look-at / break-attempt detection is excluded on principle (no
1710/// vanilla primitive).
1711#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1712#[serde(deny_unknown_fields)]
1713pub struct EnvTrigger {
1714 /// Unique trigger id (`trigger/<kebab>`).
1715 pub id: TriggerId,
1716 /// The anchor this trigger watches. Required for `strike` / `use` /
1717 /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
1718 /// v0.6), which watches a *character* and names it in `on.npc` instead —
1719 /// there is no cell for the author to supply and no cell the compiler
1720 /// would use. Either mismatch is `DW0194`.
1721 #[serde(default, skip_serializing_if = "Option::is_none")]
1722 pub at: Option<AnchorId>,
1723 /// The event that fires it.
1724 pub on: TriggerOn,
1725 /// Flags that must be set before the trigger can fire (DSL v0.4).
1726 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1727 pub requires_flags: Vec<FlagId>,
1728 /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the trigger is
1729 /// **suppressed** while ANY listed flag is set (by any player — flags are
1730 /// campaign state). The dual of `requires_flags`, so an "armed between two
1731 /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
1732 /// retaliation trigger with `requires_flags: [flag/sealed]` and
1733 /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
1734 /// the moment the wake beat takes over.
1735 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1736 pub forbids_flags: Vec<FlagId>,
1737 /// Fire at most once (default `true`, mirroring objective completion). Set
1738 /// `false` to allow re-firing every time the condition is met.
1739 #[serde(default = "default_true")]
1740 pub once: bool,
1741 /// Effects fired when the trigger matches.
1742 pub effects: Vec<QuestEffect>,
1743}
1744
1745impl EnvTrigger {
1746 /// The anchor this trigger watches, if it watches a place at all. `None`
1747 /// for `strike-npc`, whose target is a character.
1748 pub fn at_anchor(&self) -> Option<&str> {
1749 self.at.as_ref().map(|a| a.as_str())
1750 }
1751}
1752
1753/// The event an [`EnvTrigger`] watches (DSL v0.4).
1754#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1755#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
1756pub enum TriggerOn {
1757 /// The player attacks (left-clicks) the interaction entity at the anchor.
1758 Strike,
1759 /// The player uses (right-clicks) the interaction entity at the anchor.
1760 Use,
1761 /// The player comes within `range` blocks of the anchor.
1762 Approach {
1763 /// Approach radius (blocks).
1764 range: u32,
1765 },
1766 /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6, reserved
1767 /// `DW0141` earlier).
1768 ///
1769 /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
1770 /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
1771 /// body eclipses that cell (`DW0359`), so the click never reaches the
1772 /// trigger — the owner's island round-7 finding. This form has no cell. It
1773 /// rides the interaction entity the NPC already owns, which is the entity a
1774 /// click on that NPC reaches by construction.
1775 ///
1776 /// Right-click and left-click stay separate all the way down: a
1777 /// `minecraft:interaction` records them in two distinct NBT fields
1778 /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
1779 /// and this trigger takes the left-click, on one shared hitbox.
1780 StrikeNpc {
1781 /// The NPC (stage-2 ref) whose body is the target.
1782 npc: NpcId,
1783 },
1784}
1785
1786impl TriggerOn {
1787 /// The kebab tag (`strike` / `use` / `approach` / `strike-npc`).
1788 pub fn kind(&self) -> &'static str {
1789 match self {
1790 TriggerOn::Strike => "strike",
1791 TriggerOn::Use => "use",
1792 TriggerOn::Approach { .. } => "approach",
1793 TriggerOn::StrikeNpc { .. } => "strike-npc",
1794 }
1795 }
1796
1797 /// Whether this event needs an `at` anchor — true for everything that
1798 /// watches a place, false for `strike-npc`, which watches a character.
1799 pub fn needs_anchor(&self) -> bool {
1800 !matches!(self, TriggerOn::StrikeNpc { .. })
1801 }
1802
1803 /// The NPC whose body this event watches (`strike-npc` only).
1804 pub fn npc_target(&self) -> Option<&NpcId> {
1805 match self {
1806 TriggerOn::StrikeNpc { npc } => Some(npc),
1807 _ => None,
1808 }
1809 }
1810}
1811
1812/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
1813/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
1814/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
1815/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
1816/// and the `kill` objective completes when the count reaches zero.
1817#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1818#[serde(deny_unknown_fields)]
1819pub struct Wave {
1820 /// Unique wave id.
1821 pub id: WaveId,
1822 /// The anchor the wave's mobs spawn at.
1823 pub anchor: AnchorId,
1824 /// The mobs that make up the wave (1..N).
1825 pub mobs: Vec<WaveMob>,
1826 /// Re-seat this wave every time the party rests at (or respawns from) a
1827 /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
1828 /// enemies come back. The compiler kills any survivor carrying the wave tag
1829 /// and re-runs the wave's own spawn function, so the room is restored to its
1830 /// authored composition and spawn cells.
1831 ///
1832 /// Inert without a `bonfire` in the campaign, which is a compile error
1833 /// (`DW0370`) rather than a silent no-op.
1834 #[serde(default, skip_serializing_if = "is_false")]
1835 pub respawns_on_rest: bool,
1836 /// Tower-defense lane routing (spec-0016 §6): march this wave along a
1837 /// waypoint polyline while distant, hand it to native AI the instant a
1838 /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
1839 /// stand), byte-identical.
1840 #[serde(default, skip_serializing_if = "Option::is_none")]
1841 pub lane: Option<WaveLane>,
1842 /// Where the wave's mobs materialize (spec-0016 §6). Absent =
1843 /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
1844 /// wave `anchor`.
1845 #[serde(default, skip_serializing_if = "Option::is_none")]
1846 pub summon: Option<WaveSummon>,
1847 /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
1848 /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
1849 ///
1850 /// This is a **declaration, not a knob**: the compiler never scales content
1851 /// from it (spec-0023 "Out of scope"). It exists because the validation
1852 /// ladder's inverted floor gate needs to know which fights the content
1853 /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
1854 /// on its first attempt is reported as too easy for its billing. Marking it
1855 /// is how the author opts into that scrutiny; the alternative — inferring
1856 /// "elite" from how tuned a stack looks — is exactly the downstream folklore
1857 /// CLAUDE.md's no-hack rule forbids.
1858 ///
1859 /// **It does reach emission in exactly one place** (spec-0016 §1, owner
1860 /// ruling 2026-08-05): in a campaign with a `bonfire`, a billed `elite`/
1861 /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
1862 /// rest *while it is still standing* — deleted and re-seated at full count
1863 /// and full health, so chipping it down one life at a time is never a path.
1864 /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
1865 /// `respawns_on_rest` at once.
1866 #[serde(default, skip_serializing_if = "Option::is_none")]
1867 pub tier: Option<EncounterTier>,
1868}
1869
1870/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
1871/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
1872/// — by one emission site: a bonfire refreshes a billed wave that is still
1873/// standing. Nothing about the encounter itself is scaled from it.
1874#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1875#[serde(rename_all = "kebab-case")]
1876pub enum EncounterTier {
1877 /// Trash / pressure. No floor expectation: a bot that wins it cold proves
1878 /// nothing either way. The default.
1879 #[default]
1880 Ordinary,
1881 /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
1882 /// and spawn-and-unleash vocabulary).
1883 Elite,
1884 /// A campaign's named fight. Same floor rule as `elite`; the distinction is
1885 /// for the run report a human reads.
1886 Boss,
1887}
1888
1889impl EncounterTier {
1890 /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
1891 pub fn token(self) -> &'static str {
1892 match self {
1893 EncounterTier::Ordinary => "ordinary",
1894 EncounterTier::Elite => "elite",
1895 EncounterTier::Boss => "boss",
1896 }
1897 }
1898
1899 /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
1900 /// content bills as hard carries an expectation the bot can measure; an
1901 /// ordinary one does not.
1902 pub fn has_floor_expectation(self) -> bool {
1903 matches!(self, EncounterTier::Elite | EncounterTier::Boss)
1904 }
1905}
1906
1907/// Where a wave's mobs materialize (spec-0016 §6).
1908#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1909#[serde(rename_all = "kebab-case")]
1910pub enum WaveSummon {
1911 /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
1912 /// behaviour and the default.
1913 Anchor,
1914 /// **Spirit-summoned at the edge of perception** (owner design 2026-08-02):
1915 /// each mob appears on the ring at its own `attributes.follow_range` from
1916 /// the wave `anchor`, so it acquires a target the instant it exists and
1917 /// closes under pure native AI. Species without patrol AI never march a
1918 /// lane; this is what they do instead — never a spawn on top of the party,
1919 /// never a mob that brushes past.
1920 ///
1921 /// With this mode the wave `anchor` is the **defended point** (what the ring
1922 /// is drawn around), not the spawn point. Each mob stack must declare its
1923 /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
1924 /// never guessed from a vanilla defaults table the compiler cannot verify.
1925 AggroEdge,
1926}
1927
1928/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
1929/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
1930/// (`docs/notes/td-routing-spike.md`).
1931///
1932/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
1933/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
1934/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
1935/// From that instant the mob is a plain native hostile. "Combat preempts
1936/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
1937/// no target — so the owner's rule (march while distant, fight with NATIVE AI
1938/// once aggroed, never brush past) falls out of the primitive unforced.
1939///
1940/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
1941/// pillager must keep its crossbow (`DW0384`).
1942#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1943#[serde(deny_unknown_fields)]
1944pub struct WaveLane {
1945 /// The lane polyline, in march order: anchors some area's prefab provides.
1946 /// At least one; consecutive legs (counting the wave `anchor` as the origin)
1947 /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
1948 /// within 10 blocks of arrival, so a tighter lane is a lane the engine
1949 /// quietly stops following (`DW0386`).
1950 pub waypoints: Vec<AnchorId>,
1951 /// The release radius, in blocks: the compiler sets every lane mob's
1952 /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
1953 /// at the same distance. The two MUST be equal — a patrolling raider that
1954 /// targets a player it cannot engage holds ground instead of marching, so a
1955 /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
1956 pub aggro_radius: u32,
1957}
1958
1959/// One mob stack in a [`Wave`].
1960#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1961#[serde(deny_unknown_fields)]
1962pub struct WaveMob {
1963 /// Vanilla entity id, validated against the pinned 1.21.11 registry.
1964 pub entity: String,
1965 /// How many to spawn.
1966 pub count: u32,
1967 /// Optional custom name (shown above the mob).
1968 #[serde(default, skip_serializing_if = "Option::is_none")]
1969 pub name: Option<String>,
1970 /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
1971 /// components. Enables e.g. a weakened live warden as a survivable stealth
1972 /// threat. Omitted = vanilla defaults.
1973 #[serde(default, skip_serializing_if = "Option::is_none")]
1974 pub attributes: Option<MobAttributes>,
1975 /// Optional permanent, ambient status effects (DSL v0.4).
1976 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1977 pub effects: Vec<MobEffect>,
1978 /// Optional worn/held equipment (DSL v0.6, task #65). A helmet is the
1979 /// sanctioned fix for daylight-burning undead (owner ruling) — never
1980 /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
1981 /// (`DW0143`, the give-item family); every emitted slot carries drop
1982 /// chance 0 so players can never farm wave gear (no-grind constitution).
1983 #[serde(default, skip_serializing_if = "Option::is_none")]
1984 pub equipment: Option<MobEquipment>,
1985 /// What this mob leaves behind when it dies (DSL v0.9, task #179; reserved
1986 /// `DW0141` earlier). Only an `elite`/`boss` wave may declare it (`DW0491`)
1987 /// — an ordinary mob's kit is never farmable. Empty = today's behaviour,
1988 /// drop chance 0 on every slot.
1989 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1990 pub drops: Vec<MobDrop>,
1991}
1992
1993/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
1994/// id for the matching vanilla equipment slot; an unset slot stays empty —
1995/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
1996/// wither-skeleton sword) still applies unless overridden. Emitted as the
1997/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
1998/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
1999#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2000#[serde(deny_unknown_fields)]
2001pub struct MobEquipment {
2002 /// Head slot.
2003 #[serde(default, skip_serializing_if = "Option::is_none")]
2004 pub head: Option<EquipItem>,
2005 /// Chest slot.
2006 #[serde(default, skip_serializing_if = "Option::is_none")]
2007 pub chest: Option<EquipItem>,
2008 /// Legs slot.
2009 #[serde(default, skip_serializing_if = "Option::is_none")]
2010 pub legs: Option<EquipItem>,
2011 /// Feet slot.
2012 #[serde(default, skip_serializing_if = "Option::is_none")]
2013 pub feet: Option<EquipItem>,
2014 /// Main-hand slot. Overrides the compiler's armed-mob default.
2015 #[serde(default, skip_serializing_if = "Option::is_none")]
2016 pub main_hand: Option<EquipItem>,
2017 /// Off-hand slot.
2018 #[serde(default, skip_serializing_if = "Option::is_none")]
2019 pub off_hand: Option<EquipItem>,
2020}
2021
2022impl MobEquipment {
2023 /// Every slot as `(dsl_field_name, piece)`, in the fixed schema order —
2024 /// the single iteration source for validation paths and emission.
2025 pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); 6] {
2026 [
2027 ("head", self.head.as_ref()),
2028 ("chest", self.chest.as_ref()),
2029 ("legs", self.legs.as_ref()),
2030 ("feet", self.feet.as_ref()),
2031 ("main_hand", self.main_hand.as_ref()),
2032 ("off_hand", self.off_hand.as_ref()),
2033 ]
2034 }
2035
2036 /// The piece this equipment declaration puts in `slot`, if any. The single
2037 /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
2038 /// actually wears (`DW0490`).
2039 pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
2040 match slot {
2041 EquipSlot::Head => self.head.as_ref(),
2042 EquipSlot::Chest => self.chest.as_ref(),
2043 EquipSlot::Legs => self.legs.as_ref(),
2044 EquipSlot::Feet => self.feet.as_ref(),
2045 EquipSlot::MainHand => self.main_hand.as_ref(),
2046 EquipSlot::OffHand => self.off_hand.as_ref(),
2047 }
2048 }
2049}
2050
2051/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
2052/// fills it (DSL v0.9, task #179). The DSL name and the summon-NBT key differ
2053/// (`main_hand` vs `mainhand`), so both live here and nowhere else.
2054#[derive(
2055 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
2056)]
2057#[serde(rename_all = "snake_case")]
2058pub enum EquipSlot {
2059 /// Head slot.
2060 Head,
2061 /// Chest slot.
2062 Chest,
2063 /// Legs slot.
2064 Legs,
2065 /// Feet slot.
2066 Feet,
2067 /// Main-hand slot.
2068 MainHand,
2069 /// Off-hand slot.
2070 OffHand,
2071}
2072
2073impl EquipSlot {
2074 /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
2075 pub fn field(self) -> &'static str {
2076 match self {
2077 EquipSlot::Head => "head",
2078 EquipSlot::Chest => "chest",
2079 EquipSlot::Legs => "legs",
2080 EquipSlot::Feet => "feet",
2081 EquipSlot::MainHand => "main_hand",
2082 EquipSlot::OffHand => "off_hand",
2083 }
2084 }
2085
2086 /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`).
2087 pub fn nbt(self) -> &'static str {
2088 match self {
2089 EquipSlot::Head => "head",
2090 EquipSlot::Chest => "chest",
2091 EquipSlot::Legs => "legs",
2092 EquipSlot::Feet => "feet",
2093 EquipSlot::MainHand => "mainhand",
2094 EquipSlot::OffHand => "offhand",
2095 }
2096 }
2097}
2098
2099/// One declared drop of an elite or boss (DSL v0.9, task #179; owner ruling
2100/// 2026-08-04).
2101///
2102/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
2103/// usually one piece and never automatically everything. Two forms, told apart
2104/// by which field is present:
2105///
2106/// ```json
2107/// { "slot": "main_hand" } // its axe
2108/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
2109/// ```
2110///
2111/// A `slot` entry must name a slot the same entity's `equipment` really fills
2112/// (`DW0490`) — the drop is the piece the player has been *looking at* through
2113/// the whole fight, so the two declarations cannot disagree. An `item` entry is
2114/// a token the fight *yields* rather than wears, and rides the entity's own
2115/// death loot table.
2116///
2117/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
2118/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
2119#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2120#[serde(untagged)]
2121pub enum MobDrop {
2122 /// A worn/held piece, named by its equipment slot.
2123 Slot(SlotDrop),
2124 /// A quest token the fight yields (not worn).
2125 Item(ItemDrop),
2126}
2127
2128/// The worn-piece form of a [`MobDrop`].
2129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2130#[serde(deny_unknown_fields)]
2131pub struct SlotDrop {
2132 /// The equipment slot whose piece drops. Must be filled by the entity's own
2133 /// `equipment` declaration (`DW0490`).
2134 pub slot: EquipSlot,
2135}
2136
2137/// The quest-token form of a [`MobDrop`].
2138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2139#[serde(deny_unknown_fields)]
2140pub struct ItemDrop {
2141 /// Vanilla item id, validated against the pinned 1.21.11 registry
2142 /// (`DW0143`, the give-item family).
2143 pub item: String,
2144 /// Display name the dropped stack carries as a `custom_name` component.
2145 /// Player-visible, so it enters the l10n string inventory and translates
2146 /// like any other line.
2147 #[serde(default, skip_serializing_if = "Option::is_none")]
2148 pub name: Option<String>,
2149}
2150
2151impl MobDrop {
2152 /// The equipment slot this entry names, or `None` for a quest-item drop.
2153 pub fn slot(&self) -> Option<EquipSlot> {
2154 match self {
2155 MobDrop::Slot(s) => Some(s.slot),
2156 MobDrop::Item(_) => None,
2157 }
2158 }
2159
2160 /// The item id this entry names, or `None` for a worn-piece drop.
2161 pub fn item(&self) -> Option<&str> {
2162 match self {
2163 MobDrop::Slot(_) => None,
2164 MobDrop::Item(i) => Some(&i.item),
2165 }
2166 }
2167
2168 /// The declared display name of a quest-item drop, when set.
2169 pub fn name(&self) -> Option<&String> {
2170 match self {
2171 MobDrop::Slot(_) => None,
2172 MobDrop::Item(i) => i.name.as_ref(),
2173 }
2174 }
2175
2176 /// Mutable access to the display name — the l10n traversal's hook.
2177 pub fn name_mut(&mut self) -> Option<&mut String> {
2178 match self {
2179 MobDrop::Slot(_) => None,
2180 MobDrop::Item(i) => i.name.as_mut(),
2181 }
2182 }
2183}
2184
2185/// One equipped item: either a bare item id, or an id carrying enchantments.
2186///
2187/// The plain form is the common case and stays a plain JSON string, which is
2188/// what keeps every campaign written before enchantments existed byte-identical
2189/// on re-serialisation:
2190///
2191/// ```json
2192/// "main_hand": "minecraft:netherite_sword"
2193/// "head": { "item": "minecraft:netherite_helmet",
2194/// "enchantments": { "minecraft:protection": 4 } }
2195/// ```
2196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2197#[serde(untagged)]
2198pub enum EquipItem {
2199 /// A bare item id — no enchantments.
2200 Plain(String),
2201 /// An item id plus its enchantments.
2202 Enchanted(EnchantedItem),
2203}
2204
2205impl EquipItem {
2206 /// The item id, whichever form was authored.
2207 pub fn item(&self) -> &str {
2208 match self {
2209 EquipItem::Plain(s) => s,
2210 EquipItem::Enchanted(e) => &e.item,
2211 }
2212 }
2213
2214 /// The enchantments on this piece — empty for the plain form. `BTreeMap`
2215 /// ordered, so emission order is the id order and never hash order
2216 /// (ADR-0006).
2217 pub fn enchantments(&self) -> &BTreeMap<String, u32> {
2218 static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
2219 std::sync::LazyLock::new(BTreeMap::new);
2220 match self {
2221 EquipItem::Plain(_) => &EMPTY,
2222 EquipItem::Enchanted(e) => &e.enchantments,
2223 }
2224 }
2225}
2226
2227/// The enchanted form of [`EquipItem`].
2228#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2229#[serde(deny_unknown_fields)]
2230pub struct EnchantedItem {
2231 /// Item id (e.g. `minecraft:netherite_chestplate`).
2232 pub item: String,
2233 /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
2234 /// the 1.21 `minecraft:enchantments` item component.
2235 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2236 pub enchantments: BTreeMap<String, u32>,
2237}
2238
2239/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
2240/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
2241#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2242#[serde(deny_unknown_fields)]
2243pub struct MobAttributes {
2244 /// `minecraft:max_health` base value.
2245 #[serde(default, skip_serializing_if = "Option::is_none")]
2246 pub max_health: Option<f64>,
2247 /// `minecraft:attack_damage` base value.
2248 #[serde(default, skip_serializing_if = "Option::is_none")]
2249 pub attack_damage: Option<f64>,
2250 /// `minecraft:movement_speed` base value.
2251 #[serde(default, skip_serializing_if = "Option::is_none")]
2252 pub movement_speed: Option<f64>,
2253 /// `minecraft:follow_range` base value.
2254 #[serde(default, skip_serializing_if = "Option::is_none")]
2255 pub follow_range: Option<f64>,
2256}
2257
2258/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
2259/// non-expiring `effect give`.
2260#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2261#[serde(deny_unknown_fields)]
2262pub struct MobEffect {
2263 /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
2264 /// pinned registry (`DW0192`).
2265 pub effect: String,
2266 /// Amplifier (0 = level I).
2267 pub amplifier: u32,
2268}
2269
2270/// One expanded quest.
2271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2272#[serde(deny_unknown_fields)]
2273pub struct Quest {
2274 /// Quest id (matches a stage-4 planned quest).
2275 pub id: QuestId,
2276 /// What starts the quest.
2277 pub trigger: Trigger,
2278 /// Ordered objectives (intra-quest DAG via `after`).
2279 pub objectives: Vec<Objective>,
2280 /// Effects fired when a given objective completes.
2281 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2282 pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
2283 /// Effects fired when the whole quest completes.
2284 pub on_complete: Vec<QuestEffect>,
2285 /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
2286 /// live during this quest — spawned and not explicitly removed — where it
2287 /// stands, what it is doing, and what its right-click offers *for this
2288 /// quest's duration*.
2289 ///
2290 /// The ledger exists because an NPC's dialogue used to be one tree for the
2291 /// whole campaign: after the climactic escape a crew member still offered
2292 /// "Tell me what he is." — a premise question absurd once the story moved on.
2293 /// Declaring the scene per quest makes the compiler able to check it
2294 /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
2295 /// emitted right-click shows the root this quest declares, so a stale root
2296 /// retires *because the ledger says so*, not because an author remembered a
2297 /// flag.
2298 ///
2299 /// Empty/absent before v0.7 (`DW0465` deprecation warning for one version
2300 /// window), so a campaign that declares none compiles byte-identically.
2301 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2302 pub cast: BTreeMap<NpcId, CastEntry>,
2303 /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
2304 /// `DW0481`; reserved `DW0141` earlier).
2305 #[serde(default, skip_serializing_if = "Option::is_none")]
2306 pub happening: Option<Happening>,
2307}
2308
2309/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
2310///
2311/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
2312/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
2313/// per-branch casts, each gated by the flags that select its branch (spec-0020
2314/// proof 4: where the effect history is branch-dependent, a single flat
2315/// declaration cannot hold on every reachable branch, and `DW0462` says so).
2316#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2317#[serde(untagged)]
2318pub enum CastEntry {
2319 /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
2320 /// placement whose `at` is that keyword and which carries no `doing` or
2321 /// `dialogue` — a character who is not in the world has no business and
2322 /// answers no right-click.
2323 Absent(CastAbsence),
2324 /// A single placement that must hold on every reachable branch.
2325 Placement(CastPlacement),
2326 /// Per-branch placements (spec-0020 proof 4). Each carries the
2327 /// `requires_flags`/`forbids_flags` that select its branch.
2328 Branches(Vec<CastPlacement>),
2329}
2330
2331impl CastEntry {
2332 /// Every placement this entry declares, in declared order. The bare-keyword
2333 /// form yields none — there is no scene to check.
2334 pub fn placements(&self) -> Vec<&CastPlacement> {
2335 match self {
2336 CastEntry::Absent(_) => Vec::new(),
2337 CastEntry::Placement(p) => vec![p],
2338 CastEntry::Branches(ps) => ps.iter().collect(),
2339 }
2340 }
2341
2342 /// Every placement this entry declares, mutably (the l10n traversal).
2343 pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
2344 match self {
2345 CastEntry::Absent(_) => Vec::new(),
2346 CastEntry::Placement(p) => vec![p],
2347 CastEntry::Branches(ps) => ps.iter_mut().collect(),
2348 }
2349 }
2350
2351 /// The bare-keyword absence this entry declares, if it is that form.
2352 pub fn absence(&self) -> Option<CastAbsence> {
2353 match self {
2354 CastEntry::Absent(a) => Some(*a),
2355 _ => None,
2356 }
2357 }
2358
2359 /// True if this entry declares the NPC out of the world entirely — the bare
2360 /// keyword, or every placement's `at` being a keyword.
2361 pub fn is_absent(&self) -> bool {
2362 match self {
2363 CastEntry::Absent(_) => true,
2364 CastEntry::Placement(p) => p.at.absence().is_some(),
2365 CastEntry::Branches(ps) => {
2366 !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
2367 }
2368 }
2369 }
2370}
2371
2372/// A declared absence: the NPC is deliberately not in the world.
2373#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2374#[serde(rename_all = "kebab-case")]
2375pub enum CastAbsence {
2376 /// Removed from the story for good (must match a `despawn-npc` with no later
2377 /// `spawn-npc`).
2378 Dead,
2379 /// Not in the world for this quest, but may return (must match a
2380 /// `despawn-npc`).
2381 Offstage,
2382}
2383
2384impl CastAbsence {
2385 /// The authored keyword (`dead` / `offstage`).
2386 pub fn token(self) -> &'static str {
2387 match self {
2388 CastAbsence::Dead => "dead",
2389 CastAbsence::Offstage => "offstage",
2390 }
2391 }
2392}
2393
2394/// Where a cast entry puts an NPC: a prefab anchor, or a declared absence.
2395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2396#[serde(untagged)]
2397pub enum CastPlace {
2398 /// `"dead"` or `"offstage"` — explicitly not in the world.
2399 Absent(CastAbsence),
2400 /// The anchor the NPC stands on for this quest's duration. Must equal the
2401 /// position the effect history actually produces (`DW0461`): declaring an
2402 /// anchor does not teleport anybody.
2403 Anchor(AnchorId),
2404}
2405
2406impl CastPlace {
2407 /// The anchor this place names, if it is an anchor.
2408 pub fn anchor(&self) -> Option<&AnchorId> {
2409 match self {
2410 CastPlace::Anchor(a) => Some(a),
2411 CastPlace::Absent(_) => None,
2412 }
2413 }
2414
2415 /// The declared absence, if this place is one.
2416 pub fn absence(&self) -> Option<CastAbsence> {
2417 match self {
2418 CastPlace::Absent(a) => Some(*a),
2419 CastPlace::Anchor(_) => None,
2420 }
2421 }
2422
2423 /// The authored token, for diagnostics.
2424 pub fn token(&self) -> &str {
2425 match self {
2426 CastPlace::Absent(a) => a.token(),
2427 CastPlace::Anchor(a) => a.as_str(),
2428 }
2429 }
2430}
2431
2432/// One declared scene for one NPC in one quest.
2433#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2434#[serde(deny_unknown_fields)]
2435pub struct CastPlacement {
2436 /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
2437 pub at: CastPlace,
2438 /// What the character is *doing* — free prose, never machine-checked.
2439 ///
2440 /// This field is the forcing function, which is the whole reason it is
2441 /// required (`DW0463`) despite being unverifiable: you cannot fill it in
2442 /// without deciding the character's business in this beat, and stage 6
2443 /// receives it as the context the NPC's lines are written against.
2444 #[serde(default, skip_serializing_if = "Option::is_none")]
2445 pub doing: Option<String>,
2446 /// What right-click offers during this quest. Required for an on-stage
2447 /// placement (`DW0463`) — including the explicit `"none"`.
2448 #[serde(default, skip_serializing_if = "Option::is_none")]
2449 pub dialogue: Option<CastDialogue>,
2450 /// Branch gate (per-branch casts): this placement describes the world only
2451 /// once every listed flag is set. Mirrors an option's `requires_flags`.
2452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2453 pub requires_flags: Vec<FlagId>,
2454 /// Negative branch gate: this placement describes the world only while no
2455 /// listed flag is set. Mirrors an option's `forbids_flags`.
2456 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2457 pub forbids_flags: Vec<FlagId>,
2458}
2459
2460/// What an NPC's right-click offers for a quest's duration.
2461///
2462/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
2463/// `{"barks": […]}` pool, then a dialogue root id.
2464#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2465#[serde(untagged)]
2466pub enum CastDialogue {
2467 /// A keyword: `"none"` or `"unchanged"`.
2468 Keyword(CastDialogueKeyword),
2469 /// A bark pool: right-click yields one inconsequential in-character line —
2470 /// no tree, no options, no consequences.
2471 Barks(CastBarks),
2472 /// A dialogue root id: right-click opens this node of the NPC's stage-6
2473 /// tree. Must be a node of *that* NPC's tree (`DW0464`).
2474 Root(DialogueId),
2475}
2476
2477/// The keyword forms of [`CastDialogue`].
2478#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2479#[serde(rename_all = "kebab-case")]
2480pub enum CastDialogueKeyword {
2481 /// Genuinely no reaction: the right-click is recorded and consumed, and
2482 /// nothing opens. Legal, but a last resort — if a body is clickable, the
2483 /// world should answer; prefer a bark.
2484 None,
2485 /// Carry forward whatever this NPC's dialogue was at its **previous**
2486 /// appearance in the quest-DAG ordering (owner amendment, 2026-08-03).
2487 ///
2488 /// The point is that carrying dialogue forward is a *conscious, declared
2489 /// act*. It is never an implicit default — an omitted `dialogue` is
2490 /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
2491 /// intent without re-spelling a root id that then drifts out of sync. It
2492 /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
2493 /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
2494 ///
2495 /// Emission is a **no-op** — no root swap is emitted for that NPC at that
2496 /// quest — which is what makes the sugar cheap and byte-stable.
2497 Unchanged,
2498}
2499
2500/// A bark pool: inconsequential in-character lines, cycled deterministically.
2501#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2502#[serde(deny_unknown_fields)]
2503pub struct CastBarks {
2504 /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
2505 /// they enter the l10n inventory like any narrate text.
2506 pub barks: Vec<String>,
2507}
2508
2509/// What triggers a quest.
2510#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2511#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
2512pub enum Trigger {
2513 /// Fires when the campaign starts.
2514 CampaignStart,
2515 /// Fires when another quest completes.
2516 QuestComplete {
2517 /// The prerequisite quest.
2518 quest: QuestId,
2519 },
2520}
2521
2522/// A quest objective.
2523///
2524/// `kill`, `collect` and `interact` are **implemented in DSL v0.3**; in a v0.2
2525/// campaign they are still reserved and rejected with `DW0141` (see
2526/// [`Objective::v03_verb`]). Every variant may carry `requires_flags` (v0.3):
2527/// flag-gated activation, satisfied only once each referenced flag has been set
2528/// by a `set-flag` effect.
2529#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2530#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
2531pub enum Objective {
2532 /// Completed by a dialogue option's `complete-objective` effect.
2533 TalkTo {
2534 /// Objective id.
2535 id: ObjectiveId,
2536 /// Short player-facing objective name (v0.3, optional).
2537 #[serde(default, skip_serializing_if = "Option::is_none")]
2538 title: Option<String>,
2539 /// One-line location/direction hint (v0.3, optional).
2540 #[serde(default, skip_serializing_if = "Option::is_none")]
2541 hint: Option<String>,
2542 /// The NPC to talk to.
2543 npc: NpcId,
2544 /// Prerequisite objectives (intra-quest ordering).
2545 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2546 after: Vec<ObjectiveId>,
2547 /// Flags that must be set before this objective activates (v0.3).
2548 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2549 requires_flags: Vec<FlagId>,
2550 /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2551 /// objective is suppressed (cannot activate or complete) while ANY listed
2552 /// flag is set for the player — the dual of `requires_flags`.
2553 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2554 forbids_flags: Vec<FlagId>,
2555 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2556 /// bot should traverse sneaking (sprint disabled). Emitted into
2557 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2558 /// hint; no datapack effect.
2559 #[serde(default, skip_serializing_if = "is_false")]
2560 stealth: bool,
2561 /// What this objective does to the story (DSL v0.8, spec-0025; required
2562 /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2563 #[serde(default, skip_serializing_if = "Option::is_none")]
2564 happening: Option<Happening>,
2565 },
2566 /// Completed by reaching an anchor once prerequisites are met.
2567 ReachAnchor {
2568 /// Objective id.
2569 id: ObjectiveId,
2570 /// Short player-facing objective name (v0.3, optional).
2571 #[serde(default, skip_serializing_if = "Option::is_none")]
2572 title: Option<String>,
2573 /// One-line location/direction hint (v0.3, optional).
2574 #[serde(default, skip_serializing_if = "Option::is_none")]
2575 hint: Option<String>,
2576 /// The anchor to reach.
2577 anchor: AnchorId,
2578 /// Completion radius (blocks).
2579 radius: u32,
2580 /// Prerequisite objectives (intra-quest ordering).
2581 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2582 after: Vec<ObjectiveId>,
2583 /// Flags that must be set before this objective activates (v0.3).
2584 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2585 requires_flags: Vec<FlagId>,
2586 /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2587 /// objective is suppressed (cannot activate or complete) while ANY listed
2588 /// flag is set for the player — the dual of `requires_flags`.
2589 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2590 forbids_flags: Vec<FlagId>,
2591 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2592 /// bot should traverse sneaking (sprint disabled). Emitted into
2593 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2594 /// hint; no datapack effect.
2595 #[serde(default, skip_serializing_if = "is_false")]
2596 stealth: bool,
2597 /// What this objective does to the story (DSL v0.8, spec-0025; required
2598 /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2599 #[serde(default, skip_serializing_if = "Option::is_none")]
2600 happening: Option<Happening>,
2601 },
2602 /// Completed when the referenced wave is fully slain (v0.3).
2603 Kill {
2604 /// Objective id.
2605 id: ObjectiveId,
2606 /// Short player-facing objective name (v0.3, optional).
2607 #[serde(default, skip_serializing_if = "Option::is_none")]
2608 title: Option<String>,
2609 /// One-line location/direction hint (v0.3, optional).
2610 #[serde(default, skip_serializing_if = "Option::is_none")]
2611 hint: Option<String>,
2612 /// The wave (stage-5 `waves` ref) whose mobs must be slain.
2613 wave: WaveId,
2614 /// Prerequisite objectives.
2615 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2616 after: Vec<ObjectiveId>,
2617 /// Flags that must be set before this objective activates (v0.3).
2618 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2619 requires_flags: Vec<FlagId>,
2620 /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2621 /// objective is suppressed (cannot activate or complete) while ANY listed
2622 /// flag is set for the player — the dual of `requires_flags`.
2623 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2624 forbids_flags: Vec<FlagId>,
2625 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2626 /// bot should traverse sneaking (sprint disabled). Emitted into
2627 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2628 /// hint; no datapack effect.
2629 #[serde(default, skip_serializing_if = "is_false")]
2630 stealth: bool,
2631 /// What this objective does to the story (DSL v0.8, spec-0025; required
2632 /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2633 #[serde(default, skip_serializing_if = "Option::is_none")]
2634 happening: Option<Happening>,
2635 },
2636 /// Completed when `count` of `item` have been collected (v0.3).
2637 ///
2638 /// The items are provided in a container: the compiler's own chest at
2639 /// `anchor` by default, or — since DSL v0.8 — the prefab's existing
2640 /// chest/barrel at [`Objective::Collect::container`], optionally carrying an
2641 /// [`Objective::Collect::item_name`] and padded to read full with
2642 /// [`Objective::Collect::fill_count`].
2643 Collect {
2644 /// Objective id.
2645 id: ObjectiveId,
2646 /// Short player-facing objective name (v0.3, optional).
2647 #[serde(default, skip_serializing_if = "Option::is_none")]
2648 title: Option<String>,
2649 /// One-line location/direction hint (v0.3, optional).
2650 #[serde(default, skip_serializing_if = "Option::is_none")]
2651 hint: Option<String>,
2652 /// Vanilla item id to collect (validated against the registry).
2653 item: String,
2654 /// How many are required.
2655 count: u32,
2656 /// The anchor items are provided at (chest / pickup).
2657 anchor: AnchorId,
2658 /// **Adopt the container the prefab already placed** (DSL v0.8, task #95;
2659 /// reserved `DW0141` earlier): the anchor whose assembled-world cell holds
2660 /// a `chest` / `trapped_chest` / `barrel` this collect fills instead of
2661 /// conjuring its own chest at [`Objective::Collect::anchor`].
2662 ///
2663 /// Same division of labour a `loot` entry and a trap's dispenser already
2664 /// keep with the prefab: furniture belongs in the piece. A beach camp's
2665 /// barrel is scenery the player has been walking past since minute one —
2666 /// having the compiler `setblock` a *second*, floating chest beside it to
2667 /// hold the quest item is exactly the downstream workaround the no-hack
2668 /// rule forbids. A `container` whose cell holds no container is a build
2669 /// error (`DW0438`), never a silent fill into a wall.
2670 ///
2671 /// The critical-path step's position follows the container (the bot opens
2672 /// *this* block), and no chest is placed at `anchor` when it is set.
2673 #[serde(default, skip_serializing_if = "Option::is_none")]
2674 container: Option<AnchorId>,
2675 /// **The item comes off a body, not out of a box** (DSL v0.9, task #179;
2676 /// reserved `DW0141` earlier): the wave whose declared `drops[]` yield
2677 /// this objective's item. No container is placed — not the compiler's own
2678 /// chest at `anchor`, not a prefab one — and `container` is therefore
2679 /// mutually exclusive with it (`DW0100`-adjacent; `DW0492`).
2680 ///
2681 /// This is what makes "kill the boss → pick up its key → open the door"
2682 /// a *proved* chain rather than an authoring intention. The compiler
2683 /// requires (a) that the named wave really declares an `{item}` drop of
2684 /// this item (`DW0492`), and (b) that a `kill` objective for that wave
2685 /// precedes this collect in the objective graph (`DW0493`). The existing
2686 /// flow machinery then carries the ordering the rest of the way: the
2687 /// door's `requires_flags` hangs off this collect exactly as it would off
2688 /// a chest one.
2689 ///
2690 /// **Waves only.** An actor's death is not observable by any objective —
2691 /// there is no vanilla-side signal the flow machinery could consume — so
2692 /// an actor-gated collect would be an unprovable claim, and per the
2693 /// no-hack doctrine it is excluded rather than approximated. An actor may
2694 /// still declare `drops[]`; those drops just cannot gate a quest.
2695 #[serde(default, skip_serializing_if = "Option::is_none")]
2696 dropped_by: Option<WaveId>,
2697 /// Display name for the collected item (DSL v0.8, task #95; reserved
2698 /// `DW0141` earlier), emitted as the vanilla `custom_name` item component.
2699 ///
2700 /// A quest item is a *named thing* in the story ("Cheese", "Tide
2701 /// Ledger"), and a player who opens the barrel must read that name — an
2702 /// unnamed `minecraft:pumpkin_pie` says nothing about what the quest asked
2703 /// for. Player-visible, so it enters the l10n string inventory
2704 /// (`obj.<quest>.<obj>.item_name`) and translates like any other line.
2705 ///
2706 /// Naming changes nothing about adjudication: the completion advancement
2707 /// and the per-tick held check both match on the ITEM ID, which a named
2708 /// stack still carries.
2709 #[serde(default, skip_serializing_if = "Option::is_none")]
2710 item_name: Option<String>,
2711 /// Padding stacks that make the container **read full** (DSL v0.8, task
2712 /// #95; reserved `DW0141` earlier). Default `0` = the single required
2713 /// stack, exactly as every pre-0.8 campaign emits.
2714 ///
2715 /// A barrel of cheese that opens on one lonely wheel reads as a bug, and
2716 /// vanilla's notion of "full" is *occupied slots*, not stack size — so
2717 /// this counts SLOTS: the objective's own stack lands in `container.0` and
2718 /// each padding stack repeats it in `container.1`, `container.2`, … Slot
2719 /// assignment is positional and total, the same determinism story `loot`
2720 /// tells (ADR-0006): no RNG, no loot tables, nothing to reseed.
2721 ///
2722 /// The padding is the same item, so taking the whole barrel still
2723 /// completes the objective and never over- or under-counts it.
2724 #[serde(default, skip_serializing_if = "is_zero")]
2725 fill_count: u32,
2726 /// Prerequisite objectives.
2727 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2728 after: Vec<ObjectiveId>,
2729 /// Flags that must be set before this objective activates (v0.3).
2730 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2731 requires_flags: Vec<FlagId>,
2732 /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2733 /// objective is suppressed (cannot activate or complete) while ANY listed
2734 /// flag is set for the player — the dual of `requires_flags`.
2735 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2736 forbids_flags: Vec<FlagId>,
2737 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2738 /// bot should traverse sneaking (sprint disabled). Emitted into
2739 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2740 /// hint; no datapack effect.
2741 #[serde(default, skip_serializing_if = "is_false")]
2742 stealth: bool,
2743 /// What this objective does to the story (DSL v0.8, spec-0025; required
2744 /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2745 #[serde(default, skip_serializing_if = "Option::is_none")]
2746 happening: Option<Happening>,
2747 },
2748 /// Completed by interacting with an entity at `anchor`; if `requires_item` is
2749 /// set, the item must be **held in the main hand** (v0.3; held semantics since
2750 /// DSL v0.7 — see [`Objective::Interact::requires_item`]).
2751 Interact {
2752 /// Objective id.
2753 id: ObjectiveId,
2754 /// Short player-facing objective name (v0.3, optional).
2755 #[serde(default, skip_serializing_if = "Option::is_none")]
2756 title: Option<String>,
2757 /// One-line location/direction hint (v0.3, optional).
2758 #[serde(default, skip_serializing_if = "Option::is_none")]
2759 hint: Option<String>,
2760 /// The anchor the interaction entity stands at.
2761 anchor: AnchorId,
2762 /// Item the player must be **holding in the main hand** for the
2763 /// interaction to complete (optional).
2764 ///
2765 /// Held, not merely possessed (owner ruling, 2026-08-03): presenting the
2766 /// item IS the action — a player who right-clicks a sleeping giant with a
2767 /// sharpened stake buried in their backpack has not stabbed anything.
2768 /// Before this ruling the gate read the whole inventory, which made every
2769 /// `requires_item` interaction fire the moment the item was picked up
2770 /// anywhere, whatever the player was actually doing with their hands.
2771 #[serde(default, skip_serializing_if = "Option::is_none")]
2772 requires_item: Option<String>,
2773 /// Diegetic feedback for a click that arrives without the required item in
2774 /// hand (DSL v0.7, reserved `DW0141` earlier): narrated to that player in
2775 /// chat instead of the silence the gate used to answer with. Requires
2776 /// `requires_item` (`DW0437`).
2777 ///
2778 /// Only fires while the objective is genuinely open — same activation gate
2779 /// as the affordance itself — so a finished or not-yet-active interaction
2780 /// stays quiet.
2781 #[serde(default, skip_serializing_if = "Option::is_none")]
2782 missing_item_hint: Option<String>,
2783 /// Prop block that IS the interaction affordance (DSL v0.4, spec-0008
2784 /// §2): the compiler `setblock`s it at the anchor on activation (exactly
2785 /// as `collect` uses a real chest). Omitted = the glowing-lantern
2786 /// hologram marker (the v0.3 fallback).
2787 #[serde(default, skip_serializing_if = "Option::is_none")]
2788 prop: Option<Prop>,
2789 /// Prerequisite objectives.
2790 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2791 after: Vec<ObjectiveId>,
2792 /// Flags that must be set before this objective activates (v0.3).
2793 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2794 requires_flags: Vec<FlagId>,
2795 /// Negative flag gate (DSL v0.6, reserved `DW0141` earlier): the
2796 /// objective is suppressed (cannot activate or complete) while ANY listed
2797 /// flag is set for the player — the dual of `requires_flags`.
2798 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2799 forbids_flags: Vec<FlagId>,
2800 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
2801 /// bot should traverse sneaking (sprint disabled). Emitted into
2802 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
2803 /// hint; no datapack effect.
2804 #[serde(default, skip_serializing_if = "is_false")]
2805 stealth: bool,
2806 /// What this objective does to the story (DSL v0.8, spec-0025; required
2807 /// at 0.8.0, `DW0481`; reserved `DW0141` earlier).
2808 #[serde(default, skip_serializing_if = "Option::is_none")]
2809 happening: Option<Happening>,
2810 },
2811}
2812
2813/// A prop block for an `interact` objective (DSL v0.4). The block is the
2814/// affordance the player interacts with; its id is validated against the pinned
2815/// 1.21.11 block registry (`DW0193`).
2816#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2817#[serde(deny_unknown_fields)]
2818pub struct Prop {
2819 /// Vanilla block id (e.g. `minecraft:lever`).
2820 pub block: String,
2821}
2822
2823// ---------------------------------------------------------------------------
2824// Stage 5 — scripted actors (DSL v0.6, spec-0014)
2825// ---------------------------------------------------------------------------
2826
2827/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
2828/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
2829/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
2830/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
2831/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
2832/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
2833/// by a real-AI twin with `unleash-actor`.
2834#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2835#[serde(deny_unknown_fields)]
2836pub struct Actor {
2837 /// Unique actor id (`actor/<kebab>`).
2838 pub id: ActorId,
2839 /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
2840 /// pinned 1.21.11 entity registry (`DW0173`).
2841 pub entity: String,
2842 /// Optional custom name shown above the puppet.
2843 #[serde(default, skip_serializing_if = "Option::is_none")]
2844 pub name: Option<String>,
2845 /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
2846 #[serde(default, skip_serializing_if = "Option::is_none")]
2847 pub skin: Option<NpcSkin>,
2848 /// The anchor the puppet is summoned on (resolved across areas, like an
2849 /// `open-gate` / `move-npc` destination).
2850 pub anchor: AnchorId,
2851 /// Initial facing (default `south`). The puppet spawns yawed this way.
2852 #[serde(default, skip_serializing_if = "Option::is_none")]
2853 pub facing: Option<Facing>,
2854 /// If `true`, the puppet is damageable (a tower-defense creep) but stays
2855 /// knockback-immune; default `false` (fully `Invulnerable`).
2856 #[serde(default, skip_serializing_if = "is_false")]
2857 pub vulnerable: bool,
2858 /// Gear the actor wears and holds, in the same shape a wave mob uses
2859 /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
2860 /// unleashed twin, so the dormant elite the player has been circling is
2861 /// visibly the same armoured thing that stands up. Drop chances are zero —
2862 /// wave gear and actor gear are never farmable (no-grind constitution).
2863 #[serde(default, skip_serializing_if = "Option::is_none")]
2864 pub equipment: Option<MobEquipment>,
2865 /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
2866 /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
2867 /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
2868 /// elite the party fights is the elite the author tuned; without it an actor
2869 /// was stuck at vanilla base values while every wave mob could be tuned,
2870 /// which is what blocked elite authoring. A `vulnerable` actor's
2871 /// knockback-immunity is emitted first and is not authorable.
2872 #[serde(default, skip_serializing_if = "Option::is_none")]
2873 pub attributes: Option<MobAttributes>,
2874 /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
2875 /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
2876 /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
2877 ///
2878 /// A wave is not the only shape an elite takes. The set-piece souls fight —
2879 /// the armoured thing kneeling among the graves that stands up when you hit
2880 /// it — is an **actor**: staged by `spawn-actor`, given AI by
2881 /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
2882 /// this field the validation ladder's inverted floor gate could only see
2883 /// `waves[].tier`, so such a boss was *structurally invisible* to it and an
2884 /// empty finding list read as a pass while covering nothing.
2885 ///
2886 /// Like the wave field this is a **declaration, not a knob**: the compiler
2887 /// never scales an actor from it, and emission is unchanged whichever tier is
2888 /// declared. What it buys is scrutiny — the actor enters
2889 /// `validation/combat-plan.json`, and the compiler states, per tiered actor,
2890 /// whether the floor gate can measure it and why not when it cannot
2891 /// (`DW0477`).
2892 #[serde(default, skip_serializing_if = "Option::is_none")]
2893 pub tier: Option<EncounterTier>,
2894 /// What this actor leaves behind when a player kills it (DSL v0.9, task
2895 /// #179; reserved `DW0141` earlier). Only an `elite`/`boss` actor may
2896 /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
2897 /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
2898 /// not to one of its two lifecycles. A `despawn-actor` strips the
2899 /// declaration off the body before removing it, so re-caging an elite (a
2900 /// souls re-seat) never scatters its axe.
2901 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2902 pub drops: Vec<MobDrop>,
2903}
2904
2905/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
2906/// (MC: yaw 0 = +z/south).
2907#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2908#[serde(rename_all = "kebab-case")]
2909pub enum Facing {
2910 /// Facing +z (yaw 0) — the default.
2911 South,
2912 /// Facing -z (yaw 180).
2913 North,
2914 /// Facing -x (yaw 90).
2915 West,
2916 /// Facing +x (yaw 270).
2917 East,
2918}
2919
2920impl Facing {
2921 /// The kebab token (`south` / `north` / `west` / `east`).
2922 pub fn token(self) -> &'static str {
2923 match self {
2924 Facing::South => "south",
2925 Facing::North => "north",
2926 Facing::West => "west",
2927 Facing::East => "east",
2928 }
2929 }
2930}
2931
2932/// How a `despawn-actor` removes its puppet (DSL v0.6).
2933#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2934#[serde(rename_all = "kebab-case")]
2935pub enum DespawnStyle {
2936 /// Silent removal (`kill @e` with no death animation is not possible; the
2937 /// compiler removes the entity via `/kill` on an `Invulnerable` puppet, or a
2938 /// data-driven removal — see the emitter — so no death particles/sound show).
2939 Vanish,
2940 /// Plays the vanilla death animation (a cutscene death).
2941 Kill,
2942}
2943
2944impl DespawnStyle {
2945 /// The kebab token (`vanish` / `kill`).
2946 pub fn token(self) -> &'static str {
2947 match self {
2948 DespawnStyle::Vanish => "vanish",
2949 DespawnStyle::Kill => "kill",
2950 }
2951 }
2952}
2953
2954/// One step of a [`QuestEffect::Sequence`] (DSL v0.6): a group of effects fired at
2955/// an exact tick offset from the sequence's start.
2956#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2957#[serde(deny_unknown_fields)]
2958pub struct SequenceStep {
2959 /// Tick offset from the sequence start at which `effects` fire.
2960 pub at_ticks: u32,
2961 /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
2962 /// `sequence` (rejected with `DW0329`).
2963 pub effects: Vec<QuestEffect>,
2964}
2965
2966impl Objective {
2967 /// This objective's id.
2968 pub fn id(&self) -> &ObjectiveId {
2969 match self {
2970 Objective::TalkTo { id, .. }
2971 | Objective::ReachAnchor { id, .. }
2972 | Objective::Kill { id, .. }
2973 | Objective::Collect { id, .. }
2974 | Objective::Interact { id, .. } => id,
2975 }
2976 }
2977
2978 /// This objective's prerequisites.
2979 pub fn after(&self) -> &[ObjectiveId] {
2980 match self {
2981 Objective::TalkTo { after, .. }
2982 | Objective::ReachAnchor { after, .. }
2983 | Objective::Kill { after, .. }
2984 | Objective::Collect { after, .. }
2985 | Objective::Interact { after, .. } => after,
2986 }
2987 }
2988
2989 /// The short player-facing objective title (v0.3, optional).
2990 pub fn title(&self) -> Option<&str> {
2991 match self {
2992 Objective::TalkTo { title, .. }
2993 | Objective::ReachAnchor { title, .. }
2994 | Objective::Kill { title, .. }
2995 | Objective::Collect { title, .. }
2996 | Objective::Interact { title, .. } => title.as_deref(),
2997 }
2998 }
2999
3000 /// The one-line location/direction hint (v0.3, optional).
3001 pub fn hint(&self) -> Option<&str> {
3002 match self {
3003 Objective::TalkTo { hint, .. }
3004 | Objective::ReachAnchor { hint, .. }
3005 | Objective::Kill { hint, .. }
3006 | Objective::Collect { hint, .. }
3007 | Objective::Interact { hint, .. } => hint.as_deref(),
3008 }
3009 }
3010
3011 /// Mutable access to the optional player-facing title (i18n localization).
3012 pub fn title_mut(&mut self) -> &mut Option<String> {
3013 match self {
3014 Objective::TalkTo { title, .. }
3015 | Objective::ReachAnchor { title, .. }
3016 | Objective::Kill { title, .. }
3017 | Objective::Collect { title, .. }
3018 | Objective::Interact { title, .. } => title,
3019 }
3020 }
3021
3022 /// Mutable access to the optional one-line hint (i18n localization).
3023 pub fn hint_mut(&mut self) -> &mut Option<String> {
3024 match self {
3025 Objective::TalkTo { hint, .. }
3026 | Objective::ReachAnchor { hint, .. }
3027 | Objective::Kill { hint, .. }
3028 | Objective::Collect { hint, .. }
3029 | Objective::Interact { hint, .. } => hint,
3030 }
3031 }
3032
3033 /// The flags that must be set before this objective activates (v0.3).
3034 pub fn requires_flags(&self) -> &[FlagId] {
3035 match self {
3036 Objective::TalkTo { requires_flags, .. }
3037 | Objective::ReachAnchor { requires_flags, .. }
3038 | Objective::Kill { requires_flags, .. }
3039 | Objective::Collect { requires_flags, .. }
3040 | Objective::Interact { requires_flags, .. } => requires_flags,
3041 }
3042 }
3043
3044 /// The negative flag gate (DSL v0.6): flags whose being set **suppresses**
3045 /// this objective. The dual of [`Objective::requires_flags`].
3046 pub fn forbids_flags(&self) -> &[FlagId] {
3047 match self {
3048 Objective::TalkTo { forbids_flags, .. }
3049 | Objective::ReachAnchor { forbids_flags, .. }
3050 | Objective::Kill { forbids_flags, .. }
3051 | Objective::Collect { forbids_flags, .. }
3052 | Objective::Interact { forbids_flags, .. } => forbids_flags,
3053 }
3054 }
3055
3056 /// What this objective does to the story (DSL v0.8, spec-0025).
3057 pub fn happening(&self) -> Option<&Happening> {
3058 match self {
3059 Objective::TalkTo { happening, .. }
3060 | Objective::ReachAnchor { happening, .. }
3061 | Objective::Kill { happening, .. }
3062 | Objective::Collect { happening, .. }
3063 | Objective::Interact { happening, .. } => happening.as_ref(),
3064 }
3065 }
3066
3067 /// The bot stealth hint (DSL v0.4): traverse this leg sneaking.
3068 pub fn stealth(&self) -> bool {
3069 match self {
3070 Objective::TalkTo { stealth, .. }
3071 | Objective::ReachAnchor { stealth, .. }
3072 | Objective::Kill { stealth, .. }
3073 | Objective::Collect { stealth, .. }
3074 | Objective::Interact { stealth, .. } => *stealth,
3075 }
3076 }
3077
3078 /// The container this objective ADOPTS (DSL v0.8), if it is a `collect` that
3079 /// declares one: the anchor whose prefab-placed chest/barrel it fills instead
3080 /// of conjuring its own chest. `None` on every other objective and on a
3081 /// `collect` that keeps the compiler-placed chest.
3082 pub fn collect_container(&self) -> Option<&AnchorId> {
3083 match self {
3084 Objective::Collect { container, .. } => container.as_ref(),
3085 _ => None,
3086 }
3087 }
3088
3089 /// The wave whose declared drops provide this objective's item (DSL v0.9),
3090 /// if it is a `collect` that declares one. `None` on every other objective
3091 /// and on a `collect` fed by a container.
3092 pub fn collect_dropped_by(&self) -> Option<&WaveId> {
3093 match self {
3094 Objective::Collect { dropped_by, .. } => dropped_by.as_ref(),
3095 _ => None,
3096 }
3097 }
3098
3099 /// The padding-stack count of a `collect` (DSL v0.8); `0` for every other
3100 /// objective and for a `collect` that fills the single required stack only.
3101 pub fn collect_fill_count(&self) -> u32 {
3102 match self {
3103 Objective::Collect { fill_count, .. } => *fill_count,
3104 _ => 0,
3105 }
3106 }
3107
3108 /// The `interact` prop block (DSL v0.4), if this is an `interact` with a prop.
3109 pub fn prop(&self) -> Option<&Prop> {
3110 match self {
3111 Objective::Interact { prop, .. } => prop.as_ref(),
3112 _ => None,
3113 }
3114 }
3115
3116 /// The kebab type tag.
3117 pub fn kind(&self) -> &'static str {
3118 match self {
3119 Objective::TalkTo { .. } => "talk-to",
3120 Objective::ReachAnchor { .. } => "reach-anchor",
3121 Objective::Kill { .. } => "kill",
3122 Objective::Collect { .. } => "collect",
3123 Objective::Interact { .. } => "interact",
3124 }
3125 }
3126
3127 /// The v0.3 verb name if this objective is one of the verbs introduced in
3128 /// DSL v0.3 (`kill`/`collect`/`interact`). These validate in v0.3 campaigns
3129 /// and are reserved (`DW0141`) in v0.2 campaigns.
3130 pub fn v03_verb(&self) -> Option<&'static str> {
3131 match self {
3132 Objective::Kill { .. } => Some("kill"),
3133 Objective::Collect { .. } => Some("collect"),
3134 Objective::Interact { .. } => Some("interact"),
3135 Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => None,
3136 }
3137 }
3138}
3139
3140/// An effect fired by quest progress.
3141///
3142/// `give-item`, `set-flag` and `spawn-wave` are **implemented in DSL v0.3**; in a
3143/// v0.2 campaign they are still reserved and rejected with `DW0141` (see
3144/// [`QuestEffect::v03_effect`]).
3145///
3146/// `Debug` is hand-written (see the impl below the enum) because it is a
3147/// **stable content-key rendering** — the compiler's `sequence_key` hashes
3148/// `{steps:?}` to name `seq_<hash>` functions, so an effect that uses none of
3149/// the v0.6 `forbids_flags` / `move-npc on_arrive` fields must render
3150/// byte-identically to the pre-addition enum (the [`CameraShot`] rule).
3151#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3152#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3153pub enum QuestEffect {
3154 /// Opens a prefab-declared gate (one-way).
3155 OpenGate {
3156 /// The gate anchor to open.
3157 anchor: AnchorId,
3158 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3160 requires_flags: Vec<FlagId>,
3161 /// Per-effect negative flag gate (DSL v0.6); see
3162 /// [`QuestEffect::forbids_flags`].
3163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3164 forbids_flags: Vec<FlagId>,
3165 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3166 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3167 /// the hand-written `Debug` rendering below: the declaration is
3168 /// validation metadata with no emission of its own, so a content key can
3169 /// never move because a beat gained a line of prose.
3170 #[serde(default, skip_serializing_if = "Option::is_none")]
3171 happening: Option<Happening>,
3172 },
3173 /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
3174 /// fills the gate anchor's region with the block the anchor declares (e.g. the
3175 /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
3176 /// The declared fill block is prefab metadata; a gate anchor with no `block` is
3177 /// rejected (`DW0343`). The completability model treats the region as **solid**
3178 /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
3179 /// clearing is modelled) — a critical path that must cross a gate after it seals
3180 /// fails the DW0311 reachability proof.
3181 CloseGate {
3182 /// The gate anchor to seal.
3183 anchor: AnchorId,
3184 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3186 requires_flags: Vec<FlagId>,
3187 /// Per-effect negative flag gate (DSL v0.6); see
3188 /// [`QuestEffect::forbids_flags`].
3189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3190 forbids_flags: Vec<FlagId>,
3191 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3192 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3193 /// the hand-written `Debug` rendering below: the declaration is
3194 /// validation metadata with no emission of its own, so a content key can
3195 /// never move because a beat gained a line of prose.
3196 #[serde(default, skip_serializing_if = "Option::is_none")]
3197 happening: Option<Happening>,
3198 /// What the seal *says* when a player right-clicks it (DSL v0.8; reserved
3199 /// `DW0141` earlier). A sealed gate is a wall the party will walk back to
3200 /// and press: the compiler answers that press on the actionbar. Absent, the
3201 /// compiler's canonical English is baked in (`The way is sealed.`) exactly
3202 /// as `world.boundary.message` does; authored, the line is l10n-inventoried
3203 /// under `<effect-key>.sealed_hint` and translates like every other
3204 /// player-visible string.
3205 ///
3206 /// Unlike `happening`, this **does** print in the hand-written `Debug`
3207 /// below when present — it changes emission, so two otherwise-identical
3208 /// sequences that differ only in their seal's answer are different content.
3209 #[serde(default, skip_serializing_if = "Option::is_none")]
3210 sealed_hint: Option<String>,
3211 },
3212 /// Marks the campaign complete (final advancement + credits). Terminal — not
3213 /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
3214 /// so this variant carries no `requires_flags`.
3215 CampaignComplete {
3216 /// Which ENDING this is (DSL v0.8, spec-0025; reserved `DW0141` earlier).
3217 /// A campaign with more than one `campaign-complete` has more than one
3218 /// ending, and a branch that runs to an ending names it here — so the
3219 /// terminality proof (`DW0482`) can state *which* ending a branch reached
3220 /// instead of merely that something ended. There is no separate `endings`
3221 /// section: the set of endings is exactly the set named here, the same
3222 /// rule flags follow.
3223 #[serde(default, skip_serializing_if = "Option::is_none")]
3224 ending: Option<EndingId>,
3225 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3226 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3227 /// the hand-written `Debug` rendering below: the declaration is
3228 /// validation metadata with no emission of its own, so a content key can
3229 /// never move because a beat gained a line of prose.
3230 #[serde(default, skip_serializing_if = "Option::is_none")]
3231 happening: Option<Happening>,
3232 },
3233 /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
3234 GiveItem {
3235 /// Vanilla item id to give (validated against the registry).
3236 item: String,
3237 /// How many to give.
3238 count: u32,
3239 /// Optional display name (DSL v0.4), matching [`KitItem::name`].
3240 #[serde(default, skip_serializing_if = "Option::is_none")]
3241 name: Option<String>,
3242 /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
3243 /// party member. `one` hands a single copy to the player whose action fired
3244 /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
3245 /// has no acting player.
3246 #[serde(default, skip_serializing_if = "Option::is_none")]
3247 carrier: Option<Carrier>,
3248 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3250 requires_flags: Vec<FlagId>,
3251 /// Per-effect negative flag gate (DSL v0.6); see
3252 /// [`QuestEffect::forbids_flags`].
3253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3254 forbids_flags: Vec<FlagId>,
3255 },
3256 /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
3257 SetFlag {
3258 /// The flag to set.
3259 flag: FlagId,
3260 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3262 requires_flags: Vec<FlagId>,
3263 /// Per-effect negative flag gate (DSL v0.6); see
3264 /// [`QuestEffect::forbids_flags`].
3265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3266 forbids_flags: Vec<FlagId>,
3267 },
3268 /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
3269 SpawnWave {
3270 /// The wave (stage-5 `waves` ref) to spawn.
3271 wave: WaveId,
3272 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3274 requires_flags: Vec<FlagId>,
3275 /// Per-effect negative flag gate (DSL v0.6); see
3276 /// [`QuestEffect::forbids_flags`].
3277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3278 forbids_flags: Vec<FlagId>,
3279 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3280 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3281 /// the hand-written `Debug` rendering below: the declaration is
3282 /// validation metadata with no emission of its own, so a content key can
3283 /// never move because a beat gained a line of prose.
3284 #[serde(default, skip_serializing_if = "Option::is_none")]
3285 happening: Option<Happening>,
3286 },
3287 /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
3288 /// l10n key inventory like any player-visible string.
3289 Narrate {
3290 /// The line shown to the player.
3291 text: String,
3292 /// Presentation channel (default `chat`).
3293 #[serde(default, skip_serializing_if = "Option::is_none")]
3294 style: Option<NarrateStyle>,
3295 /// Optional sound id played alongside the line.
3296 #[serde(default, skip_serializing_if = "Option::is_none")]
3297 sound: Option<String>,
3298 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3299 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3300 requires_flags: Vec<FlagId>,
3301 /// Per-effect negative flag gate (DSL v0.6); see
3302 /// [`QuestEffect::forbids_flags`].
3303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3304 forbids_flags: Vec<FlagId>,
3305 },
3306 /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
3307 /// placement. Block id validated against the pinned 1.21.11 block registry;
3308 /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
3309 /// accepted and passed through verbatim (DSL v0.6).
3310 SetBlock {
3311 /// The anchor to place the block at.
3312 anchor: AnchorId,
3313 /// Vanilla block id to place.
3314 block: String,
3315 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3317 requires_flags: Vec<FlagId>,
3318 /// Per-effect negative flag gate (DSL v0.6); see
3319 /// [`QuestEffect::forbids_flags`].
3320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3321 forbids_flags: Vec<FlagId>,
3322 },
3323 /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5).
3324 DespawnNpc {
3325 /// The NPC (stage-2 ref) to remove.
3326 npc: NpcId,
3327 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3329 requires_flags: Vec<FlagId>,
3330 /// Per-effect negative flag gate (DSL v0.6); see
3331 /// [`QuestEffect::forbids_flags`].
3332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3333 forbids_flags: Vec<FlagId>,
3334 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3335 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3336 /// the hand-written `Debug` rendering below: the declaration is
3337 /// validation metadata with no emission of its own, so a content key can
3338 /// never move because a beat gained a line of prose.
3339 #[serde(default, skip_serializing_if = "Option::is_none")]
3340 happening: Option<Happening>,
3341 },
3342 /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
3343 /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
3344 /// path** by A* over the solved voxel grid and emits per-tick teleport
3345 /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
3346 /// a solid affordance. An unroutable move is a compile error (`DW0307`).
3347 MoveNpc {
3348 /// The NPC (stage-2 ref) to move.
3349 npc: NpcId,
3350 /// The destination anchor.
3351 to_anchor: AnchorId,
3352 /// Optional travel speed in blocks/tick (defaults to ~0.15).
3353 #[serde(default, skip_serializing_if = "Option::is_none")]
3354 speed: Option<f64>,
3355 /// Effects fired once the NPC arrives at the destination cell (DSL v0.6,
3356 /// reserved `DW0141` earlier) — exact parity with [`QuestEffect::MoveActor`]
3357 /// `on_arrive`: same arrival detection (the walk driver's final tick), same
3358 /// execution context, and every deep effect walker recurses into it via
3359 /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
3360 /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
3361 /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
3362 /// mark).
3363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3364 on_arrive: Vec<QuestEffect>,
3365 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3367 requires_flags: Vec<FlagId>,
3368 /// Per-effect negative flag gate (DSL v0.6); see
3369 /// [`QuestEffect::forbids_flags`].
3370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3371 forbids_flags: Vec<FlagId>,
3372 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3373 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3374 /// the hand-written `Debug` rendering below: the declaration is
3375 /// validation metadata with no emission of its own, so a content key can
3376 /// never move because a beat gained a line of prose.
3377 #[serde(default, skip_serializing_if = "Option::is_none")]
3378 happening: Option<Happening>,
3379 },
3380 /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
3381 /// gamemode+position, spectator, then dolly two co-located cameras along a
3382 /// straight-line lerp between waypoints and alternate `spectate` between them
3383 /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
3384 /// no-op and is never emitted), and restore on completion. The compiler
3385 /// validates the dolly path passes only through non-solid blocks — cameras
3386 /// fly but must not clip a solid (`DW0308`).
3387 ///
3388 /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
3389 /// emission to face that world point from its own position, so the shot keeps
3390 /// its subject framed for the whole move; without it, the camera faces along
3391 /// the direction of travel (the segment it is currently traversing).
3392 ///
3393 /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
3394 /// inside one save/restore bracket (hard cut between shots). Two accepted,
3395 /// mutually exclusive spellings, both normalized by
3396 /// [`QuestEffect::cutscene_shots`]:
3397 /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
3398 /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
3399 /// exactly equivalent to a one-entry `shots` list.
3400 ///
3401 /// Mixing or omitting both is `DW0199`.
3402 Cutscene {
3403 /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
3404 /// with the single-shot `path`/`seconds` fields (`DW0199`).
3405 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3406 shots: Vec<CameraShot>,
3407 /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
3408 /// lerp between them).
3409 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3410 path: Vec<CameraWaypoint>,
3411 /// Single-shot form (DSL v0.4): shot duration in seconds.
3412 #[serde(default, skip_serializing_if = "Option::is_none")]
3413 seconds: Option<u32>,
3414 /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
3415 /// Absent = face along the direction of travel.
3416 #[serde(default, skip_serializing_if = "Option::is_none")]
3417 look_at: Option<CameraTarget>,
3418 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3419 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3420 requires_flags: Vec<FlagId>,
3421 /// Per-effect negative flag gate (DSL v0.6); see
3422 /// [`QuestEffect::forbids_flags`].
3423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3424 forbids_flags: Vec<FlagId>,
3425 },
3426 /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
3427 /// Instantaneous (vanilla has no gradual transition); the state persists
3428 /// because the daylight cycle is frozen by sealing.
3429 SetTime {
3430 /// The time state to cut to.
3431 time: WorldTime,
3432 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3433 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3434 requires_flags: Vec<FlagId>,
3435 /// Per-effect negative flag gate (DSL v0.6); see
3436 /// [`QuestEffect::forbids_flags`].
3437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3438 forbids_flags: Vec<FlagId>,
3439 },
3440 /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
3441 /// Instantaneous; persists because the weather cycle is frozen by sealing.
3442 SetWeather {
3443 /// The weather state to cut to.
3444 weather: WorldWeather,
3445 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3447 requires_flags: Vec<FlagId>,
3448 /// Per-effect negative flag gate (DSL v0.6); see
3449 /// [`QuestEffect::forbids_flags`].
3450 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3451 forbids_flags: Vec<FlagId>,
3452 },
3453 /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
3454 /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
3455 /// sound-event registry (`DW0326` unknown). `at` selects where the sound
3456 /// originates (default: each player's own position); `volume`/`pitch` map to
3457 /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
3458 /// vanilla).
3459 PlaySound {
3460 /// The vanilla sound-event id (`minecraft:` prefix optional).
3461 sound: String,
3462 /// Where the sound plays from (default: `players`).
3463 #[serde(default, skip_serializing_if = "Option::is_none")]
3464 at: Option<SoundAt>,
3465 /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
3466 #[serde(default, skip_serializing_if = "Option::is_none")]
3467 volume: Option<f64>,
3468 /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
3469 #[serde(default, skip_serializing_if = "Option::is_none")]
3470 pitch: Option<f64>,
3471 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3472 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3473 requires_flags: Vec<FlagId>,
3474 /// Per-effect negative flag gate (DSL v0.6); see
3475 /// [`QuestEffect::forbids_flags`].
3476 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3477 forbids_flags: Vec<FlagId>,
3478 },
3479 /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
3480 /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
3481 /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
3482 /// acting player: at top level it damages every player once; inside a stealth
3483 /// `on_caught` it damages the caught player (the "caught → death → respawn at
3484 /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
3485 /// is lethal through golden apples / absorption. `within` (JSON `in`) narrows to
3486 /// acting players standing inside an anchor-centred box (the same box model as a
3487 /// stealth zone), keeping the per-`@s` semantics. `damage_type` is the damage
3488 /// type — a curated set of vanilla types that all respect `keepInventory` and do
3489 /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
3490 /// (The field is `damage_type`, not `type`, because the effect enum is
3491 /// internally tagged on `type`.)
3492 DamagePlayers {
3493 /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
3494 amount: u32,
3495 /// Optional spatial filter: only damage an acting player inside this
3496 /// anchor-centred box (`anchor ± extent`). Absent = every acting player.
3497 #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
3498 within: Option<StealthZone>,
3499 /// The damage type (default [`DamageKind::Generic`]).
3500 #[serde(default, skip_serializing_if = "Option::is_none")]
3501 damage_type: Option<DamageKind>,
3502 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3504 requires_flags: Vec<FlagId>,
3505 /// Per-effect negative flag gate (DSL v0.6); see
3506 /// [`QuestEffect::forbids_flags`].
3507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3508 forbids_flags: Vec<FlagId>,
3509 },
3510 /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
3511 /// `spawnpoint @a` at the anchor cell and mirrors the coords into
3512 /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
3513 /// `set-checkpoint` always replaces an earlier one). The compiler proves the
3514 /// cell is standable (`DW0316`) and that the remaining critical path stays
3515 /// reachable from it (`DW0315`).
3516 SetCheckpoint {
3517 /// The prefab checkpoint anchor the party respawns at.
3518 anchor: AnchorId,
3519 /// Per-player effects re-run each time a player respawns while this
3520 /// checkpoint is the active one — scene reset (e.g. re-caging an
3521 /// unleashed actor). Emitted idempotently in declared order; empty = no
3522 /// hook. Respawn is detected via the vanilla `deathCount` criterion.
3523 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3524 on_respawn: Vec<QuestEffect>,
3525 },
3526 /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
3527 /// [`QuestEffect::SetCheckpoint`] for souls-mode pacing. The effect *arms*
3528 /// the rest affordance (a `minecraft:interaction` the player right-clicks at
3529 /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
3530 /// only **when the party actually rests**. Resting fires `on_rest` — the
3531 /// scene reset that makes retry cheap: re-arming traps, re-seating waves
3532 /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
3533 /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
3534 /// so the world's answer to a death and to a rest is identical (spec-0016:
3535 /// death is an investment, never a tax).
3536 ///
3537 /// Proofs are inherited from the checkpoint machinery: the anchor must be
3538 /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
3539 /// the beat that arms the bonfire (the earliest moment a rest can happen).
3540 Bonfire {
3541 /// The prefab anchor the rest affordance stands at, and the cell the
3542 /// party respawns at once rested.
3543 anchor: AnchorId,
3544 /// Effects re-run on every rest **and** on every respawn at this bonfire
3545 /// — the scene reset. Emitted in declared order and expected to be
3546 /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
3547 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3548 on_rest: Vec<QuestEffect>,
3549 /// Title of the two-option rest dialog (DSL v0.8, owner ruling
3550 /// 2026-08-03). Absent = the compiler's canonical English `Bonfire`,
3551 /// baked at emit time (the `world.boundary.message` precedent): an
3552 /// authored line is inventoried and translates like every other
3553 /// player-visible string.
3554 #[serde(default, skip_serializing_if = "Option::is_none")]
3555 prompt: Option<String>,
3556 /// Label of the **rest and save** button. Absent = `Rest and save`.
3557 /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
3558 /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
3559 #[serde(default, skip_serializing_if = "Option::is_none")]
3560 rest_label: Option<String>,
3561 /// Label of the **save only** button. Absent = `Save only`.
3562 #[serde(default, skip_serializing_if = "Option::is_none")]
3563 save_label: Option<String>,
3564 },
3565 /// Begins a stealth beat (DSL v0.6, spec-0014; owner ruling 2026-08-01:
3566 /// zone presence alone = hidden — no sneak requirement, which collided with
3567 /// the spectator cutscene camera). While active, every player must be
3568 /// inside some `zone` each tick; a player outside every zone for
3569 /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
3570 /// Zone membership is read from the player's position. The compiler proves
3571 /// each zone is standable and reachable from the activating beat (`DW0327`).
3572 BeginStealth {
3573 /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
3574 zones: Vec<StealthZone>,
3575 /// Per-player effects fired when a player is caught (out of every zone
3576 /// for `grace_ticks`). Empty = no consequence.
3577 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3578 on_caught: Vec<QuestEffect>,
3579 /// Ticks a player may be exposed before `on_caught` fires (default 20).
3580 #[serde(default = "default_grace_ticks")]
3581 grace_ticks: u32,
3582 },
3583 /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
3584 EndStealth,
3585 /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
3586 /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
3587 /// scripted entrance a staged character needs. Idempotent: spawning an NPC
3588 /// already in the world is a no-op. Only meaningful for an NPC declared
3589 /// `deferred: true`; a non-deferred NPC is already in the world from init.
3590 SpawnNpc {
3591 /// The NPC (stage-2 ref) to summon.
3592 npc: NpcId,
3593 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3594 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3595 /// the hand-written `Debug` rendering below: the declaration is
3596 /// validation metadata with no emission of its own, so a content key can
3597 /// never move because a beat gained a line of prose.
3598 #[serde(default, skip_serializing_if = "Option::is_none")]
3599 happening: Option<Happening>,
3600 },
3601 // --- DSL v0.6 actor staging effects (spec-0014) ---
3602 /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
3603 /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
3604 SpawnActor {
3605 /// The actor (stage-5 `actors` ref) to summon.
3606 actor: ActorId,
3607 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3608 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3609 /// the hand-written `Debug` rendering below: the declaration is
3610 /// validation metadata with no emission of its own, so a content key can
3611 /// never move because a beat gained a line of prose.
3612 #[serde(default, skip_serializing_if = "Option::is_none")]
3613 happening: Option<Happening>,
3614 },
3615 /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
3616 /// animation (cutscene deaths); `vanish` is silent removal.
3617 DespawnActor {
3618 /// The actor (stage-5 `actors` ref) to remove.
3619 actor: ActorId,
3620 /// How the puppet is removed.
3621 style: DespawnStyle,
3622 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3623 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3624 /// the hand-written `Debug` rendering below: the declaration is
3625 /// validation metadata with no emission of its own, so a content key can
3626 /// never move because a beat gained a line of prose.
3627 #[serde(default, skip_serializing_if = "Option::is_none")]
3628 happening: Option<Happening>,
3629 },
3630 /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
3631 /// the assembled model, using the actor's hitbox footprint, yawed along the
3632 /// path tangent (DSL v0.6, task #46). Concurrent movers are allowed (a herded
3633 /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
3634 /// effects fire once the puppet reaches the destination cell.
3635 MoveActor {
3636 /// The actor (stage-5 `actors` ref) to move.
3637 actor: ActorId,
3638 /// The destination anchor.
3639 to_anchor: AnchorId,
3640 /// Optional travel speed in blocks/tick (defaults to ~0.15).
3641 #[serde(default, skip_serializing_if = "Option::is_none")]
3642 speed: Option<f64>,
3643 /// Effects fired once the puppet arrives at the destination cell.
3644 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3645 on_arrive: Vec<QuestEffect>,
3646 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3647 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3648 /// the hand-written `Debug` rendering below: the declaration is
3649 /// validation metadata with no emission of its own, so a content key can
3650 /// never move because a beat gained a line of prose.
3651 #[serde(default, skip_serializing_if = "Option::is_none")]
3652 happening: Option<Happening>,
3653 },
3654 /// Replaces an actor's puppet with a real-AI twin of the same type / position /
3655 /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
3656 /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
3657 /// special verb.
3658 UnleashActor {
3659 /// The actor (stage-5 `actors` ref) to unleash.
3660 actor: ActorId,
3661 /// What this beat does to the story (DSL v0.8, spec-0025; required at
3662 /// 0.8.0, `DW0481`; reserved `DW0141` earlier). Deliberately absent from
3663 /// the hand-written `Debug` rendering below: the declaration is
3664 /// validation metadata with no emission of its own, so a content key can
3665 /// never move because a beat gained a line of prose.
3666 #[serde(default, skip_serializing_if = "Option::is_none")]
3667 happening: Option<Happening>,
3668 },
3669 /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
3670 /// at exact tick offsets. Effects are any in the stage-5 set except a nested
3671 /// `sequence` (rejected with `DW0329`).
3672 Sequence {
3673 /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
3674 steps: Vec<SequenceStep>,
3675 },
3676 /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
3677 /// projectiles with real velocity vectors, fired from a gallery slot into a
3678 /// declared kill zone.
3679 ///
3680 /// The contract is **saturation, not sniping** (owner ruling 2026-08-03).
3681 /// Every salvo puts one projectile on the trajectory to **every standable
3682 /// cell of `kill_zone`**, plus one aimed at the triggering player's
3683 /// fire-time position (which punishes standing still). A player therefore
3684 /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
3685 /// decision rather than a lucky step. Coverage is proven at compile time:
3686 /// `from_anchor` must have clear line of fire to every standable kill-zone
3687 /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
3688 /// on the stairs" is a build-time fact, not a hope.
3689 ///
3690 /// Projectiles are summoned `NoGravity` so the flown path is exactly the
3691 /// straight segment the coverage proof checks — proof and runtime share one
3692 /// geometry. Drag scales speed but not direction, so the line is preserved.
3693 Volley {
3694 /// Projectile entity id (default `minecraft:arrow`; validated against
3695 /// the pinned 1.21.11 registry, `DW0441`).
3696 #[serde(default, skip_serializing_if = "Option::is_none")]
3697 projectile: Option<String>,
3698 /// The gallery slot the volley is fired FROM — a point anchor. Its cell
3699 /// must be clear (a projectile spawned inside a wall never leaves it).
3700 from_anchor: AnchorId,
3701 /// The zone the volley must blanket, as an anchor-centred box
3702 /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
3703 /// `begin-stealth`'s `zones` use. Every standable cell in it receives
3704 /// fire each salvo.
3705 ///
3706 /// Deliberately NOT a bare prefab `region` anchor: the assembled model
3707 /// clears every gate-region anchor's cells unconditionally, so
3708 /// describing a zone that way would delete the geometry it names.
3709 kill_zone: StealthZone,
3710 /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
3711 #[serde(default, skip_serializing_if = "Option::is_none")]
3712 salvos: Option<u32>,
3713 /// Ticks between salvos (default 10, `DW0443` bounds it).
3714 #[serde(default, skip_serializing_if = "Option::is_none")]
3715 interval: Option<u32>,
3716 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3717 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3718 requires_flags: Vec<FlagId>,
3719 /// Per-effect negative flag gate (DSL v0.6); see
3720 /// [`QuestEffect::forbids_flags`].
3721 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3722 forbids_flags: Vec<FlagId>,
3723 },
3724 /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
3725 /// drop them as `falling_block` entities — the buried-alive trap redstone
3726 /// cannot express at all.
3727 ///
3728 /// The post-collapse world is **modeled, not guessed**: the compiler clears
3729 /// the region, settles every dropped column through the existing gravity
3730 /// model (spec-0010), optionally paves the landing surface with
3731 /// `then_floor`, and re-runs the critical-path completability proof against
3732 /// that mutated world (`DW0445`). A collapse that buries the only route is a
3733 /// build error, exactly as a `shortcut` seal that strands the party is.
3734 Collapse {
3735 /// The volume whose blocks fall, as an anchor-centred box
3736 /// (`anchor ± extent`). Must hold blocks and sit above standable footing
3737 /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
3738 ///
3739 /// An anchor-centred box rather than a prefab `region` anchor for a
3740 /// load-bearing reason: the assembled model deletes every gate-region
3741 /// anchor's cells, so a ceiling slab declared that way would already be
3742 /// gone before the trap ever fired.
3743 region_anchor: StealthZone,
3744 /// The block the falling entities are made of (default
3745 /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
3746 #[serde(default, skip_serializing_if = "Option::is_none")]
3747 falling_block: Option<String>,
3748 /// Optional block the landing surface is paved with once the debris
3749 /// settles — the authored post-collapse floor the completability proof
3750 /// reasons over.
3751 #[serde(default, skip_serializing_if = "Option::is_none")]
3752 then_floor: Option<String>,
3753 /// Per-effect flag gate (DSL v0.6); see [`QuestEffect::requires_flags`].
3754 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3755 requires_flags: Vec<FlagId>,
3756 /// Per-effect negative flag gate (DSL v0.6); see
3757 /// [`QuestEffect::forbids_flags`].
3758 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3759 forbids_flags: Vec<FlagId>,
3760 },
3761}
3762
3763/// `Debug` is hand-written because it is a **stable content-key rendering**: the
3764/// compiler's `sequence_key` (FNV over `{steps:?}`, where a step's `effects` are
3765/// `QuestEffect`s) names generated `seq_<hash>` functions from it, so an effect
3766/// that uses none of the v0.6 additions (`forbids_flags` anywhere, `on_arrive`
3767/// on `move-npc`) must render byte-identically to the pre-addition derive —
3768/// otherwise every existing sequence would silently churn its function names on
3769/// a purely additive schema change (the [`CameraShot`] precedent). Rules:
3770/// every pre-existing field prints exactly as `#[derive(Debug)]` printed it (in
3771/// declaration order); `forbids_flags` prints only when non-empty; `move-npc`'s
3772/// `on_arrive` prints only when non-empty.
3773impl std::fmt::Debug for QuestEffect {
3774 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3775 /// Append `forbids_flags` only when non-empty (see the impl doc).
3776 fn ff<'c, 'a, 'b: 'a>(
3777 d: &'c mut std::fmt::DebugStruct<'a, 'b>,
3778 forbids_flags: &[FlagId],
3779 ) -> &'c mut std::fmt::DebugStruct<'a, 'b> {
3780 if forbids_flags.is_empty() {
3781 d
3782 } else {
3783 d.field("forbids_flags", &forbids_flags)
3784 }
3785 }
3786 match self {
3787 QuestEffect::OpenGate {
3788 anchor,
3789 requires_flags,
3790 forbids_flags,
3791 ..
3792 } => ff(
3793 f.debug_struct("OpenGate")
3794 .field("anchor", anchor)
3795 .field("requires_flags", requires_flags),
3796 forbids_flags,
3797 )
3798 .finish(),
3799 QuestEffect::CloseGate {
3800 anchor,
3801 requires_flags,
3802 forbids_flags,
3803 sealed_hint,
3804 ..
3805 } => {
3806 let mut s = f.debug_struct("CloseGate");
3807 let d = ff(
3808 s.field("anchor", anchor)
3809 .field("requires_flags", requires_flags),
3810 forbids_flags,
3811 );
3812 // Prints only when authored (the additive-field rule): a campaign
3813 // that takes the compiler's canonical seal line renders exactly as
3814 // the pre-0.8 derive did, so no existing `seq_<hash>` moves.
3815 match sealed_hint {
3816 Some(h) => d.field("sealed_hint", h).finish(),
3817 None => d.finish(),
3818 }
3819 }
3820 QuestEffect::CampaignComplete { .. } => f.write_str("CampaignComplete"),
3821 QuestEffect::GiveItem {
3822 item,
3823 count,
3824 name,
3825 carrier,
3826 requires_flags,
3827 forbids_flags,
3828 } => ff(
3829 f.debug_struct("GiveItem")
3830 .field("item", item)
3831 .field("count", count)
3832 .field("name", name)
3833 .field("carrier", carrier)
3834 .field("requires_flags", requires_flags),
3835 forbids_flags,
3836 )
3837 .finish(),
3838 QuestEffect::SetFlag {
3839 flag,
3840 requires_flags,
3841 forbids_flags,
3842 } => ff(
3843 f.debug_struct("SetFlag")
3844 .field("flag", flag)
3845 .field("requires_flags", requires_flags),
3846 forbids_flags,
3847 )
3848 .finish(),
3849 QuestEffect::SpawnWave {
3850 wave,
3851 requires_flags,
3852 forbids_flags,
3853 ..
3854 } => ff(
3855 f.debug_struct("SpawnWave")
3856 .field("wave", wave)
3857 .field("requires_flags", requires_flags),
3858 forbids_flags,
3859 )
3860 .finish(),
3861 QuestEffect::Narrate {
3862 text,
3863 style,
3864 sound,
3865 requires_flags,
3866 forbids_flags,
3867 } => ff(
3868 f.debug_struct("Narrate")
3869 .field("text", text)
3870 .field("style", style)
3871 .field("sound", sound)
3872 .field("requires_flags", requires_flags),
3873 forbids_flags,
3874 )
3875 .finish(),
3876 QuestEffect::SetBlock {
3877 anchor,
3878 block,
3879 requires_flags,
3880 forbids_flags,
3881 } => ff(
3882 f.debug_struct("SetBlock")
3883 .field("anchor", anchor)
3884 .field("block", block)
3885 .field("requires_flags", requires_flags),
3886 forbids_flags,
3887 )
3888 .finish(),
3889 QuestEffect::DespawnNpc {
3890 npc,
3891 requires_flags,
3892 forbids_flags,
3893 ..
3894 } => ff(
3895 f.debug_struct("DespawnNpc")
3896 .field("npc", npc)
3897 .field("requires_flags", requires_flags),
3898 forbids_flags,
3899 )
3900 .finish(),
3901 QuestEffect::MoveNpc {
3902 npc,
3903 to_anchor,
3904 speed,
3905 on_arrive,
3906 requires_flags,
3907 forbids_flags,
3908 ..
3909 } => {
3910 let mut d = f.debug_struct("MoveNpc");
3911 d.field("npc", npc)
3912 .field("to_anchor", to_anchor)
3913 .field("speed", speed);
3914 if !on_arrive.is_empty() {
3915 d.field("on_arrive", on_arrive);
3916 }
3917 d.field("requires_flags", requires_flags);
3918 ff(&mut d, forbids_flags).finish()
3919 }
3920 QuestEffect::Cutscene {
3921 shots,
3922 path,
3923 seconds,
3924 look_at,
3925 requires_flags,
3926 forbids_flags,
3927 } => ff(
3928 f.debug_struct("Cutscene")
3929 .field("shots", shots)
3930 .field("path", path)
3931 .field("seconds", seconds)
3932 .field("look_at", look_at)
3933 .field("requires_flags", requires_flags),
3934 forbids_flags,
3935 )
3936 .finish(),
3937 QuestEffect::SetTime {
3938 time,
3939 requires_flags,
3940 forbids_flags,
3941 } => ff(
3942 f.debug_struct("SetTime")
3943 .field("time", time)
3944 .field("requires_flags", requires_flags),
3945 forbids_flags,
3946 )
3947 .finish(),
3948 QuestEffect::SetWeather {
3949 weather,
3950 requires_flags,
3951 forbids_flags,
3952 } => ff(
3953 f.debug_struct("SetWeather")
3954 .field("weather", weather)
3955 .field("requires_flags", requires_flags),
3956 forbids_flags,
3957 )
3958 .finish(),
3959 QuestEffect::PlaySound {
3960 sound,
3961 at,
3962 volume,
3963 pitch,
3964 requires_flags,
3965 forbids_flags,
3966 } => ff(
3967 f.debug_struct("PlaySound")
3968 .field("sound", sound)
3969 .field("at", at)
3970 .field("volume", volume)
3971 .field("pitch", pitch)
3972 .field("requires_flags", requires_flags),
3973 forbids_flags,
3974 )
3975 .finish(),
3976 QuestEffect::DamagePlayers {
3977 amount,
3978 within,
3979 damage_type,
3980 requires_flags,
3981 forbids_flags,
3982 } => ff(
3983 f.debug_struct("DamagePlayers")
3984 .field("amount", amount)
3985 .field("within", within)
3986 .field("damage_type", damage_type)
3987 .field("requires_flags", requires_flags),
3988 forbids_flags,
3989 )
3990 .finish(),
3991 QuestEffect::SetCheckpoint { anchor, on_respawn } => f
3992 .debug_struct("SetCheckpoint")
3993 .field("anchor", anchor)
3994 .field("on_respawn", on_respawn)
3995 .finish(),
3996 QuestEffect::Bonfire {
3997 anchor,
3998 on_rest,
3999 prompt,
4000 rest_label,
4001 save_label,
4002 } => f
4003 .debug_struct("Bonfire")
4004 .field("anchor", anchor)
4005 .field("on_rest", on_rest)
4006 .field("prompt", prompt)
4007 .field("rest_label", rest_label)
4008 .field("save_label", save_label)
4009 .finish(),
4010 QuestEffect::BeginStealth {
4011 zones,
4012 on_caught,
4013 grace_ticks,
4014 } => f
4015 .debug_struct("BeginStealth")
4016 .field("zones", zones)
4017 .field("on_caught", on_caught)
4018 .field("grace_ticks", grace_ticks)
4019 .finish(),
4020 QuestEffect::EndStealth => f.write_str("EndStealth"),
4021 QuestEffect::SpawnNpc { npc, .. } => {
4022 f.debug_struct("SpawnNpc").field("npc", npc).finish()
4023 }
4024 QuestEffect::SpawnActor { actor, .. } => {
4025 f.debug_struct("SpawnActor").field("actor", actor).finish()
4026 }
4027 QuestEffect::DespawnActor { actor, style, .. } => f
4028 .debug_struct("DespawnActor")
4029 .field("actor", actor)
4030 .field("style", style)
4031 .finish(),
4032 QuestEffect::MoveActor {
4033 actor,
4034 to_anchor,
4035 speed,
4036 on_arrive,
4037 ..
4038 } => f
4039 .debug_struct("MoveActor")
4040 .field("actor", actor)
4041 .field("to_anchor", to_anchor)
4042 .field("speed", speed)
4043 .field("on_arrive", on_arrive)
4044 .finish(),
4045 QuestEffect::UnleashActor { actor, .. } => f
4046 .debug_struct("UnleashActor")
4047 .field("actor", actor)
4048 .finish(),
4049 QuestEffect::Sequence { steps } => {
4050 f.debug_struct("Sequence").field("steps", steps).finish()
4051 }
4052 // spec-0022 additions. These are NEW variants, so there is no
4053 // pre-addition rendering to preserve — every field prints
4054 // unconditionally, which is the stable choice going forward.
4055 QuestEffect::Volley {
4056 projectile,
4057 from_anchor,
4058 kill_zone,
4059 salvos,
4060 interval,
4061 requires_flags,
4062 forbids_flags,
4063 } => f
4064 .debug_struct("Volley")
4065 .field("projectile", projectile)
4066 .field("from_anchor", from_anchor)
4067 .field("kill_zone", kill_zone)
4068 .field("salvos", salvos)
4069 .field("interval", interval)
4070 .field("requires_flags", requires_flags)
4071 .field("forbids_flags", forbids_flags)
4072 .finish(),
4073 QuestEffect::Collapse {
4074 region_anchor,
4075 falling_block,
4076 then_floor,
4077 requires_flags,
4078 forbids_flags,
4079 } => f
4080 .debug_struct("Collapse")
4081 .field("region_anchor", region_anchor)
4082 .field("falling_block", falling_block)
4083 .field("then_floor", then_floor)
4084 .field("requires_flags", requires_flags)
4085 .field("forbids_flags", forbids_flags)
4086 .finish(),
4087 }
4088 }
4089}
4090
4091/// Default `grace_ticks` for [`QuestEffect::BeginStealth`] (spec-0014).
4092fn default_grace_ticks() -> u32 {
4093 20
4094}
4095
4096/// Default projectile for [`QuestEffect::Volley`] (spec-0022).
4097pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
4098/// Default salvo count for [`QuestEffect::Volley`] (spec-0022).
4099pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
4100/// Default ticks between salvos for [`QuestEffect::Volley`] (spec-0022).
4101pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
4102/// Largest admissible `salvos` — beyond this a volley is an entity-count
4103/// hazard rather than a trap (`DW0443`).
4104pub const MAX_VOLLEY_SALVOS: u32 = 16;
4105/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
4106/// slower than this is no longer one event the player reads as a trap.
4107pub const MAX_VOLLEY_INTERVAL: u32 = 200;
4108/// Default falling block for [`QuestEffect::Collapse`] (spec-0022).
4109pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
4110
4111/// A stealth "shadow" region (DSL v0.6, spec-0014): an axis-aligned box centred
4112/// on `anchor`, extending `extent` blocks along each axis (so the box spans
4113/// `anchor ± extent`). Presented in-world via dark cells but judged purely by
4114/// region membership, so the check is deterministic and provable.
4115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4116#[serde(deny_unknown_fields)]
4117pub struct StealthZone {
4118 /// The prefab anchor at the centre of the zone box.
4119 pub anchor: AnchorId,
4120 /// Half-extents `[x, y, z]` in blocks from the anchor (each component ≥ 0);
4121 /// the zone AABB is `[anchor - extent, anchor + extent]`.
4122 pub extent: [u32; 3],
4123}
4124
4125/// Where a [`QuestEffect::PlaySound`] originates (DSL v0.6, spec-0014). The
4126/// `actor` variant is accepted by the schema but not yet wired — it is rejected
4127/// with `DW0335` until the actors surface (spec-0014 `actors[]`) lands, at which
4128/// point it resolves to the actor's position like `move-actor` does.
4129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4130#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
4131pub enum SoundAt {
4132 /// Play the sound positioned at a resolved anchor, audible to all players.
4133 Anchor {
4134 /// The anchor the sound plays from.
4135 anchor: AnchorId,
4136 },
4137 /// Play the sound at each player's own position (the default).
4138 Players,
4139 /// Play the sound at a scripted actor's position (deferred — `DW0335` until
4140 /// the actors surface lands).
4141 Actor {
4142 /// The actor id (stage-5 `actors[]`; not yet resolvable).
4143 actor: String,
4144 },
4145}
4146
4147/// The damage type of a [`QuestEffect::DamagePlayers`] effect (DSL v0.6). A
4148/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
4149/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
4150/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
4151/// `generic_kill` types are deliberately excluded, so a scripted consequence can
4152/// never silently void a player's held totem. Modelled as an enum (not a free
4153/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
4154/// registry / diagnostic. `generic` is the default: command damage that respects
4155/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
4156#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4157#[serde(rename_all = "kebab-case")]
4158pub enum DamageKind {
4159 /// `minecraft:generic` — armor-ignoring command damage (default).
4160 Generic,
4161 /// `minecraft:magic` — magical damage.
4162 Magic,
4163 /// `minecraft:wither` — the wither/withering effect's damage type.
4164 Wither,
4165 /// `minecraft:on_fire` — burning damage.
4166 Fire,
4167 /// `minecraft:drown` — drowning damage.
4168 Drown,
4169 /// `minecraft:freeze` — powder-snow freezing damage.
4170 Freeze,
4171 /// `minecraft:fall` — fall damage.
4172 Fall,
4173 /// `minecraft:lightning_bolt` — a lightning strike's damage type.
4174 LightningBolt,
4175 /// `minecraft:explosion` — a (non-player) explosion's damage type.
4176 Explosion,
4177}
4178
4179impl DamageKind {
4180 /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
4181 pub fn id(self) -> &'static str {
4182 match self {
4183 DamageKind::Generic => "minecraft:generic",
4184 DamageKind::Magic => "minecraft:magic",
4185 DamageKind::Wither => "minecraft:wither",
4186 DamageKind::Fire => "minecraft:on_fire",
4187 DamageKind::Drown => "minecraft:drown",
4188 DamageKind::Freeze => "minecraft:freeze",
4189 DamageKind::Fall => "minecraft:fall",
4190 DamageKind::LightningBolt => "minecraft:lightning_bolt",
4191 DamageKind::Explosion => "minecraft:explosion",
4192 }
4193 }
4194}
4195
4196/// The presentation channel for a [`QuestEffect::Narrate`] (DSL v0.4).
4197#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4198#[serde(rename_all = "kebab-case")]
4199pub enum NarrateStyle {
4200 /// A chat line (default).
4201 Chat,
4202 /// A large on-screen title.
4203 Title,
4204 /// An on-screen subtitle.
4205 Subtitle,
4206 /// An "art" title rendered through the delve's custom resource-pack pixel-banner
4207 /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
4208 /// spec-0014). Text is checked at compile time against the font's glyph inventory
4209 /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
4210 /// renders in the vanilla title slot, so it is width-checked like any title
4211 /// (`DW0330`) — roughly 15 glyphs fit on screen.
4212 Art,
4213}
4214
4215impl NarrateStyle {
4216 /// The kebab tag (`chat` / `title` / `subtitle` / `art`).
4217 pub fn token(self) -> &'static str {
4218 match self {
4219 NarrateStyle::Chat => "chat",
4220 NarrateStyle::Title => "title",
4221 NarrateStyle::Subtitle => "subtitle",
4222 NarrateStyle::Art => "art",
4223 }
4224 }
4225}
4226
4227/// One camera waypoint of a [`QuestEffect::Cutscene`] (DSL v0.4): an anchor plus
4228/// an integer block offset from it, giving the camera's world position.
4229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4230#[serde(deny_unknown_fields)]
4231pub struct CameraWaypoint {
4232 /// The anchor the waypoint is relative to.
4233 pub anchor: AnchorId,
4234 /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
4235 #[serde(default, skip_serializing_if = "is_zero3")]
4236 pub offset: [i32; 3],
4237}
4238
4239/// One shot of a [`QuestEffect::Cutscene`] (DSL v0.6): a camera dolly with its
4240/// own duration and optional subject. A cutscene plays its shots back-to-back —
4241/// a hard cut between them — inside a single gamemode/position save-restore
4242/// bracket, so a wide establishing move can be followed by an interior close-up
4243/// without the players ever leaving the cinematic.
4244#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4245#[serde(deny_unknown_fields)]
4246pub struct CameraShot {
4247 /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
4248 /// path is a static shot. Required without `shot_style`; with one, optional —
4249 /// an explicit `path` always overrides the style's expanded dolly.
4250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4251 pub path: Vec<CameraWaypoint>,
4252 /// This shot's duration in seconds. Required without `shot_style`; with one,
4253 /// optional — the style's default duration applies (see
4254 /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
4255 #[serde(default, skip_serializing_if = "Option::is_none")]
4256 pub seconds: Option<u32>,
4257 /// Optional subject the camera keeps framed for this shot. Absent = face
4258 /// along the direction of travel (or, under a `shot_style`, the style's own
4259 /// aim at its `subject`). An explicit `look_at` always overrides a style's
4260 /// aim.
4261 #[serde(default, skip_serializing_if = "Option::is_none")]
4262 pub look_at: Option<CameraTarget>,
4263 /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
4264 /// compiler expands the style deterministically into a camera dolly +
4265 /// per-keyframe aim from the `subject`'s resolved geometry. Requires
4266 /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
4267 /// the corresponding expanded part (`DW0348` polices the combinations).
4268 #[serde(default, skip_serializing_if = "Option::is_none")]
4269 pub shot_style: Option<ShotStyle>,
4270 /// The styled shot's subject — what the shot is *about*. Required with
4271 /// `shot_style`, rejected without one (`DW0348`).
4272 #[serde(default, skip_serializing_if = "Option::is_none")]
4273 pub subject: Option<CameraSubject>,
4274 /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
4275 #[serde(default, skip_serializing_if = "Option::is_none")]
4276 pub subject_b: Option<CameraSubject>,
4277 /// Styled shots only: the style's characteristic camera distance in blocks
4278 /// (its *start* distance for the dolly styles). Default per style — see the
4279 /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
4280 /// (`DW0348`).
4281 #[serde(default, skip_serializing_if = "Option::is_none")]
4282 pub dist: Option<f64>,
4283 /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
4284 /// default 90 (`DW0348` elsewhere or out of range).
4285 #[serde(default, skip_serializing_if = "Option::is_none")]
4286 pub degrees: Option<f64>,
4287 /// Styled shots only: placement bearing in degrees — where the camera sits
4288 /// (or starts) relative to the subject, measured like a Minecraft yaw
4289 /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
4290 /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
4291 /// `side-track` the bearing picks which side of the subject's travel the
4292 /// camera runs abeam (`0` = right of travel, `180` = left).
4293 #[serde(default, skip_serializing_if = "Option::is_none")]
4294 pub bearing: Option<f64>,
4295}
4296
4297/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
4298/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
4299/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
4300/// is **distance only** — vanilla has no in-game FOV control.
4301#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4302#[serde(rename_all = "kebab-case")]
4303pub enum ShotStyle {
4304 /// Fully static close framing of a prop/detail. The beat that always looks
4305 /// right.
4306 Insert,
4307 /// Static position; only the aim turns as the subject (ideally moving)
4308 /// passes. Rockstar's roadside "ground view".
4309 LockedOff,
4310 /// Straight dolly toward the subject along the view axis; medium → close.
4311 PushIn,
4312 /// Reverse of `push-in`: close → wide, revealing context.
4313 PullBackReveal,
4314 /// High and far, descending and closing on the subject. First sight of an
4315 /// area.
4316 EstablishingCrane,
4317 /// Constant-radius, constant-height arc around the subject.
4318 OrbitArc,
4319 /// Parallel dolly abeam a **moving** subject at constant offset — needs a
4320 /// compiler-known subject path (`DW0349`).
4321 SideTrack,
4322 /// Static placement solved so **both** subjects land on opposite thirds
4323 /// (Toric-space construction). Needs `subject_b`.
4324 TwoShot,
4325 /// Low, close, trailing a **moving** subject near ground level — needs a
4326 /// compiler-known subject path (`DW0349`).
4327 LowFollow,
4328}
4329
4330impl ShotStyle {
4331 /// The kebab token (diagnostics, digests).
4332 pub fn token(self) -> &'static str {
4333 match self {
4334 ShotStyle::Insert => "insert",
4335 ShotStyle::LockedOff => "locked-off",
4336 ShotStyle::PushIn => "push-in",
4337 ShotStyle::PullBackReveal => "pull-back-reveal",
4338 ShotStyle::EstablishingCrane => "establishing-crane",
4339 ShotStyle::OrbitArc => "orbit-arc",
4340 ShotStyle::SideTrack => "side-track",
4341 ShotStyle::TwoShot => "two-shot",
4342 ShotStyle::LowFollow => "low-follow",
4343 }
4344 }
4345
4346 /// Default shot duration in seconds, from the dossier's per-style duration
4347 /// ranges (§2, anchored on the film-editing ASL literature) — the value an
4348 /// omitted `seconds` resolves to.
4349 pub fn default_seconds(self) -> u32 {
4350 match self {
4351 ShotStyle::Insert => 2,
4352 ShotStyle::LockedOff => 6,
4353 ShotStyle::PushIn => 4,
4354 ShotStyle::PullBackReveal => 6,
4355 ShotStyle::EstablishingCrane => 8,
4356 ShotStyle::OrbitArc => 8,
4357 ShotStyle::SideTrack => 8,
4358 ShotStyle::TwoShot => 5,
4359 ShotStyle::LowFollow => 5,
4360 }
4361 }
4362
4363 /// `true` for the styles whose subject must be *moving* on a compiler-known
4364 /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
4365 /// `side-track` and `low-follow` (`DW0349` otherwise).
4366 pub fn needs_moving_subject(self) -> bool {
4367 matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
4368 }
4369}
4370
4371/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
4372/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
4373/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
4374/// **plus one block up** (torso height, so a close shot does not frame feet)
4375/// before `offset` is applied; an `anchor` subject aims at the block centre
4376/// exactly like a [`CameraTarget`].
4377/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
4378/// (task #78). Serde has no variant-level `deny_unknown_fields`, so an untagged
4379/// enum with inline struct variants silently *ignores* any key it does not
4380/// recognise: `{"npc": …, "ofset": [0,1,0]}` deserialized happily with the offset
4381/// dropped, and `{"anchor": …, "npc": …}` quietly matched `Anchor` and discarded
4382/// the NPC. Lifting each variant into its own type restores the repo-wide
4383/// deny-unknown rule for both serde and the published JSON Schema
4384/// (`additionalProperties: false`): a mistyped shot subject now fails the schema
4385/// instead of rendering a shot pointed somewhere the author never asked for.
4386#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4387#[serde(untagged)]
4388pub enum CameraSubject {
4389 /// A fixed world point: prefab anchor + offset.
4390 Anchor(AnchorSubject),
4391 /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
4392 /// group / sequence, else static at its declared (or spawn) anchor.
4393 Npc(NpcSubject),
4394 /// A stage-5 actor — moving if a `move-actor` for it runs in the same
4395 /// effect group / sequence, else static at its declared anchor.
4396 Actor(ActorSubject),
4397}
4398
4399/// A [`CameraSubject::Anchor`] payload: a fixed world point.
4400#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4401#[serde(deny_unknown_fields)]
4402pub struct AnchorSubject {
4403 /// The anchor the subject sits at.
4404 pub anchor: AnchorId,
4405 /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
4406 #[serde(default, skip_serializing_if = "is_zero3")]
4407 pub offset: [i32; 3],
4408}
4409
4410/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
4411#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4412#[serde(deny_unknown_fields)]
4413pub struct NpcSubject {
4414 /// The NPC (stage-2 ref).
4415 pub npc: NpcId,
4416 /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
4417 #[serde(default, skip_serializing_if = "is_zero3")]
4418 pub offset: [i32; 3],
4419}
4420
4421/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
4422#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4423#[serde(deny_unknown_fields)]
4424pub struct ActorSubject {
4425 /// The actor (stage-5 `actors` ref).
4426 pub actor: ActorId,
4427 /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
4428 #[serde(default, skip_serializing_if = "is_zero3")]
4429 pub offset: [i32; 3],
4430}
4431
4432impl CameraSubject {
4433 /// The subject's integer offset, whichever variant.
4434 pub fn offset(&self) -> [i32; 3] {
4435 match self {
4436 CameraSubject::Anchor(s) => s.offset,
4437 CameraSubject::Npc(s) => s.offset,
4438 CameraSubject::Actor(s) => s.offset,
4439 }
4440 }
4441
4442 /// A short canonical rendering for digests/diagnostics.
4443 pub fn canon(&self) -> String {
4444 let (kind, id, o) = match self {
4445 CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
4446 CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
4447 CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
4448 };
4449 format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
4450 }
4451}
4452
4453/// `Debug` is hand-written because it is a **stable content-key rendering**:
4454/// the compiler's `sequence_key` (FNV over `{steps:?}`) names generated
4455/// `seq_<hash>` functions from it, so a shot that uses none of the v0.6 style
4456/// fields must render byte-identically to the pre-style struct (`seconds`
4457/// prints its inner value; absent style fields print nothing) — otherwise
4458/// every existing sequence containing a cutscene would silently churn its
4459/// function names on a purely additive schema change.
4460impl std::fmt::Debug for CameraShot {
4461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4462 let mut d = f.debug_struct("CameraShot");
4463 d.field("path", &self.path);
4464 match self.seconds {
4465 Some(v) => d.field("seconds", &v),
4466 None => d.field("seconds", &self.seconds),
4467 };
4468 d.field("look_at", &self.look_at);
4469 if self.shot_style.is_some() {
4470 d.field("shot_style", &self.shot_style);
4471 }
4472 if self.subject.is_some() {
4473 d.field("subject", &self.subject);
4474 }
4475 if self.subject_b.is_some() {
4476 d.field("subject_b", &self.subject_b);
4477 }
4478 if self.dist.is_some() {
4479 d.field("dist", &self.dist);
4480 }
4481 if self.degrees.is_some() {
4482 d.field("degrees", &self.degrees);
4483 }
4484 if self.bearing.is_some() {
4485 d.field("bearing", &self.bearing);
4486 }
4487 d.finish()
4488 }
4489}
4490
4491impl CameraShot {
4492 /// The shot's resolved duration in seconds: explicit `seconds`, else the
4493 /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
4494 /// fallback only keeps downstream passes total).
4495 pub fn resolved_seconds(&self) -> u32 {
4496 self.seconds
4497 .or(self.shot_style.map(ShotStyle::default_seconds))
4498 .unwrap_or(1)
4499 }
4500}
4501
4502/// The subject a [`QuestEffect::Cutscene`] camera keeps framed (DSL v0.6): an
4503/// anchor plus an integer block offset from it, giving the world point every
4504/// dolly camera is aimed at. Same shape as a [`CameraWaypoint`] — a waypoint says
4505/// where the camera *is*, a target says what it *looks at*.
4506#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4507#[serde(deny_unknown_fields)]
4508pub struct CameraTarget {
4509 /// The anchor the look target is relative to.
4510 pub anchor: AnchorId,
4511 /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
4512 #[serde(default, skip_serializing_if = "is_zero3")]
4513 pub offset: [i32; 3],
4514}
4515
4516/// serde `skip_serializing_if` helper: skip a `[0, 0, 0]` offset.
4517fn is_zero3(v: &[i32; 3]) -> bool {
4518 *v == [0, 0, 0]
4519}
4520
4521impl QuestEffect {
4522 /// The kebab-case `type` tag this effect serializes as — the verb a
4523 /// diagnostic should name so the author can find it in the JSON.
4524 pub fn verb(&self) -> &'static str {
4525 match self {
4526 QuestEffect::OpenGate { .. } => "open-gate",
4527 QuestEffect::CloseGate { .. } => "close-gate",
4528 QuestEffect::CampaignComplete { .. } => "campaign-complete",
4529 QuestEffect::GiveItem { .. } => "give-item",
4530 QuestEffect::SetFlag { .. } => "set-flag",
4531 QuestEffect::SpawnWave { .. } => "spawn-wave",
4532 QuestEffect::Narrate { .. } => "narrate",
4533 QuestEffect::SetBlock { .. } => "set-block",
4534 QuestEffect::DespawnNpc { .. } => "despawn-npc",
4535 QuestEffect::MoveNpc { .. } => "move-npc",
4536 QuestEffect::Cutscene { .. } => "cutscene",
4537 QuestEffect::SetTime { .. } => "set-time",
4538 QuestEffect::SetWeather { .. } => "set-weather",
4539 QuestEffect::PlaySound { .. } => "play-sound",
4540 QuestEffect::DamagePlayers { .. } => "damage-players",
4541 QuestEffect::SetCheckpoint { .. } => "set-checkpoint",
4542 QuestEffect::Bonfire { .. } => "bonfire",
4543 QuestEffect::BeginStealth { .. } => "begin-stealth",
4544 QuestEffect::EndStealth => "end-stealth",
4545 QuestEffect::SpawnNpc { .. } => "spawn-npc",
4546 QuestEffect::SpawnActor { .. } => "spawn-actor",
4547 QuestEffect::DespawnActor { .. } => "despawn-actor",
4548 QuestEffect::MoveActor { .. } => "move-actor",
4549 QuestEffect::UnleashActor { .. } => "unleash-actor",
4550 QuestEffect::Sequence { .. } => "sequence",
4551 QuestEffect::Volley { .. } => "volley",
4552 QuestEffect::Collapse { .. } => "collapse",
4553 }
4554 }
4555
4556 /// The gate anchor if this is `open-gate`.
4557 pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
4558 match self {
4559 QuestEffect::OpenGate { anchor, .. } => Some(anchor),
4560 _ => None,
4561 }
4562 }
4563
4564 /// The gate anchor if this is `close-gate` (DSL v0.6).
4565 pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
4566 match self {
4567 QuestEffect::CloseGate { anchor, .. } => Some(anchor),
4568 _ => None,
4569 }
4570 }
4571
4572 /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
4573 /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
4574 /// the compiler's canonical English seal line.
4575 pub fn close_gate_sealed_hint(&self) -> Option<&str> {
4576 match self {
4577 QuestEffect::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
4578 _ => None,
4579 }
4580 }
4581
4582 /// The wave id if this is `spawn-wave` (v0.3).
4583 pub fn spawn_wave(&self) -> Option<&WaveId> {
4584 match self {
4585 QuestEffect::SpawnWave { wave, .. } => Some(wave),
4586 _ => None,
4587 }
4588 }
4589
4590 /// The flag id if this is `set-flag` (v0.3).
4591 pub fn set_flag(&self) -> Option<&FlagId> {
4592 match self {
4593 QuestEffect::SetFlag { flag, .. } => Some(flag),
4594 _ => None,
4595 }
4596 }
4597
4598 /// The item id if this is `give-item` (v0.3).
4599 pub fn give_item(&self) -> Option<&str> {
4600 match self {
4601 QuestEffect::GiveItem { item, .. } => Some(item),
4602 _ => None,
4603 }
4604 }
4605
4606 /// `true` if this is a `give-item` carrying a v0.4 display `name`.
4607 pub fn give_item_named(&self) -> bool {
4608 matches!(self, QuestEffect::GiveItem { name: Some(_), .. })
4609 }
4610
4611 /// The declared `carrier` if this is a `give-item` that states one (v0.6,
4612 /// spec-0018). `None` for any other effect **and** for a `give-item` that
4613 /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
4614 /// matters only for the pre-0.6 reserved-field gate.
4615 pub fn give_carrier(&self) -> Option<Carrier> {
4616 match self {
4617 QuestEffect::GiveItem { carrier, .. } => *carrier,
4618 _ => None,
4619 }
4620 }
4621
4622 /// Does this `give-item` hand a single copy to the acting player (v0.6,
4623 /// spec-0018)? `false` for every other effect and for the party-wide default.
4624 pub fn gives_to_one(&self) -> bool {
4625 matches!(self.give_carrier(), Some(Carrier::One))
4626 }
4627
4628 /// The `set-block` block id if this is a v0.4 `set-block` effect.
4629 pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
4630 match self {
4631 QuestEffect::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
4632 _ => None,
4633 }
4634 }
4635
4636 /// The NPC id if this is a v0.4 `despawn-npc` effect.
4637 pub fn despawn_npc(&self) -> Option<&NpcId> {
4638 match self {
4639 QuestEffect::DespawnNpc { npc, .. } => Some(npc),
4640 _ => None,
4641 }
4642 }
4643
4644 /// `(npc, to_anchor)` if this is a v0.4 `move-npc` effect.
4645 pub fn move_npc(&self) -> Option<(&NpcId, &AnchorId)> {
4646 match self {
4647 QuestEffect::MoveNpc { npc, to_anchor, .. } => Some((npc, to_anchor)),
4648 _ => None,
4649 }
4650 }
4651
4652 /// The v0.3 effect name if this effect is one introduced in DSL v0.3
4653 /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
4654 /// and are reserved (`DW0141`) in v0.2 campaigns.
4655 pub fn v03_effect(&self) -> Option<&'static str> {
4656 match self {
4657 QuestEffect::GiveItem { .. } => Some("give-item"),
4658 QuestEffect::SetFlag { .. } => Some("set-flag"),
4659 QuestEffect::SpawnWave { .. } => Some("spawn-wave"),
4660 QuestEffect::OpenGate { .. }
4661 | QuestEffect::CloseGate { .. }
4662 | QuestEffect::CampaignComplete { .. } => None,
4663 // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
4664 // are not v0.3 verbs.
4665 QuestEffect::Narrate { .. }
4666 | QuestEffect::SetBlock { .. }
4667 | QuestEffect::DespawnNpc { .. }
4668 | QuestEffect::MoveNpc { .. }
4669 | QuestEffect::Cutscene { .. }
4670 | QuestEffect::SetTime { .. }
4671 | QuestEffect::SetWeather { .. }
4672 | QuestEffect::PlaySound { .. }
4673 | QuestEffect::DamagePlayers { .. }
4674 | QuestEffect::SetCheckpoint { .. }
4675 | QuestEffect::Bonfire { .. }
4676 | QuestEffect::BeginStealth { .. }
4677 | QuestEffect::EndStealth
4678 | QuestEffect::SpawnActor { .. }
4679 | QuestEffect::DespawnActor { .. }
4680 | QuestEffect::MoveActor { .. }
4681 | QuestEffect::UnleashActor { .. }
4682 | QuestEffect::SpawnNpc { .. }
4683 | QuestEffect::Sequence { .. }
4684 // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
4685 | QuestEffect::Volley { .. }
4686 | QuestEffect::Collapse { .. } => None,
4687 }
4688 }
4689
4690 /// The v0.4 effect name if this effect is one introduced in DSL v0.4
4691 /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
4692 /// in v0.4 campaigns and are reserved (`DW0141`) earlier.
4693 pub fn v04_effect(&self) -> Option<&'static str> {
4694 match self {
4695 QuestEffect::Narrate { .. } => Some("narrate"),
4696 QuestEffect::SetBlock { .. } => Some("set-block"),
4697 QuestEffect::DespawnNpc { .. } => Some("despawn-npc"),
4698 QuestEffect::MoveNpc { .. } => Some("move-npc"),
4699 QuestEffect::Cutscene { .. } => Some("cutscene"),
4700 _ => None,
4701 }
4702 }
4703
4704 /// The v0.5 effect name if this effect is one introduced in DSL v0.5
4705 /// (`set-time`/`set-weather`, spec-0010). These validate in v0.5 campaigns and
4706 /// are reserved (`DW0141`) earlier.
4707 pub fn v05_effect(&self) -> Option<&'static str> {
4708 match self {
4709 QuestEffect::SetTime { .. } => Some("set-time"),
4710 QuestEffect::SetWeather { .. } => Some("set-weather"),
4711 _ => None,
4712 }
4713 }
4714
4715 /// The v0.6 effect name if this effect is one introduced in DSL v0.6
4716 /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
4717 /// `play-sound`, spec-0014; the scripted-actor staging verbs
4718 /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
4719 /// spec-0014). These validate in v0.6 campaigns and are reserved (`DW0141`)
4720 /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
4721 /// — see [`QuestEffect::narrate_art`] — not a new effect.)
4722 pub fn v06_effect(&self) -> Option<&'static str> {
4723 match self {
4724 QuestEffect::CloseGate { .. } => Some("close-gate"),
4725 QuestEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
4726 QuestEffect::Bonfire { .. } => Some("bonfire"),
4727 QuestEffect::BeginStealth { .. } => Some("begin-stealth"),
4728 QuestEffect::EndStealth => Some("end-stealth"),
4729 QuestEffect::PlaySound { .. } => Some("play-sound"),
4730 QuestEffect::DamagePlayers { .. } => Some("damage-players"),
4731 QuestEffect::SpawnActor { .. } => Some("spawn-actor"),
4732 QuestEffect::DespawnActor { .. } => Some("despawn-actor"),
4733 QuestEffect::MoveActor { .. } => Some("move-actor"),
4734 QuestEffect::UnleashActor { .. } => Some("unleash-actor"),
4735 QuestEffect::Sequence { .. } => Some("sequence"),
4736 QuestEffect::SpawnNpc { .. } => Some("spawn-npc"),
4737 // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
4738 QuestEffect::Volley { .. } => Some("volley"),
4739 QuestEffect::Collapse { .. } => Some("collapse"),
4740 _ => None,
4741 }
4742 }
4743
4744 /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
4745 /// `volley` (spec-0022), with the documented defaults already applied.
4746 pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
4747 match self {
4748 QuestEffect::Volley {
4749 projectile,
4750 from_anchor,
4751 kill_zone,
4752 salvos,
4753 interval,
4754 ..
4755 } => Some((
4756 projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
4757 from_anchor,
4758 kill_zone,
4759 salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
4760 interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
4761 )),
4762 _ => None,
4763 }
4764 }
4765
4766 /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
4767 /// (spec-0022), with the documented default already applied.
4768 pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
4769 match self {
4770 QuestEffect::Collapse {
4771 region_anchor,
4772 falling_block,
4773 then_floor,
4774 ..
4775 } => Some((
4776 region_anchor,
4777 falling_block
4778 .as_deref()
4779 .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
4780 then_floor.as_deref(),
4781 )),
4782 _ => None,
4783 }
4784 }
4785
4786 /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
4787 /// [`QuestEffect::despawn_npc`]).
4788 pub fn spawn_npc(&self) -> Option<&NpcId> {
4789 match self {
4790 QuestEffect::SpawnNpc { npc, .. } => Some(npc),
4791 _ => None,
4792 }
4793 }
4794
4795 /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
4796 pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
4797 match self {
4798 QuestEffect::SetCheckpoint { anchor, on_respawn } => {
4799 Some((anchor, on_respawn.as_slice()))
4800 }
4801 _ => None,
4802 }
4803 }
4804
4805 /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
4806 pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
4807 match self {
4808 QuestEffect::Bonfire {
4809 anchor, on_rest, ..
4810 } => Some((anchor, on_rest.as_slice())),
4811 _ => None,
4812 }
4813 }
4814
4815 /// The bonfire's authored rest-dialog strings, each `None` when unauthored
4816 /// (the compiler then bakes its canonical English). `None` for every other
4817 /// effect. spec-0016 §1, owner ruling 2026-08-03.
4818 pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
4819 match self {
4820 QuestEffect::Bonfire {
4821 prompt,
4822 rest_label,
4823 save_label,
4824 ..
4825 } => Some(BonfireLabels {
4826 prompt: prompt.as_deref(),
4827 rest_label: rest_label.as_deref(),
4828 save_label: save_label.as_deref(),
4829 }),
4830 _ => None,
4831 }
4832 }
4833
4834 /// The `within` filter zone if this is a v0.6 `damage-players` effect that
4835 /// declares one (the `in` spatial scope). `None` for an unscoped
4836 /// `damage-players` and for every other effect.
4837 pub fn damage_within(&self) -> Option<&StealthZone> {
4838 match self {
4839 QuestEffect::DamagePlayers { within, .. } => within.as_ref(),
4840 _ => None,
4841 }
4842 }
4843
4844 /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
4845 pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
4846 match self {
4847 QuestEffect::BeginStealth {
4848 zones,
4849 on_caught,
4850 grace_ticks,
4851 } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
4852 _ => None,
4853 }
4854 }
4855
4856 /// The target time if this is a v0.5 `set-time` effect.
4857 pub fn set_time(&self) -> Option<WorldTime> {
4858 match self {
4859 QuestEffect::SetTime { time, .. } => Some(*time),
4860 _ => None,
4861 }
4862 }
4863
4864 /// The target weather if this is a v0.5 `set-weather` effect.
4865 pub fn set_weather(&self) -> Option<WorldWeather> {
4866 match self {
4867 QuestEffect::SetWeather { weather, .. } => Some(*weather),
4868 _ => None,
4869 }
4870 }
4871
4872 /// The effect lists nested one level inside this effect (DSL v0.6): a
4873 /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
4874 /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
4875 /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
4876 ///
4877 /// This is the **single authority** on effect nesting. Every deep traversal —
4878 /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
4879 /// string inventory, and emission — walks the tree through it (see
4880 /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
4881 /// once and no walker can silently miss a list (the class of bug where a
4882 /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
4883 pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
4884 match self {
4885 QuestEffect::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
4886 QuestEffect::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
4887 QuestEffect::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
4888 QuestEffect::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
4889 QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
4890 vec![on_arrive.as_slice()]
4891 }
4892 _ => Vec::new(),
4893 }
4894 }
4895
4896 /// Visit `self` and every transitively nested effect (depth-first, pre-order),
4897 /// descending through [`Self::nested_effect_lists`].
4898 pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
4899 f(self);
4900 for list in self.nested_effect_lists() {
4901 for e in list {
4902 e.visit_deep(f);
4903 }
4904 }
4905 }
4906
4907 /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
4908 /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
4909 /// exposed mutably so the localization pass can rewrite nested player-visible
4910 /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
4911 /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
4912 /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
4913 /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
4914 /// derived segments make every derived key deterministic and stable across
4915 /// builds (ADR-0006 byte-identity).
4916 pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
4917 match self {
4918 QuestEffect::Sequence { steps } => steps
4919 .iter_mut()
4920 .enumerate()
4921 .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
4922 .collect(),
4923 QuestEffect::SetCheckpoint { on_respawn, .. } => {
4924 vec![("respawn".to_string(), on_respawn.as_mut_slice())]
4925 }
4926 QuestEffect::Bonfire { on_rest, .. } => {
4927 vec![("rest".to_string(), on_rest.as_mut_slice())]
4928 }
4929 QuestEffect::BeginStealth { on_caught, .. } => {
4930 vec![("caught".to_string(), on_caught.as_mut_slice())]
4931 }
4932 QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
4933 vec![("arrive".to_string(), on_arrive.as_mut_slice())]
4934 }
4935 _ => Vec::new(),
4936 }
4937 }
4938
4939 /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
4940 /// exposes the **JSON-pointer path segment** for each nested list, so a deep
4941 /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
4942 /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
4943 /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
4944 /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
4945 /// (same lists, same order): the l10n key segments match
4946 /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
4947 /// `arrive`), and the path segments name the real fields
4948 /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
4949 pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
4950 match self {
4951 QuestEffect::Sequence { steps } => steps
4952 .iter()
4953 .enumerate()
4954 .map(|(s, st)| {
4955 (
4956 format!("steps/{s}/effects"),
4957 format!("seq.{s}"),
4958 st.effects.as_slice(),
4959 )
4960 })
4961 .collect(),
4962 QuestEffect::SetCheckpoint { on_respawn, .. } => vec![(
4963 "on_respawn".to_string(),
4964 "respawn".to_string(),
4965 on_respawn.as_slice(),
4966 )],
4967 QuestEffect::Bonfire { on_rest, .. } => vec![(
4968 "on_rest".to_string(),
4969 "rest".to_string(),
4970 on_rest.as_slice(),
4971 )],
4972 QuestEffect::BeginStealth { on_caught, .. } => vec![(
4973 "on_caught".to_string(),
4974 "caught".to_string(),
4975 on_caught.as_slice(),
4976 )],
4977 QuestEffect::MoveActor { on_arrive, .. } | QuestEffect::MoveNpc { on_arrive, .. } => {
4978 vec![(
4979 "on_arrive".to_string(),
4980 "arrive".to_string(),
4981 on_arrive.as_slice(),
4982 )]
4983 }
4984 _ => Vec::new(),
4985 }
4986 }
4987
4988 /// Every world anchor this effect names **at this node** — the single
4989 /// authority on the anchor-bearing effect surface, the referential sibling of
4990 /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
4991 /// where the suffix is appended to the effect's own JSON pointer
4992 /// (`anchor`, `to_anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
4993 /// `shots/<i>/path/<j>/anchor`, …).
4994 ///
4995 /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
4996 /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
4997 /// reference scan, the compiler's build-time resolution seal (`DW0355`) —
4998 /// goes through here, so a new anchor-bearing variant (or a new anchor field
4999 /// on an existing one) is picked up by all of them at once. That closes the
5000 /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
5001 /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
5002 /// found no match) while every shallow validator looked straight past it.
5003 pub fn anchor_refs(&self) -> Vec<(String, &AnchorId)> {
5004 /// `(suffix, anchor)` for a shot's own anchor-bearing fields, under `base`.
5005 fn shot_refs<'a>(base: &str, shot: &'a CameraShot) -> Vec<(String, &'a AnchorId)> {
5006 let mut out: Vec<(String, &AnchorId)> = shot
5007 .path
5008 .iter()
5009 .enumerate()
5010 .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor))
5011 .collect();
5012 if let Some(t) = &shot.look_at {
5013 out.push((format!("{base}look_at/anchor"), &t.anchor));
5014 }
5015 for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
5016 if let Some(CameraSubject::Anchor(s)) = subject {
5017 out.push((format!("{base}{field}/anchor"), &s.anchor));
5018 }
5019 }
5020 out
5021 }
5022 match self {
5023 QuestEffect::OpenGate { anchor, .. }
5024 | QuestEffect::CloseGate { anchor, .. }
5025 | QuestEffect::SetBlock { anchor, .. }
5026 | QuestEffect::SetCheckpoint { anchor, .. }
5027 | QuestEffect::Bonfire { anchor, .. } => vec![("anchor".to_string(), anchor)],
5028 QuestEffect::MoveNpc { to_anchor, .. } | QuestEffect::MoveActor { to_anchor, .. } => {
5029 vec![("to_anchor".to_string(), to_anchor)]
5030 }
5031 QuestEffect::DamagePlayers {
5032 within: Some(zone), ..
5033 } => vec![("in/anchor".to_string(), &zone.anchor)],
5034 QuestEffect::BeginStealth { zones, .. } => zones
5035 .iter()
5036 .enumerate()
5037 .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor))
5038 .collect(),
5039 QuestEffect::PlaySound {
5040 at: Some(SoundAt::Anchor { anchor }),
5041 ..
5042 } => vec![("at/anchor".to_string(), anchor)],
5043 // spec-0022 trap-payload verbs. Both anchors of a `volley` are
5044 // load-bearing for the coverage proof, so both register here — a
5045 // typo'd `kill_zone` must be a dangling-reference error, never a
5046 // silently zero-cell (and therefore vacuously "covered") volley.
5047 QuestEffect::Volley {
5048 from_anchor,
5049 kill_zone,
5050 ..
5051 } => vec![
5052 ("from_anchor".to_string(), from_anchor),
5053 ("kill_zone/anchor".to_string(), &kill_zone.anchor),
5054 ],
5055 QuestEffect::Collapse { region_anchor, .. } => {
5056 vec![("region_anchor/anchor".to_string(), ®ion_anchor.anchor)]
5057 }
5058 // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
5059 // multi-shot list, or the v0.4 single-shot fields flattened at the
5060 // effect's own level.
5061 QuestEffect::Cutscene {
5062 shots,
5063 path,
5064 look_at,
5065 ..
5066 } => {
5067 let mut out: Vec<(String, &AnchorId)> = shots
5068 .iter()
5069 .enumerate()
5070 .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
5071 .collect();
5072 out.extend(
5073 path.iter()
5074 .enumerate()
5075 .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor)),
5076 );
5077 if let Some(t) = look_at {
5078 out.push(("look_at/anchor".to_string(), &t.anchor));
5079 }
5080 out
5081 }
5082 _ => Vec::new(),
5083 }
5084 }
5085
5086 /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
5087 /// the v0.6 `look_at` field (reserved `DW0141` under a pre-0.6 campaign).
5088 pub fn cutscene_look_at(&self) -> Option<&CameraTarget> {
5089 match self {
5090 QuestEffect::Cutscene { look_at, .. } => look_at.as_ref(),
5091 _ => None,
5092 }
5093 }
5094
5095 /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
5096 /// (reserved `DW0141` under a pre-0.6 campaign).
5097 pub fn cutscene_multi_shot(&self) -> bool {
5098 matches!(self, QuestEffect::Cutscene { shots, .. } if !shots.is_empty())
5099 }
5100
5101 /// The normalized shot list of a `cutscene`, whichever spelling was used: the
5102 /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
5103 /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
5104 /// whose shape is invalid (`DW0199` reports that).
5105 pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
5106 match self {
5107 QuestEffect::Cutscene {
5108 shots,
5109 path,
5110 seconds,
5111 look_at,
5112 ..
5113 } => {
5114 if !shots.is_empty() {
5115 return Some(shots.clone());
5116 }
5117 match seconds {
5118 Some(secs) => Some(vec![CameraShot {
5119 path: path.clone(),
5120 seconds: Some(*secs),
5121 look_at: look_at.clone(),
5122 shot_style: None,
5123 subject: None,
5124 subject_b: None,
5125 dist: None,
5126 degrees: None,
5127 bearing: None,
5128 }]),
5129 None => Some(Vec::new()),
5130 }
5131 }
5132 _ => None,
5133 }
5134 }
5135
5136 /// `true` if this is a `narrate` carrying the v0.6 `art` style (reserved
5137 /// `DW0141` under a pre-0.6 campaign; glyph-checked `DW0328`).
5138 pub fn narrate_art(&self) -> bool {
5139 matches!(
5140 self,
5141 QuestEffect::Narrate {
5142 style: Some(NarrateStyle::Art),
5143 ..
5144 }
5145 )
5146 }
5147
5148 /// The `narrate` line's text if this is a `narrate` with the `art` style.
5149 pub fn narrate_art_text(&self) -> Option<&str> {
5150 match self {
5151 QuestEffect::Narrate {
5152 text,
5153 style: Some(NarrateStyle::Art),
5154 ..
5155 } => Some(text.as_str()),
5156 _ => None,
5157 }
5158 }
5159
5160 /// The `narrate` line's style and text if this is a `narrate` rendered **on
5161 /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
5162 /// styles vanilla draws centred and unwrapped, so their rendered width is
5163 /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
5164 /// is exempt.
5165 pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
5166 match self {
5167 QuestEffect::Narrate {
5168 text,
5169 style: Some(s),
5170 ..
5171 } if matches!(
5172 s,
5173 NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
5174 ) =>
5175 {
5176 Some((*s, text.as_str()))
5177 }
5178 _ => None,
5179 }
5180 }
5181
5182 /// Every vanilla sound-event id this effect references, for registry
5183 /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
5184 /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
5185 /// within the effect (e.g. `sound`).
5186 pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
5187 match self {
5188 QuestEffect::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
5189 QuestEffect::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
5190 _ => Vec::new(),
5191 }
5192 }
5193
5194 /// The deferred `play-sound` `at: actor` id, if this effect is a `play-sound`
5195 /// targeting an actor (rejected `DW0335` until the actors surface lands).
5196 pub fn play_sound_actor(&self) -> Option<&str> {
5197 match self {
5198 QuestEffect::PlaySound {
5199 at: Some(SoundAt::Actor { actor }),
5200 ..
5201 } => Some(actor.as_str()),
5202 _ => None,
5203 }
5204 }
5205
5206 /// The per-effect flag gate (DSL v0.6, task #55): flags that must ALL be set
5207 /// (per player) for this effect to fire. Empty for an ungated effect and for
5208 /// the verbs that are not per-effect gatable — terminal `campaign-complete`
5209 /// and the party/session-global `set-checkpoint` / `begin-stealth` /
5210 /// `end-stealth`. Emission wraps a gated effect's commands in a per-player
5211 /// `execute if score @s dw.f_<flag> matches 1` guard; a pre-0.6 campaign that
5212 /// gates any effect is rejected (`DW0141`).
5213 pub fn requires_flags(&self) -> &[FlagId] {
5214 match self {
5215 QuestEffect::OpenGate { requires_flags, .. }
5216 | QuestEffect::CloseGate { requires_flags, .. }
5217 | QuestEffect::GiveItem { requires_flags, .. }
5218 | QuestEffect::SetFlag { requires_flags, .. }
5219 | QuestEffect::SpawnWave { requires_flags, .. }
5220 | QuestEffect::Narrate { requires_flags, .. }
5221 | QuestEffect::SetBlock { requires_flags, .. }
5222 | QuestEffect::DespawnNpc { requires_flags, .. }
5223 | QuestEffect::MoveNpc { requires_flags, .. }
5224 | QuestEffect::Cutscene { requires_flags, .. }
5225 | QuestEffect::SetTime { requires_flags, .. }
5226 | QuestEffect::SetWeather { requires_flags, .. }
5227 | QuestEffect::PlaySound { requires_flags, .. }
5228 | QuestEffect::DamagePlayers { requires_flags, .. }
5229 | QuestEffect::Volley { requires_flags, .. }
5230 | QuestEffect::Collapse { requires_flags, .. } => requires_flags,
5231 // Terminal / party- or session-global verbs are not per-effect
5232 // gatable: `campaign-complete` is terminal; `set-checkpoint`
5233 // (`spawnpoint @a`) / `begin-stealth` / `end-stealth` are party-wide
5234 // session state; and the actor staging verbs (`spawn-actor` /
5235 // `despawn-actor` / `move-actor` / `unleash-actor` / `sequence`) and
5236 // `spawn-npc` are world-global staging — none are per-player `@s`
5237 // effects. Gate these at the objective / dialogue-option level instead.
5238 QuestEffect::CampaignComplete { .. }
5239 | QuestEffect::SpawnNpc { .. }
5240 | QuestEffect::SetCheckpoint { .. }
5241 | QuestEffect::Bonfire { .. }
5242 | QuestEffect::BeginStealth { .. }
5243 | QuestEffect::EndStealth
5244 | QuestEffect::SpawnActor { .. }
5245 | QuestEffect::DespawnActor { .. }
5246 | QuestEffect::MoveActor { .. }
5247 | QuestEffect::UnleashActor { .. }
5248 | QuestEffect::Sequence { .. } => &[],
5249 }
5250 }
5251
5252 /// The per-effect **negative** flag gate (DSL v0.6): flags whose being set
5253 /// (per player) **suppresses** this effect — the dual of
5254 /// [`QuestEffect::requires_flags`], accepted on exactly the same verbs.
5255 /// Emission wraps a gated effect's commands in a per-player
5256 /// `execute unless score @s dw.f_<flag> matches 1` guard, so an unset score
5257 /// counts as "not set" (flag scores are never pre-initialized). Empty for an
5258 /// ungated effect and for the verbs that are not per-effect gatable (see
5259 /// `requires_flags`); a pre-0.6 campaign that uses it is rejected (`DW0141`).
5260 pub fn forbids_flags(&self) -> &[FlagId] {
5261 match self {
5262 QuestEffect::OpenGate { forbids_flags, .. }
5263 | QuestEffect::CloseGate { forbids_flags, .. }
5264 | QuestEffect::GiveItem { forbids_flags, .. }
5265 | QuestEffect::SetFlag { forbids_flags, .. }
5266 | QuestEffect::SpawnWave { forbids_flags, .. }
5267 | QuestEffect::Narrate { forbids_flags, .. }
5268 | QuestEffect::SetBlock { forbids_flags, .. }
5269 | QuestEffect::DespawnNpc { forbids_flags, .. }
5270 | QuestEffect::MoveNpc { forbids_flags, .. }
5271 | QuestEffect::Cutscene { forbids_flags, .. }
5272 | QuestEffect::SetTime { forbids_flags, .. }
5273 | QuestEffect::SetWeather { forbids_flags, .. }
5274 | QuestEffect::PlaySound { forbids_flags, .. }
5275 | QuestEffect::DamagePlayers { forbids_flags, .. }
5276 | QuestEffect::Volley { forbids_flags, .. }
5277 | QuestEffect::Collapse { forbids_flags, .. } => forbids_flags,
5278 QuestEffect::CampaignComplete { .. }
5279 | QuestEffect::SpawnNpc { .. }
5280 | QuestEffect::SetCheckpoint { .. }
5281 | QuestEffect::Bonfire { .. }
5282 | QuestEffect::BeginStealth { .. }
5283 | QuestEffect::EndStealth
5284 | QuestEffect::SpawnActor { .. }
5285 | QuestEffect::DespawnActor { .. }
5286 | QuestEffect::MoveActor { .. }
5287 | QuestEffect::UnleashActor { .. }
5288 | QuestEffect::Sequence { .. } => &[],
5289 }
5290 }
5291
5292 /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
5293 /// parity with `move-actor`). `None` for a bare `move-npc` and every other
5294 /// effect — the v0.6-reserved gate (`DW0141`) keys off `Some`.
5295 pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
5296 match self {
5297 QuestEffect::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => {
5298 Some(on_arrive.as_slice())
5299 }
5300 _ => None,
5301 }
5302 }
5303
5304 /// The actor id this effect targets, if it is one of the actor staging effects
5305 /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
5306 /// no single actor (its nested effects each carry their own).
5307 pub fn actor_ref(&self) -> Option<&ActorId> {
5308 match self {
5309 QuestEffect::SpawnActor { actor, .. }
5310 | QuestEffect::DespawnActor { actor, .. }
5311 | QuestEffect::MoveActor { actor, .. }
5312 | QuestEffect::UnleashActor { actor, .. } => Some(actor),
5313 _ => None,
5314 }
5315 }
5316}
5317
5318// ---------------------------------------------------------------------------
5319// Stage 7 — world-edits (the map editor edit script, DSL v0.6, spec-0017)
5320// ---------------------------------------------------------------------------
5321
5322/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
5323///
5324/// The artifact of record for L3 world detailing: an ordered list of edit
5325/// batches the compiler replays deterministically **after** world assembly.
5326/// The world files are never truth — same DSL + same edits + same seed →
5327/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
5328/// builds byte-identically to one from before this stage existed.
5329#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5330#[serde(deny_unknown_fields)]
5331pub struct WorldEditsContent {
5332 /// Ordered edit batches, replayed in order. After every batch the post-edit
5333 /// invariants re-prove (walkability, sealing + relight, boundary safety),
5334 /// so each batch is a valid, snapshot-reviewable world state.
5335 pub batches: Vec<EditBatch>,
5336}
5337
5338/// One edit batch: an ordered group of edit verbs applied to a single area,
5339/// checked and snapshot-rendered as a unit.
5340#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5341#[serde(deny_unknown_fields)]
5342pub struct EditBatch {
5343 /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
5344 /// snapshot name and seed-stream label — renaming a batch deliberately
5345 /// reseeds its noise.
5346 pub id: EditBatchId,
5347 /// The stage-1 area this batch edits. Frames and regions resolve against
5348 /// this area's placed pieces and anchors.
5349 pub area: AreaId,
5350 /// Authoring context (why this batch exists). Machine-ignored; **excluded**
5351 /// from l10n like `theme`/`premise`.
5352 #[serde(default, skip_serializing_if = "Option::is_none")]
5353 pub note: Option<String>,
5354 /// Ordered edit verbs. `select` defines named regions; later verbs in the
5355 /// same batch refer back to them (strictly backward, like every DSL ref).
5356 pub edits: Vec<WorldEdit>,
5357}
5358
5359/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
5360/// seeded verb derives its noise stream from the campaign seed + its script
5361/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
5362#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5363#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
5364pub enum WorldEdit {
5365 /// Define a named region (`region/<kebab>`) for later verbs in this batch.
5366 Select {
5367 /// The region name being defined (unique within the batch).
5368 name: RegionId,
5369 /// The region's shape (box, surface band, palette match, or a
5370 /// composition of earlier regions).
5371 shape: RegionShape,
5372 },
5373 /// Fill every cell of a region from a seeded palette recipe (value-noise
5374 /// keyed, so picks cluster into patches — never a uniform fill).
5375 Fill {
5376 /// The region (an earlier `select` in this batch) to fill.
5377 region: RegionId,
5378 /// The palette recipe to fill with.
5379 recipe: PaletteRecipe,
5380 },
5381 /// Like `fill`, but only rewrites cells whose current block matches one of
5382 /// `matching` (base ids; blockstate suffixes are ignored when matching).
5383 Replace {
5384 /// The region (an earlier `select` in this batch) to edit.
5385 region: RegionId,
5386 /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
5387 matching: Vec<String>,
5388 /// The palette recipe to rewrite them with.
5389 recipe: PaletteRecipe,
5390 },
5391 /// Clear a region to air. Sealing-aware: the carved region re-enters the
5392 /// sealing + relight passes and every walkability invariant re-proves.
5393 Carve {
5394 /// The region (an earlier `select` in this batch) to clear.
5395 region: RegionId,
5396 },
5397 /// Reshape terrain surface within a region (raise / lower / smooth).
5398 Morph {
5399 /// The region (an earlier `select` in this batch) whose columns to
5400 /// reshape. The region defines the footprint and where each column's
5401 /// surface is read (the highest occupied cell in the region's y-range);
5402 /// `raise`/`smooth` may add cells above the region's top — reshaping
5403 /// upward is the point — while removal only touches region cells.
5404 region: RegionId,
5405 /// The surface operation.
5406 op: MorphOp,
5407 },
5408 /// Seeded dressing scatter (spec-0017 PR 2): drop weighted single-block
5409 /// dressing (flora, rocks, props) onto standable cells of a region —
5410 /// air cells with an occupied cell directly below — honoring keep-clear
5411 /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
5412 /// speckle, not the fill verbs' clustered patches), deterministic from the
5413 /// campaign seed + script position.
5414 Scatter {
5415 /// The region (an earlier `select` in this batch) to dress.
5416 region: RegionId,
5417 /// Weighted dressing blocks (blockstate suffixes allowed).
5418 items: Vec<PaletteBlock>,
5419 /// Per-candidate placement probability in `(0, 1]`.
5420 density: f64,
5421 /// Keep-clear envelopes: earlier regions whose cells (and columns —
5422 /// matched by `(x, z)`) never receive dressing.
5423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5424 avoid: Vec<RegionId>,
5425 /// Optional minimum spacing: when set, candidates are taken in
5426 /// descending noise order and one is rejected while another accepted
5427 /// candidate is closer than this on **both** horizontal axes (the
5428 /// generators' spread rule).
5429 #[serde(default, skip_serializing_if = "Option::is_none")]
5430 spacing: Option<u32>,
5431 /// Optional cap on how many items are placed (highest-noise first).
5432 #[serde(default, skip_serializing_if = "Option::is_none")]
5433 limit: Option<u32>,
5434 },
5435 /// Structural flora (spec-0017 PR 2): plant hand-shaped trees via the
5436 /// lean-or-grow canopy rules (#121) — a canopy that would reach a
5437 /// keep-clear (`avoid`) column first leans one block away from it; if that
5438 /// still covers the corridor the tree grows tall instead, arching its
5439 /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
5440 Plant {
5441 /// The region (an earlier `select` in this batch) to plant in. Trunk
5442 /// cells are standable region cells (air over an occupied cell).
5443 region: RegionId,
5444 /// The tree species (canopy shape rules are per-species).
5445 tree: TreeKind,
5446 /// How many trees to plant (≥ 1; highest-noise candidates first).
5447 count: u32,
5448 /// Keep-clear envelopes: trunks never stand in these columns and
5449 /// canopies lean/grow to clear them.
5450 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5451 avoid: Vec<RegionId>,
5452 /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
5453 #[serde(default, skip_serializing_if = "Option::is_none")]
5454 spacing: Option<u32>,
5455 },
5456 /// Stamp a prefab fragment (spec-0017 PR 2): copy a library prefab's
5457 /// non-air cells into the world at a frame-resolved position. The fragment
5458 /// is a first-class library prefab — its provenance/license metadata is
5459 /// recorded and validated exactly like any placed prefab (ADR-0013);
5460 /// nothing outside the library can be stamped.
5461 Fragment {
5462 /// The library prefab to stamp.
5463 prefab: PrefabId,
5464 /// The frame `at` resolves in.
5465 frame: EditFrame,
5466 /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
5467 at: [i32; 3],
5468 /// Placement rotation (default `none`), the same quarter-turn set as
5469 /// `/place template`.
5470 #[serde(default, skip_serializing_if = "Option::is_none")]
5471 rotation: Option<FragmentRotation>,
5472 },
5473 /// Explicit region relight (spec-0017 PR 2, spec-0010 machinery): run the
5474 /// deterministic fixture-placement pass over ONE region and bake the
5475 /// resulting fixtures into the edit script's writes — authorial control of
5476 /// where fixtures land, instead of the whole-area pass's greedy siting.
5477 /// (The whole-area relight still re-proves after every batch either way.)
5478 Relight {
5479 /// The region (an earlier `select` in this batch) to relight: its
5480 /// reachable walkable cells are brought to `min_light`.
5481 region: RegionId,
5482 /// Fixture override; default = the area's declared `lighting.fixture`.
5483 #[serde(default, skip_serializing_if = "Option::is_none")]
5484 fixture: Option<Fixture>,
5485 /// Target light override (1..=14); default = the area's declared
5486 /// `lighting.min_light`. Required (with `fixture`) when the area
5487 /// declares no `lighting`.
5488 #[serde(default, skip_serializing_if = "Option::is_none")]
5489 min_light: Option<u8>,
5490 },
5491 /// L2 massing (spec-0017 PR 3): replace a placed piece with another
5492 /// library prefab that re-mates every currently-mated socket at its exact
5493 /// world pose (any rotation; overlap-checked). Applied at **plan** time —
5494 /// the whole downstream ladder (anchors, gate reachability, assembly,
5495 /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
5496 /// verbs live in massing-only batches, ordered before every detailing
5497 /// batch.
5498 SwapPiece {
5499 /// The piece's placement index in its area's solved layout (0-based,
5500 /// entry first).
5501 piece: u32,
5502 /// The prefab the indexed piece must currently be (drift guard).
5503 prefab: PrefabId,
5504 /// The library prefab to swap in.
5505 with: PrefabId,
5506 },
5507 /// L2 massing (spec-0017 PR 3): attach a new piece at a specific **open**
5508 /// (unmated) socket of an existing piece — the targeted form of the
5509 /// solver's frontier attach. The socket opens (its seal becomes a
5510 /// passage); the new piece's other sockets seal.
5511 InsertPiece {
5512 /// The host piece's placement index.
5513 at_piece: u32,
5514 /// The prefab the host piece must currently be (drift guard).
5515 prefab: PrefabId,
5516 /// The host's connector index (prefab metadata `connectors` order).
5517 socket: u32,
5518 /// The library prefab to attach.
5519 insert: PrefabId,
5520 },
5521 /// L2 massing (spec-0017 PR 3): remove a **leaf** piece (exactly one
5522 /// mated socket; never the entry piece). The neighbour's socket unmates
5523 /// and re-seals. Removal shifts later placement indices — order removals
5524 /// before other index-referencing massing verbs.
5525 RemovePiece {
5526 /// The piece's placement index.
5527 piece: u32,
5528 /// The prefab the indexed piece must currently be (drift guard).
5529 prefab: PrefabId,
5530 },
5531 /// L2 massing (spec-0017 PR 3): override one socket's seal — `open`
5532 /// clears the opening to a passage, `sealed` walls it up — independent of
5533 /// its mated state (sealing a mated doorway makes a wall between joined
5534 /// pieces; opening an unmated exterior socket exposes the outside, which
5535 /// the boundary-safety proof then judges).
5536 RewireSocket {
5537 /// The piece's placement index.
5538 piece: u32,
5539 /// The prefab the indexed piece must currently be (drift guard).
5540 prefab: PrefabId,
5541 /// The connector index (prefab metadata `connectors` order).
5542 socket: u32,
5543 /// The socket's new state.
5544 state: SocketState,
5545 },
5546 /// L2 massing (spec-0017 PR 3): re-pick this piece from its area pool's
5547 /// compatible members (weighted, seeded from the campaign seed + this
5548 /// verb's script position — moving the verb deliberately re-rolls). The
5549 /// current prefab is excluded, so a reseed always changes the piece or
5550 /// errors loudly.
5551 ReseedPiece {
5552 /// The piece's placement index.
5553 piece: u32,
5554 /// The prefab the indexed piece must currently be (drift guard).
5555 prefab: PrefabId,
5556 },
5557}
5558
5559/// A socket seal state for `rewire-socket` (spec-0017 PR 3).
5560#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5561#[serde(rename_all = "kebab-case")]
5562pub enum SocketState {
5563 /// The opening is cleared to a passage.
5564 Open,
5565 /// The opening is walled up.
5566 Sealed,
5567}
5568
5569/// A tree species for the `plant` verb (spec-0017 PR 2). One species per
5570/// canopy-rule implementation; the shipped rule set is the #121 oak.
5571#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5572#[serde(rename_all = "kebab-case")]
5573pub enum TreeKind {
5574 /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
5575 /// lean-or-grow corridor rules.
5576 Oak,
5577}
5578
5579/// A `fragment` stamp rotation (spec-0017 PR 2) — the `/place template`
5580/// quarter-turn set.
5581#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5582#[serde(rename_all = "kebab-case")]
5583pub enum FragmentRotation {
5584 /// No rotation.
5585 None,
5586 /// 90° clockwise.
5587 Clockwise90,
5588 /// 180°.
5589 Clockwise180,
5590 /// 90° counterclockwise.
5591 Counterclockwise90,
5592}
5593
5594/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
5595/// frame; compositions combine earlier regions of the same batch.
5596#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5597#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5598pub enum RegionShape {
5599 /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
5600 Box {
5601 /// The coordinate frame `min`/`max` resolve in.
5602 frame: EditFrame,
5603 /// Inclusive minimum corner (frame coordinates).
5604 min: [i32; 3],
5605 /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
5606 max: [i32; 3],
5607 },
5608 /// The band of cells at `from..=to` blocks relative to each column's
5609 /// terrain surface (the highest non-air cell of the column) within an
5610 /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
5611 /// surface; `from: 0, to: 0` is the surface cells themselves; negative
5612 /// offsets reach below the surface.
5613 SurfaceBand {
5614 /// The earlier region whose columns are scanned.
5615 over: RegionId,
5616 /// Inclusive band start, relative to each column's surface y.
5617 from: i32,
5618 /// Inclusive band end (≥ `from`), relative to each column's surface y.
5619 to: i32,
5620 },
5621 /// The cells of an earlier region whose current block matches one of
5622 /// `blocks` (base ids; blockstate suffixes ignored when matching).
5623 PaletteMatch {
5624 /// The earlier region to filter.
5625 within: RegionId,
5626 /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
5627 blocks: Vec<String>,
5628 },
5629 /// The union of earlier regions.
5630 Union {
5631 /// The earlier regions to unite (≥ 2).
5632 of: Vec<RegionId>,
5633 },
5634 /// The intersection of earlier regions.
5635 Intersect {
5636 /// The earlier regions to intersect (≥ 2).
5637 of: Vec<RegionId>,
5638 },
5639 /// An earlier region minus other earlier regions.
5640 Subtract {
5641 /// The earlier region to start from.
5642 base: RegionId,
5643 /// The earlier regions to remove from it (≥ 1).
5644 remove: Vec<RegionId>,
5645 },
5646}
5647
5648/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
5649/// piece-local or anchor-relative — never raw world coordinates, so an edit
5650/// script survives a layout's world placement moving.
5651#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5652#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5653pub enum EditFrame {
5654 /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
5655 /// the piece's structure origin, axes as authored (the compiler applies
5656 /// the piece's placed rotation).
5657 PieceLocal {
5658 /// The piece's placement index in the area's solved layout (0-based,
5659 /// entry piece first — the order `delvec snapshot`'s manifest lists).
5660 piece: u32,
5661 /// The prefab the indexed piece must be (a drift guard: if a re-solve
5662 /// changed the layout, the mismatch is a loud compile error, never a
5663 /// silently misplaced edit).
5664 prefab: PrefabId,
5665 },
5666 /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
5667 /// anchor cell, axes world-aligned.
5668 AnchorRelative {
5669 /// The anchor (prefab metadata, resolved by the compiler).
5670 anchor: AnchorId,
5671 },
5672}
5673
5674/// A surface operation for the `morph` verb (spec-0017).
5675#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5676#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5677pub enum MorphOp {
5678 /// Raise each column's surface by up to `by` blocks, drawing the added
5679 /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
5680 /// as natural strata, not an extruded slab).
5681 Raise {
5682 /// How many blocks to raise each column (≥ 1).
5683 by: u32,
5684 /// The palette recipe for the added cells.
5685 recipe: PaletteRecipe,
5686 },
5687 /// Lower each column's surface by up to `by` blocks (carving the topmost
5688 /// solid cells to air).
5689 Lower {
5690 /// How many blocks to lower each column (≥ 1).
5691 by: u32,
5692 },
5693 /// Relax each column's surface toward the mean of its cardinal neighbours
5694 /// (one block per pass), turning steps into slopes. Added cells draw from
5695 /// `recipe`; removed cells carve to air. Deterministic double-buffered
5696 /// passes in fixed scan order.
5697 Smooth {
5698 /// Relaxation passes (≥ 1).
5699 passes: u32,
5700 /// The palette recipe for cells a pass adds.
5701 recipe: PaletteRecipe,
5702 },
5703}
5704
5705/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
5706/// smooth value-noise sample, the island/cave generators' proven primitive —
5707/// picks cluster into strata/patches instead of per-cell speckle, and a
5708/// single-entry recipe is the degenerate (discouraged) uniform case.
5709#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5710#[serde(deny_unknown_fields)]
5711pub struct PaletteRecipe {
5712 /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
5713 pub blocks: Vec<PaletteBlock>,
5714 /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
5715 /// across). Larger = smaller patches. Must be finite and > 0.
5716 #[serde(default, skip_serializing_if = "Option::is_none")]
5717 pub scale: Option<f64>,
5718}
5719
5720/// One weighted entry of a [`PaletteRecipe`].
5721#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5722#[serde(deny_unknown_fields)]
5723pub struct PaletteBlock {
5724 /// Vanilla block id (validated against the pinned 1.21.11 registry), with
5725 /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
5726 pub block: String,
5727 /// Relative weight (finite, > 0).
5728 pub weight: f64,
5729}
5730
5731/// Where in the campaign one effect sits — the attribution every per-branch
5732/// proof and the branch chronicle need (spec-0025).
5733#[derive(Clone, Debug, PartialEq, Eq)]
5734pub enum EffectSite {
5735 /// A quest's `on_objective_complete[<objective>]` bundle.
5736 Objective {
5737 /// The owning quest.
5738 quest: String,
5739 /// The objective whose completion fires the bundle.
5740 objective: String,
5741 },
5742 /// A quest's `on_complete` bundle.
5743 QuestComplete {
5744 /// The owning quest.
5745 quest: String,
5746 },
5747 /// An environment trigger's `effects` bundle — ambient, no DAG position.
5748 Trigger {
5749 /// The trigger id.
5750 trigger: String,
5751 },
5752 /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
5753 Trap {
5754 /// The trap id.
5755 trap: String,
5756 },
5757 /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
5758 /// DAG position, and the only site that does not live in the quests stage.
5759 ///
5760 /// This variant did not exist until the effect-root sweep, and its absence was
5761 /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
5762 /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
5763 /// (`combat::actor_beats`, `validate::difficulty_checks`,
5764 /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
5765 /// even if their authors had thought of it. Widening the type is what let the
5766 /// walk widen.
5767 DialogueRespawn {
5768 /// The NPC whose dialogue tree hosts the option.
5769 npc: String,
5770 /// The node the option sits under.
5771 node: String,
5772 },
5773}
5774
5775impl EffectSite {
5776 /// The quest this site belongs to, if it has a DAG position at all.
5777 pub fn quest(&self) -> Option<&str> {
5778 match self {
5779 EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
5780 Some(quest)
5781 }
5782 EffectSite::Trigger { .. }
5783 | EffectSite::Trap { .. }
5784 | EffectSite::DialogueRespawn { .. } => None,
5785 }
5786 }
5787}
5788
5789/// Visit **every** effect the compiler can lower — at every one of the five
5790/// effect roots, top-level and transitively nested — in a fixed deterministic
5791/// order, invoking `f(json_pointer, site, effect)`.
5792///
5793/// The roots come from [`crate::effects::for_each_effect_root`], the single
5794/// enumeration; nesting is descended through the single
5795/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
5796/// enumerated here, which is the point: this walk used to hand-list four of the
5797/// five roots (it had no `EffectSite` variant for the fifth), so every proof
5798/// defined in terms of it inherited that blind spot.
5799pub fn for_each_campaign_effect<'a>(
5800 c: &'a crate::envelope::Campaign,
5801 f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
5802) {
5803 crate::effects::for_each_effect_root(c, &mut |root, list| {
5804 let site = match root.owner {
5805 crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
5806 EffectSite::Objective {
5807 quest: quest.id.as_str().to_string(),
5808 objective: objective.to_string(),
5809 }
5810 }
5811 crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
5812 quest: quest.id.as_str().to_string(),
5813 },
5814 crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
5815 trigger: t.id.as_str().to_string(),
5816 },
5817 crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
5818 trap: t.id.as_str().to_string(),
5819 },
5820 crate::effects::EffectRootOwner::DialogueRespawn => {
5821 // The npc and node are in the root's path; parse them back rather
5822 // than widening the root walk's owner for one consumer.
5823 let seg = |n: usize| -> String {
5824 root.path.split('/').nth(n).unwrap_or_default().to_string()
5825 };
5826 EffectSite::DialogueRespawn {
5827 npc: seg(3),
5828 node: seg(5),
5829 }
5830 }
5831 };
5832 for (i, eff) in list.iter().enumerate() {
5833 campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
5834 }
5835 });
5836}
5837
5838fn campaign_effect_deep<'a>(
5839 eff: &'a QuestEffect,
5840 path: &str,
5841 site: &EffectSite,
5842 f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
5843) {
5844 f(path, site, eff);
5845 for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
5846 for (j, inner) in list.iter().enumerate() {
5847 campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
5848 }
5849 }
5850}