Skip to main content

delvewright_dsl/
world.rs

1//! Stage 1 — the world: the setting's time, weather, difficulty, horizon,
2//! boundary, areas, pieces, skies and texture overrides (spec-0001).
3
4use std::collections::BTreeMap;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::serde_fields::default_true;
10use crate::{AreaId, AtmosphereId, CelestialTime, Clock, MoonPhase, PoolId, PrefabId};
11
12#[cfg(doc)]
13use crate::Verb;
14
15/// Stage 1 payload: setting, seed and the areas that make up the delve.
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
17#[serde(deny_unknown_fields)]
18pub struct WorldContent {
19    /// Player-facing delve title.
20    pub title: String,
21    /// One-line thematic description.
22    pub theme: String,
23    /// Short narrative premise.
24    pub premise: String,
25    /// The single downstream randomness source (ADR-0006).
26    pub seed: u64,
27    /// Informational pacing target in minutes (v0: not enforced).
28    pub target_minutes: u32,
29    /// The areas the delve is made of; each binds exactly one of `prefab` /
30    /// `prefab_pool`.
31    ///
32    /// **A campaign declares its placement in exactly one document, so this
33    /// list is empty on a site-plan campaign** (`DW0839`): where a
34    /// `site-plan.json` is present the plan is the placement authority, its one
35    /// place is `area/site`, and declaring `areas[]` as well gives every
36    /// question about where something is two answers. Empty is therefore a
37    /// legitimate and common value, not a campaign that forgot to place
38    /// anything.
39    pub areas: Vec<Area>,
40    /// Additional author-declared translation languages (BCP-47-style codes, e.g.
41    /// `["zh-cn"]`). English (`en`) is implicit, always canonical, and is **never**
42    /// listed here (spec-0001 i18n addendum). Absent or empty = English-only. Every
43    /// declared language must ship a fully-covering `l10n/<code>.json` sidecar
44    /// (`DW0180`/`DW0181`). Stage docs themselves stay pure English.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub languages: Vec<String>,
47    /// **The hour this delve is played at** (DSL v0.5, spec-0010; required since
48    /// spec-0061). Dimension-global; frozen by environment sealing
49    /// (`advance_time false`) so the set state persists. Affects sky attenuation
50    /// in the compiler's assembled-light model.
51    ///
52    /// **Required, and it has no default.** "This delve is played at noon" is a
53    /// design decision, and a mechanism that supplies one silently when the
54    /// author said nothing is exactly what `CLAUDE.md` forbids a primitive from
55    /// encoding — the same ruling spec-0060 §4.1 made for `walk_y`. It is also
56    /// the world half of the comparison `DW0890` makes against the approved
57    /// design's rows, so every campaign has to state it for the comparison to
58    /// have two sides. Emission is unchanged: `time set <kw>` was always
59    /// emitted, so a campaign that already declared this builds
60    /// byte-identically.
61    pub time: WorldTime,
62    /// **The weather this delve is played in** (DSL v0.5, spec-0010; required
63    /// since spec-0061). Dimension-global; frozen by environment sealing
64    /// (`advance_weather false`). Rain and thunder attenuate effective sky
65    /// brightness in the assembled-light model.
66    ///
67    /// Required, with no default, for the reason [`WorldContent::time`] gives.
68    /// Emission is unchanged: `weather <kw>` is emitted only for a declared
69    /// non-`clear` weather, because `clear` is vanilla's own state.
70    pub weather: WorldWeather,
71    /// Declared combat difficulty (DSL v0.6). Absent =
72    /// the compiler's historical derivation — `easy` when the campaign fields any
73    /// wave, `peaceful` when it fields none — which is what keeps every campaign
74    /// written before this field byte-identical. Declaring it overrides the
75    /// derivation for **both** the shipped `server.properties` and a `/difficulty`
76    /// in the sealing baseline, so the declaration also holds when the datapack is
77    /// dropped into somebody else's world.
78    ///
79    /// `peaceful` is rejected (`DW0468`). Raising difficulty changes the damage
80    /// players take — easy halves it — so combat arithmetic tuned under the old
81    /// implicit `easy` must be redone.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub difficulty: Option<WorldDifficulty>,
84    /// The scenic horizon: the ground and the sky the map stands in
85    /// (spec-0026). Absent or `void` is the void world. `ocean` swaps the world
86    /// generator for a deterministic superflat sea (bedrock/stone/water, sea
87    /// level y=62) and drops the area datum to y=60 so island pieces meet the
88    /// sea at their authored waterline. `valley` rings the map in a generated
89    /// mountain annulus — the one base that builds terrain rather than picking
90    /// a generator. Either a string shorthand or the object form
91    /// `{base, …params}`; see [`Horizon`].
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub horizon: Option<Horizon>,
94    /// **How far a player must be able to see**, in chunks (spec-0091): the
95    /// server's `view-distance`, declared by the campaign whose far views need
96    /// it. A thing farther from a body than the served radius is never sent to
97    /// that body's client, so a landmark meant to be seen from across the map
98    /// is a declaration here, not a hope. Absent = the engine's floor
99    /// ([`crate::viewdistance::FLOOR`], 10 chunks = 160 blocks), which every
100    /// proof in the engine is written against; declared in
101    /// `FLOOR..=CEILING` (vanilla serves at most 32). A camera, a sightline, a
102    /// view or a cutscene shot aimed past the served radius is refused
103    /// (`DW0956`); the build states the heap the declared distance costs the
104    /// host at the player cap, and the hosting side meets it.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub view_distance: Option<u8>,
107    /// **The skies a place can stand under** (spec-0080). Each one is declared
108    /// once, here, beside `time`, `weather` and `horizon` — the other
109    /// statements about the sky the party stands under — and ships as a
110    /// datapack biome (`<ns>:atmosphere/<kebab>`) built from the pinned game's
111    /// environment attributes. A place carries one from the first tick
112    /// ([`Area::atmosphere`], `boxes[].atmosphere`), and a beat repaints a
113    /// volume with another ([`Verb::SetAtmosphere`]). Absent or empty: every
114    /// cell stands in the horizon's biome. One no place carries and no beat
115    /// paints is refused (`DW0930`).
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub atmospheres: Vec<Atmosphere>,
118    /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
119    /// derives a region from the placed geometry and a per-second clock returns any
120    /// player who leaves it to the last checkpoint. Required when `horizon` is
121    /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub boundary: Option<Boundary>,
124    /// The closing line on the campaign-completion advancement — the last
125    /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
126    /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
127    /// finale quest's `goal`, which is already both campaign-derived and
128    /// inventoried; the description was previously the hardcoded English
129    /// "You left the keep." on *every* delve, whatever its theme or language.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub outro: Option<String>,
132    /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
133    /// party of one is always legal and every pre-0.6 campaign keeps that reading.
134    /// A design whose beats genuinely need `n` players — two rooms whose switches
135    /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
136    /// lobby then refuses to start below it: the class-selection dialog stays shut
137    /// and the waiting players get a party-count actionbar instead.
138    ///
139    /// Progression is party state either way (spec-0018), so this is a *declaration
140    /// of intent*, not a mechanism: it makes a mandatory-n design first-class
141    /// and turns on the analyzer's n-agent division
142    /// proof. Out of `1..=4` is `DW0370`.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub min_players: Option<u8>,
145    /// **The vanilla textures this delve replaces** (spec-0084). Each row names
146    /// one texture the pinned client ships and the campaign's own image for it,
147    /// at `textures/<id>.png` in the campaign directory; the build bakes it into
148    /// the resource pack at the vanilla path, so it is drawn wherever the client
149    /// draws that texture — every mob of that kind, the moon over every area —
150    /// for every player who accepted the pack. Absent or empty = vanilla's look.
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub textures: Vec<TextureOverride>,
153    /// **Whether a player must accept this delve's resource pack to play it**
154    /// (spec-0084 §11). `true` is emitted as `require-resource-pack=true`: a
155    /// player who declines is disconnected by the server. Absent or `false` =
156    /// the pack is offered and may be declined, in which case the player reads
157    /// English and sees vanilla's textures. A host may still set the server's own
158    /// flip (itzg's `RESOURCE_PACK_ENFORCE`), which is obeyed as given.
159    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
160    pub require_resource_pack: bool,
161    /// **A fallen player waits before rejoining** (spec-0077). After clicking
162    /// *Respawn*, a player whose party still has somebody in play watches a
163    /// teammate as a spectator for `seconds`, and counts as down for the party
164    /// wipe while they wait. Absent = no wait, and emission is byte-identical to
165    /// a campaign that never had the field. Needs a checkpoint or bonfire to come
166    /// back to; `seconds` outside `1..=120`, or no checkpoint, is `DW0925`.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub respawn_wait: Option<RespawnWait>,
169}
170
171/// One vanilla texture a campaign replaces (spec-0084 §3.1). The row is a
172/// judgement and nothing more: width, height and frame count are read off the
173/// file and the pinned client's census, and the namespace is fixed.
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175#[serde(deny_unknown_fields)]
176pub struct TextureOverride {
177    /// A bare kebab token, unique among the campaign's textures (`DW0190`). The
178    /// image is `textures/<id>.png` in the campaign directory (`DW0309`), and a
179    /// `textures/<id>.png.mcmeta` beside it ships with it as the animation.
180    pub id: String,
181    /// The texture replaced, as a resource location in the `minecraft`
182    /// namespace without `textures/` and without `.png` — the path vanilla's own
183    /// models and atlases use (e.g. `minecraft:entity/zombie/drowned`,
184    /// `minecraft:environment/celestial/moon/full_moon`). It must name a texture
185    /// the pinned client ships (`DW0939`).
186    pub replaces: String,
187    /// Where the image came from and under what licence (ADR-0013): original
188    /// work (`spdx` and `source` both `original`), or an allowlisted third-party
189    /// image with its `url`, and its `attribution` for CC BY (`DW0741`).
190    pub license: crate::license::LicenseEvidence,
191}
192
193/// One declared sky (spec-0080 §3.1): what the party sees and hears while it
194/// stands in a cell painted with this atmosphere's biome.
195///
196/// The biome is vanilla's one channel for sky colour, fog, clouds, sky-light
197/// tint, stars, ambient particles, music, ambience, grass, foliage and water
198/// tint, and precipitation. In the overworld the day cycle stacks over it: it
199/// multiplies the colours (a biome's value survives, darkened at night), takes
200/// the maximum of `star_brightness`, and replaces the sun, moon and star
201/// angles, the sunrise colour and the moon phase outright — which is why those
202/// five ids are refused (`DW0928`) and the sun cannot be moved from a place.
203#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct Atmosphere {
206    /// `atmosphere/<kebab>`, unique.
207    pub id: AtmosphereId,
208    /// Environment attributes, keyed by id (`visual/sky_color`; the
209    /// `minecraft:` prefix is optional). Which ids a campaign may set, the
210    /// shape of each value and the range the pinned codec accepts are vendored
211    /// data (`crates/delvec/data/environment-attributes-1.21.11.json`), not
212    /// DSL surface: a value outside them is `DW0928`. A float attribute may
213    /// also be written in vanilla's modifier form, `{"argument": 0.85,
214    /// "modifier": "multiply"}`.
215    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
216    pub attributes: BTreeMap<String, serde_json::Value>,
217    /// Grass, foliage, dry-foliage and water tint (`#rrggbb`), each optional.
218    /// Absent, vanilla derives grass and foliage from the climate and water is
219    /// the void biome's `#3f76e4`.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub tint: Option<AtmosphereTint>,
222    /// What falls here when the world's weather is rain or thunder. The
223    /// compiler derives the three vanilla fields that must agree from it
224    /// (`has_precipitation`, `temperature`, `downfall`), and it is the fact
225    /// `DW0496` reads at a cell: `none` under a rainy world means the undead
226    /// burn here.
227    pub precipitation: Precipitation,
228    /// Overrides the derived `temperature` / `downfall`, for vanilla's own
229    /// grass colormap at a named point. Must agree with `precipitation`
230    /// (`DW0930`): snow below 0.15, rain at or above it.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub climate: Option<Climate>,
233}
234
235/// An atmosphere's tint (spec-0080 §3.1.3): the biome `effects` colours the
236/// pinned data writes, each `#rrggbb`.
237#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
238#[serde(deny_unknown_fields)]
239pub struct AtmosphereTint {
240    /// `grass_color`.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub grass: Option<String>,
243    /// `foliage_color`.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub foliage: Option<String>,
246    /// `dry_foliage_color`.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub dry_foliage: Option<String>,
249    /// `water_color`.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub water: Option<String>,
252}
253
254/// What an atmosphere's biome lets fall (spec-0080 §3.1.4).
255#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
256#[serde(rename_all = "kebab-case")]
257pub enum Precipitation {
258    /// Nothing falls: `has_precipitation: false`.
259    None,
260    /// Rain falls: `has_precipitation: true`, temperature 0.5.
261    Rain,
262    /// Snow falls: `has_precipitation: true`, temperature 0.0.
263    Snow,
264}
265
266/// A biome's climate pair (spec-0080 §3.1.4).
267#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
268#[serde(deny_unknown_fields)]
269pub struct Climate {
270    /// `temperature`: vanilla snows below 0.15.
271    pub temperature: f64,
272    /// `downfall`.
273    pub downfall: f64,
274}
275
276/// How long a fallen player waits before rejoining the party (spec-0077 §3).
277#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
278#[serde(deny_unknown_fields)]
279pub struct RespawnWait {
280    /// Seconds a fallen player waits after clicking *Respawn*, `1..=120`
281    /// (`DW0925`). Counted on the server only while the player is online.
282    pub seconds: u16,
283    /// Whether a player who comes back with nobody else present also waits.
284    /// Default `false`: a party of one never waits.
285    #[serde(default)]
286    pub alone: bool,
287}
288
289/// A declared world time state (DSL v0.5, spec-0010; the celestial spelling
290/// spec-0081). The sole difference from vanilla is that the daylight cycle is
291/// frozen (`advance_time false`), so a set state persists for the whole delve
292/// until a `set-time` effect cuts to another.
293///
294/// **Two spellings of one clock.** A keyword — `day`, `noon`, `dusk`, `night`,
295/// `midnight`, `dawn` — states vanilla's word with vanilla's meaning: the hour,
296/// on day 0, so a keyword night shows a full moon. A celestial statement
297/// ([`CelestialTime`], `{"moon": "just-risen", "phase": "new-moon"}`) names one
298/// body, where it stands and, where the moon shows, its phase, and the engine
299/// computes the tick count, day included. Both resolve to one [`Clock`]
300/// ([`WorldTime::clock`]); two values are equal when their clocks are.
301///
302/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
303/// tick count, and the tick form is the general one. `dusk` and `dawn` are the
304/// tick form exposed first-class, per the no-hack rule; a celestial statement is
305/// the same primitive reached from a designer's sentence. A keyword on day 0
306/// still emits its keyword verbatim ([`WorldTime::token`]), so existing
307/// campaigns are byte-identical.
308///
309/// **There is no `Default`** (spec-0061 §4). A default hour is a design decision
310/// wearing a mechanism's clothes, and `#[default] Noon` is what let a delve whose
311/// whole approved look was night build, light-check and render under a blue noon
312/// sky. Removing the impl is what makes that unwritable rather than merely
313/// discouraged: `WorldContent::time` is required, and nothing can supply an hour
314/// the author did not.
315#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
316pub enum WorldTime {
317    /// Morning daylight (`/time set day`, 1000 ticks).
318    Day,
319    /// Midday, brightest (`/time set noon`, 6000 ticks).
320    Noon,
321    /// Sunset onset (`/time set 12000`).
322    Dusk,
323    /// Night, sun fully down (`/time set night`, 13000 ticks).
324    Night,
325    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
326    Midnight,
327    /// First light, just before sunrise (`/time set 23000`).
328    Dawn,
329    /// A sky stated in a designer's words (spec-0081).
330    Celestial(CelestialTime),
331}
332
333/// The six keyword spellings of a [`WorldTime`] — the wire and schema form of
334/// its keyword half.
335#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
336#[serde(rename_all = "kebab-case")]
337pub enum TimeKeyword {
338    /// Morning daylight (`/time set day`, 1000 ticks).
339    Day,
340    /// Midday, brightest (`/time set noon`, 6000 ticks).
341    Noon,
342    /// Sunset — the sky visibly going orange and the day ending
343    /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
344    /// has finished setting, which is what the `night` keyword already sets, so
345    /// 13000 would make `dusk` a synonym rather than its own beat.
346    Dusk,
347    /// Night, sun fully down (`/time set night`, 13000 ticks).
348    Night,
349    /// Deep night, darkest (`/time set midnight`, 18000 ticks).
350    Midnight,
351    /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
352    /// `sunrise` is accepted as a synonym on input.
353    #[serde(alias = "sunrise")]
354    Dawn,
355}
356
357impl From<TimeKeyword> for WorldTime {
358    fn from(k: TimeKeyword) -> WorldTime {
359        match k {
360            TimeKeyword::Day => WorldTime::Day,
361            TimeKeyword::Noon => WorldTime::Noon,
362            TimeKeyword::Dusk => WorldTime::Dusk,
363            TimeKeyword::Night => WorldTime::Night,
364            TimeKeyword::Midnight => WorldTime::Midnight,
365            TimeKeyword::Dawn => WorldTime::Dawn,
366        }
367    }
368}
369
370impl Serialize for WorldTime {
371    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
372        match self {
373            WorldTime::Celestial(c) => c.serialize(s),
374            kw => s.serialize_str(kw.keyword_str().expect("a keyword")),
375        }
376    }
377}
378
379impl<'de> Deserialize<'de> for WorldTime {
380    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<WorldTime, D::Error> {
381        struct V;
382        impl<'de> serde::de::Visitor<'de> for V {
383            type Value = WorldTime;
384            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
385                f.write_str(
386                    "a time: a keyword (day, noon, dusk, night, midnight, dawn) or an object \
387                     naming one body, where it stands and its phase \
388                     ({\"moon\": \"just-risen\", \"phase\": \"new-moon\"})",
389                )
390            }
391            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WorldTime, E> {
392                use serde::de::IntoDeserializer;
393                TimeKeyword::deserialize(v.into_deserializer()).map(WorldTime::from)
394            }
395            fn visit_map<A: serde::de::MapAccess<'de>>(
396                self,
397                map: A,
398            ) -> Result<WorldTime, A::Error> {
399                CelestialTime::deserialize(serde::de::value::MapAccessDeserializer::new(map))
400                    .map(WorldTime::Celestial)
401            }
402        }
403        de.deserialize_any(V)
404    }
405}
406
407impl JsonSchema for WorldTime {
408    fn schema_name() -> std::borrow::Cow<'static, str> {
409        "WorldTime".into()
410    }
411
412    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
413        schemars::json_schema!({
414            "description": "A time: a keyword, vanilla's hour on day 0 (a keyword night is a full \
415                            moon), or a celestial statement naming one body, where it stands and, \
416                            where the moon shows, its phase (spec-0081).",
417            "anyOf": [g.subschema_for::<TimeKeyword>(), g.subschema_for::<CelestialTime>()],
418        })
419    }
420}
421
422impl WorldTime {
423    /// The keyword table: `(the /time set argument, daytime ticks)`, or `None`
424    /// for a celestial statement.
425    ///
426    /// A state vanilla names keeps its keyword — the argument the compiler has
427    /// always emitted — so no shipped campaign's bytes move. A state vanilla does
428    /// not name emits the equivalent tick count, which is the same primitive.
429    const fn spec(self) -> Option<(&'static str, i64)> {
430        match self {
431            WorldTime::Day => Some(("day", 1000)),
432            WorldTime::Noon => Some(("noon", 6000)),
433            WorldTime::Dusk => Some(("12000", 12000)),
434            WorldTime::Night => Some(("night", 13000)),
435            WorldTime::Midnight => Some(("midnight", 18000)),
436            WorldTime::Dawn => Some(("23000", 23000)),
437            WorldTime::Celestial(_) => None,
438        }
439    }
440
441    /// Whether this is one of the six keywords.
442    pub fn is_keyword(self) -> bool {
443        !matches!(self, WorldTime::Celestial(_))
444    }
445
446    /// The celestial statement, if this is one.
447    pub fn celestial(self) -> Option<CelestialTime> {
448        match self {
449            WorldTime::Celestial(c) => Some(c),
450            _ => None,
451        }
452    }
453
454    /// The phase this value states, if any.
455    pub fn stated_phase(self) -> Option<MoonPhase> {
456        self.celestial().and_then(|c| c.phase)
457    }
458
459    /// The `daytime` tick value this state sets (the `time query daytime`
460    /// read-back). Keywords: day=1000, noon=6000, dusk=12000 (sunset onset),
461    /// night=13000, midnight=18000, dawn=23000; a celestial statement its
462    /// position's tick ([`crate::celestial::position_tick`]).
463    pub fn daytime_ticks(self) -> i64 {
464        match self.spec() {
465            Some((_, t)) => t,
466            None => self.celestial().expect("celestial").daytime(),
467        }
468    }
469
470    /// The day this value names as **the world's own time**: a keyword is day 0;
471    /// a celestial statement the day its `phase` names, or day 0 where it names
472    /// none (the moon is below the horizon, so no phase is stated).
473    pub fn world_day(self) -> i64 {
474        self.stated_phase().map(MoonPhase::index).unwrap_or(0)
475    }
476
477    /// **The clock this value sets**, at a site whose world declares `world`
478    /// (spec-0081 §3.3).
479    ///
480    /// - A celestial statement is its position's tick on the day its `phase`
481    ///   names, or the world's day where it states none — a cut changes the
482    ///   hour, and the moon keeps the phase the world declared.
483    /// - A keyword is its table row on day 0 where it states a sky — the
484    ///   world's own time, a design row, a camera ([`TimeSite::Sky`]) — and on
485    ///   the world's day where it is a `set-time` cut ([`TimeSite::Cut`]), which
486    ///   changes the hour and keeps the moon.
487    pub fn clock(self, site: TimeSite, world: WorldTime) -> Clock {
488        let daytime = self.daytime_ticks();
489        let day = match (self, site) {
490            (WorldTime::Celestial(c), _) => c
491                .phase
492                .map(MoonPhase::index)
493                .unwrap_or_else(|| world.world_day()),
494            (_, TimeSite::Cut) => world.world_day(),
495            (_, TimeSite::Sky) => 0,
496        };
497        Clock { day, daytime }
498    }
499
500    /// The world's own clock: [`WorldTime::clock`] at the world's site.
501    pub fn world_clock(self) -> Clock {
502        self.clock(TimeSite::Sky, self)
503    }
504
505    /// **The vanilla `/time set` argument for `clock`**, the one token every
506    /// `time set` the engine emits goes through: a keyword on day 0 emits its
507    /// table argument verbatim (`night`, `12000`), so no keyword campaign's bytes
508    /// move; any other clock emits the integer `day × 24000 + daytime`.
509    pub fn token(self, clock: Clock) -> String {
510        match self.spec() {
511            Some((tok, t)) if clock.day == 0 && clock.daytime == t => tok.to_string(),
512            _ => clock.absolute().to_string(),
513        }
514    }
515
516    /// The keyword spelling, if this is a keyword.
517    fn keyword_str(self) -> Option<&'static str> {
518        match self {
519            WorldTime::Day => Some("day"),
520            WorldTime::Noon => Some("noon"),
521            WorldTime::Dusk => Some("dusk"),
522            WorldTime::Night => Some("night"),
523            WorldTime::Midnight => Some("midnight"),
524            WorldTime::Dawn => Some("dawn"),
525            WorldTime::Celestial(_) => None,
526        }
527    }
528
529    /// **What an author writes** — this state's spelling in a document: the
530    /// keyword, or the canonical JSON of a celestial statement
531    /// (`{"moon":"high","phase":"new-moon"}`).
532    ///
533    /// Not [`WorldTime::token`], which is the `/time set` argument and is a raw
534    /// tick count for every state vanilla does not name. A diagnostic that asks
535    /// an author to declare an hour has to say `dusk`, not `12000`.
536    pub fn keyword(self) -> String {
537        match self {
538            WorldTime::Celestial(c) => c.spelling(),
539            kw => kw.keyword_str().expect("a keyword").to_string(),
540        }
541    }
542}
543
544/// Where a time value is written, which decides the day a keyword names
545/// ([`WorldTime::clock`]).
546#[derive(Clone, Copy, Debug, PartialEq, Eq)]
547pub enum TimeSite {
548    /// A statement of a sky: the world's own time, a design row, a camera.
549    Sky,
550    /// A `set-time` effect, quest or dialogue.
551    Cut,
552}
553
554/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
555/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
556///
557/// **No `Default`**, for the reason [`WorldTime`] gives.
558#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
559#[serde(rename_all = "kebab-case")]
560pub enum WorldWeather {
561    /// Clear sky (`/weather clear`). Vanilla's own state, so emission writes no
562    /// `/weather` command for it.
563    Clear,
564    /// Rain (`/weather rain`).
565    Rain,
566    /// Thunderstorm (`/weather thunder`).
567    Thunder,
568}
569
570impl WorldWeather {
571    /// The word an author writes — identical to [`WorldWeather::token`] for
572    /// every state, and stated separately so a message that names a document's
573    /// vocabulary reads the document's vocabulary. See [`WorldTime::keyword`],
574    /// where the two differ.
575    pub fn keyword(self) -> &'static str {
576        self.token()
577    }
578
579    /// The vanilla `/weather` keyword.
580    pub fn token(self) -> &'static str {
581        match self {
582            WorldWeather::Clear => "clear",
583            WorldWeather::Rain => "rain",
584            WorldWeather::Thunder => "thunder",
585        }
586    }
587}
588
589/// The declared combat difficulty of the delve (DSL v0.6). Values are the
590/// vanilla `/difficulty` keywords.
591///
592/// Difficulty is the single largest lever on how hard a delve *feels*, so the
593/// campaign declares it rather than letting the compiler choose. Easy **halves
594/// incoming player damage** — `min(dmg / 2 + 1, dmg)` — so a campaign tuned
595/// under `easy` is tuned against a halved world. A campaign that
596/// raises this must redo that arithmetic.
597///
598/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
599/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
600/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
601/// campaign would silently vanish. It is a variant only so the compiler can say
602/// that in a diagnostic instead of a serde "unknown variant" parse error.
603#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
604#[serde(rename_all = "kebab-case")]
605pub enum WorldDifficulty {
606    /// `/difficulty easy` — the compiler's historical choice for a wave
607    /// campaign, and the default reading of an absent field. Incoming player
608    /// damage is halved (`min(dmg / 2 + 1, dmg)`).
609    #[default]
610    Easy,
611    /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
612    Normal,
613    /// `/difficulty hard` — amplified damage, and zombies reinforce.
614    Hard,
615    /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
616    /// the rejection can be a diagnostic with a rationale.
617    Peaceful,
618}
619
620impl WorldDifficulty {
621    /// The vanilla `/difficulty` keyword.
622    pub fn token(self) -> &'static str {
623        match self {
624            WorldDifficulty::Peaceful => "peaceful",
625            WorldDifficulty::Easy => "easy",
626            WorldDifficulty::Normal => "normal",
627            WorldDifficulty::Hard => "hard",
628        }
629    }
630
631    /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
632    /// `/difficulty` query command returns — the only vanilla read-back path for
633    /// the setting, and so what the generated PackTest asserts on.
634    pub fn id(self) -> i32 {
635        match self {
636            WorldDifficulty::Peaceful => 0,
637            WorldDifficulty::Easy => 1,
638            WorldDifficulty::Normal => 2,
639            WorldDifficulty::Hard => 3,
640        }
641    }
642}
643
644/// A scenic horizon (spec-0026). A horizon is a **composition of orthogonal
645/// axes**, not an enum of monoliths: a **base** — what surrounds the map — and
646/// that base's params. The field accepts a plain string shorthand
647/// ([`HorizonName`]) or the object form [`HorizonSpec`] `{base, …params}`.
648///
649/// Consumers never match this wire enum. [`Horizon::resolved`] desugars both
650/// forms into one [`ResolvedHorizon`] with the pinned defaults applied, and
651/// [`horizon_base`] answers the one question most callers have.
652#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
653#[serde(untagged)]
654pub enum Horizon {
655    /// A bare base name — `"ocean"` is exactly `{base: "ocean"}`. The two
656    /// bases that predate the horizon library were spelled this way and still
657    /// are, byte-identically; a base added since is spellable this way too,
658    /// because a base with every param at its default has nothing else to say.
659    Name(HorizonBase),
660    /// The object form `{base, …params}`.
661    Spec(HorizonSpec),
662}
663
664/// What surrounds the map.
665///
666/// **One enumeration of bases, reachable two ways.** The shorthand
667/// `horizon: "ocean"` and the object form `horizon: {base: "ocean"}` name this
668/// same variant, which is what stops a base from existing in one spelling and
669/// not the other. A separate list of "names" beside this one would be two
670/// enumerations of the same thing, and the second base added would land in
671/// whichever of them its author was looking at.
672///
673/// What is deliberately NOT here is a name that stands for a base plus a set of
674/// params. Such a name reads as a thing, and the whole claim of this design is
675/// that it is not one — it is a base with params set. A spelling that hides
676/// which params it sets makes that claim unverifiable by looking at the
677/// document, and buys a few saved keystrokes for it. Each base carries its own params on
678/// [`HorizonSpec`], and a param foreign to the declared base is refused rather
679/// than ignored.
680#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
681#[serde(rename_all = "kebab-case")]
682pub enum HorizonBase {
683    /// Void superflat; no surround. The default.
684    #[default]
685    Void,
686    /// Pinned water superflat, sea level 62; no surround.
687    Ocean,
688    /// A mountain annulus around a flat gap floor, with void ambient below the
689    /// tile skirt. The one base that generates terrain.
690    Valley,
691}
692
693impl HorizonBase {
694    /// The kebab wire name.
695    pub fn token(self) -> &'static str {
696        match self {
697            HorizonBase::Void => "void",
698            HorizonBase::Ocean => "ocean",
699            HorizonBase::Valley => "valley",
700        }
701    }
702
703    /// Whether this base generates a surround — compiler-built terrain outside
704    /// the map's own extent. `void` and `ocean` are pure ambient and build
705    /// nothing.
706    pub fn has_surround(self) -> bool {
707        matches!(self, HorizonBase::Valley)
708    }
709}
710
711/// The `horizon` object form: a `base` plus that base's params, all optional
712/// with pinned defaults ([`horizon_defaults`]).
713///
714/// The `valley` surround generator carries a second flora and a second surface
715/// palette (a cherry grove over `minecraft:cherry_grove`) and **this struct
716/// does not expose them.** Every engine surface owes a gallery element in the
717/// change that lands it; the element a second flora needs is a valley overlay,
718/// and one is writable now that a one-area campaign's single prefab states an
719/// extent ([`crate::placement::Extent`], `DW0855`) — the reason recorded here
720/// was that no two-file overlay could ring a map, and that reason is spent.
721/// What is left is that nothing has written the element, and a surface lands
722/// with its element or it does not land. The shape is flat rather than
723/// per-base tagged, and a param foreign to the declared base is refused
724/// (`DW0853`) — so an `ocean` cannot quietly carry a `rim_height` that nothing
725/// reads.
726#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
727#[serde(deny_unknown_fields)]
728pub struct HorizonSpec {
729    /// The base — what surrounds the map.
730    pub base: HorizonBase,
731    /// `valley`: the surround's total footprint as a multiple of the map's, on
732    /// each axis (`2.0..=3.0`, default 2.5).
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub ratio: Option<f64>,
735    /// `valley`: crest height of the rim over the gap floor (`16..=128`,
736    /// default 48).
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub rim_height: Option<i32>,
739}
740
741/// Pinned horizon param defaults. One table, so the doc comments, the resolver
742/// and the diagnostics cannot drift.
743pub mod horizon_defaults {
744    /// `valley.ratio`.
745    pub const RATIO: f64 = 2.5;
746    /// `valley.ratio` lower bound — below 2.0 the annulus has no room for a
747    /// gap floor and a slope run both.
748    pub const RATIO_MIN: f64 = 2.0;
749    /// `valley.ratio` upper bound — above 3.0 the surround is mostly terrain a
750    /// body never reaches, at a cost that is all shipped bytes.
751    pub const RATIO_MAX: f64 = 3.0;
752    /// `valley.rim_height`.
753    pub const RIM_HEIGHT: i32 = 48;
754    /// `valley.rim_height` lower bound — a rim under 16 does not close the
755    /// horizon from a body standing on the gap floor.
756    pub const RIM_HEIGHT_MIN: i32 = 16;
757    /// `valley.rim_height` upper bound — the build range is 384 blocks tall and
758    /// the surround has to fit under whatever the map puts above it.
759    pub const RIM_HEIGHT_MAX: i32 = 128;
760}
761
762/// A horizon with both wire forms desugared and every default applied — the
763/// only view downstream code reads.
764#[derive(Clone, Copy, Debug, PartialEq)]
765pub struct ResolvedHorizon {
766    /// The base.
767    pub base: HorizonBase,
768    /// `valley.ratio`.
769    pub ratio: f64,
770    /// `valley.rim_height`.
771    pub rim_height: i32,
772}
773
774impl Default for ResolvedHorizon {
775    fn default() -> Self {
776        ResolvedHorizon {
777            base: HorizonBase::Void,
778            ratio: horizon_defaults::RATIO,
779            rim_height: horizon_defaults::RIM_HEIGHT,
780        }
781    }
782}
783
784impl ResolvedHorizon {
785    /// The resolved horizon of `base` with every param at its pinned default.
786    pub fn of_base(base: HorizonBase) -> Self {
787        ResolvedHorizon {
788            base,
789            ..Default::default()
790        }
791    }
792}
793
794impl Horizon {
795    /// Desugar either wire form to the one resolved view, defaults applied.
796    pub fn resolved(&self) -> ResolvedHorizon {
797        match self {
798            Horizon::Name(base) => ResolvedHorizon::of_base(*base),
799            Horizon::Spec(s) => ResolvedHorizon {
800                base: s.base,
801                ratio: s.ratio.unwrap_or(horizon_defaults::RATIO),
802                rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT),
803            },
804        }
805    }
806
807    /// The resolved base.
808    pub fn base(&self) -> HorizonBase {
809        self.resolved().base
810    }
811
812    /// True when this declaration needs the horizon-library surface: the object
813    /// form, or a bare name for a base that did not exist before it.
814    ///
815    /// The two bases that predate the library stay writable as bare names at
816    /// the version that introduced them, and their emission does not move —
817    /// which is what makes this a widening rather than a break. What is fenced
818    /// is saying something the old surface had no spelling for.
819    pub fn needs_horizon_library(&self) -> bool {
820        match self {
821            Horizon::Spec(_) => true,
822            Horizon::Name(base) => match base {
823                HorizonBase::Void | HorizonBase::Ocean => false,
824                HorizonBase::Valley => true,
825            },
826        }
827    }
828}
829
830/// The resolved base of an optional stage-1 `horizon` field — `Void` when
831/// absent. The one helper every downstream consumer (placement, the ambient
832/// model, emission) goes through, so that a new base cannot be forgotten at one
833/// of them.
834pub fn horizon_base(horizon: &Option<Horizon>) -> HorizonBase {
835    horizon.as_ref().map(|h| h.base()).unwrap_or_default()
836}
837
838/// The resolved view of an optional stage-1 `horizon` field, with defaults
839/// applied for an absent one.
840pub fn resolved_horizon(horizon: &Option<Horizon>) -> ResolvedHorizon {
841    horizon.as_ref().map(|h| h.resolved()).unwrap_or_default()
842}
843
844/// The default boundary `margin` (blocks of horizontal breathing room added
845/// around the derived region). Separate function so `serde(default = …)` and the
846/// documented literal cannot drift.
847fn default_margin() -> u16 {
848    16
849}
850
851/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
852/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
853/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
854/// placed block − 8) — never authored — so "every anchor is inside" is structural.
855/// Enforcement is a per-second clock that returns any player outside the region to
856/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
857/// damage, no items lost.
858#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
859#[serde(deny_unknown_fields)]
860pub struct Boundary {
861    /// Horizontal breathing room in blocks added around the derived region on
862    /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
863    #[serde(default = "default_margin")]
864    pub margin: u16,
865    /// Actionbar message shown on return. Absent = the compiler's English default.
866    /// When set, it is inventoried under l10n key `world.boundary.message` and is
867    /// translated like every other player-facing string.
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub message: Option<String>,
870    /// **Whether the boundary returns a player who leaves it** (spec-0092 §10).
871    /// Default `true`: the per-second clock returns any player outside the region
872    /// to the last checkpoint. `false` keeps the region — every proof that reads
873    /// it reads the same box — and emits no clock: the creator's switch for a
874    /// world nobody can leave, where a return only fights a creator flying out
875    /// to look at a far view. Legal only where the build proves no body can walk
876    /// or swim out of the region (`DW0960`).
877    #[serde(default = "default_true", skip_serializing_if = "is_true")]
878    pub returns: bool,
879}
880
881#[allow(clippy::trivially_copy_pass_by_ref)] // serde's `skip_serializing_if` hands a reference
882fn is_true(b: &bool) -> bool {
883    *b
884}
885
886/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
887/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
888/// compiler owns the placement rule and block-light emission.
889#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
890#[serde(rename_all = "kebab-case")]
891pub enum Fixture {
892    /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
893    Torch,
894    /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
895    Lantern,
896    /// Floor campfire (block light 15); never on or adjacent to a required path
897    /// cell (it is a damage source).
898    Campfire,
899    /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
900    Shroomlight,
901}
902
903impl Fixture {
904    /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
905    pub fn token(self) -> &'static str {
906        match self {
907            Fixture::Torch => "torch",
908            Fixture::Lantern => "lantern",
909            Fixture::Campfire => "campfire",
910            Fixture::Shroomlight => "shroomlight",
911        }
912    }
913}
914
915/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
916/// presence puts the area on the relight path: the compiler guarantees every
917/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
918/// with `DW0211` if it cannot.
919#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
920#[serde(deny_unknown_fields)]
921pub struct AreaLighting {
922    /// The fixture the relight pass places.
923    pub fixture: Fixture,
924    /// The minimum block+sky light guaranteed on reachable walkable cells
925    /// (1..=14, default 7). Range-checked (`DW0196`).
926    #[serde(default = "default_min_light")]
927    pub min_light: u8,
928}
929
930/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
931fn default_min_light() -> u8 {
932    7
933}
934
935/// A per-area **darkness mitigation** declaration (DSL v0.6).
936///
937/// The first-class answer to "this area is meant to be dark, and the players are
938/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
939/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
940/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
941/// gap between the check and the feature.
942///
943/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
944/// for `night vision`: that accepted a renamed water bottle, so the gate passed
945/// while nothing in the world granted night vision (owner, island QA).
946#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
947#[serde(rename_all = "kebab-case")]
948pub enum AreaMitigation {
949    /// Every player inside the area's placed bounds is kept under
950    /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
951    NightVision,
952}
953
954/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
955///
956/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
957/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
958/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
959/// full jigsaw layout semantics are spec-0002's.
960#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
961#[serde(deny_unknown_fields)]
962pub struct Area {
963    /// Unique area id.
964    pub id: AreaId,
965    /// Player-facing area name.
966    pub name: String,
967    /// The single prefab bound to this area (mutually exclusive with
968    /// `prefab_pool`).
969    #[serde(default, skip_serializing_if = "Option::is_none")]
970    pub prefab: Option<PrefabId>,
971    /// The jigsaw prefab pool bound to this area (mutually exclusive with
972    /// `prefab`); requires `pieces`.
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub prefab_pool: Option<PoolId>,
975    /// Jigsaw piece-count bounds (only with `prefab_pool`).
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub pieces: Option<Pieces>,
978    /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
979    /// present, the compiler's relight pass guarantees `min_light` on every
980    /// reachable walkable cell of this area by placing the declared fixture, or
981    /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
982    /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub lighting: Option<AreaLighting>,
985    /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
986    /// the compiler emit a clocked `effect give` over this area's placed bounds and
987    /// is the (only) declaration that satisfies `DW0210` without `lighting`.
988    /// Independent of `lighting`: an area may declare both (fixtures *and* the
989    /// effect), either, or neither.
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    pub mitigation: Option<AreaMitigation>,
992    /// **The sky this place stands under from the first tick** (spec-0080
993    /// §3.2): one of `world.atmospheres[]`, painted at world setup over the
994    /// area's placed bounds grown up and down as far as the client's biome
995    /// blend reads. The volume is the placement's, never typed.
996    /// Absent: the horizon's biome.
997    #[serde(default, skip_serializing_if = "Option::is_none")]
998    pub atmosphere: Option<AtmosphereId>,
999}
1000
1001/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
1002#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1003#[serde(deny_unknown_fields)]
1004pub struct Pieces {
1005    /// Minimum number of pieces to assemble.
1006    pub min: u32,
1007    /// Maximum number of pieces to assemble.
1008    pub max: u32,
1009}
1010
1011// ---------------------------------------------------------------------------
1012// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
1013// ---------------------------------------------------------------------------
1014
1015use std::collections::BTreeSet;
1016
1017use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
1018use crate::envelope::Campaign;
1019use crate::ids::is_kebab;
1020use crate::registry::AnchorRegistry;
1021
1022crate::dw_code! {
1023    /// Area binds neither or both of `prefab` / `prefab_pool` (exactly one
1024    /// required).
1025    pub const PREFAB_BINDING: DwCode = DwCode::new("DW0160", ExitTier::Build);
1026}
1027
1028crate::dw_code! {
1029    /// Area `prefab_pool` references a pool absent from `prefabs/` metadata.
1030    pub const POOL_UNKNOWN: DwCode = DwCode::new("DW0161", ExitTier::Build);
1031}
1032
1033crate::dw_code! {
1034    /// Area `prefab` names a piece absent from `prefabs/` metadata — the same
1035    /// obligation [`POOL_UNKNOWN`] carries on the other arm of the binding. It
1036    /// is an error rather than a deferral because an area whose piece is absent
1037    /// contributes no anchor set at all, so every per-area anchor proof over it
1038    /// is SKIPPED rather than failed: a misspelling here is strictly less
1039    /// checked than a correct name.
1040    pub const PREFAB_UNKNOWN: DwCode = DwCode::new("DW0856", ExitTier::Build);
1041}
1042
1043crate::dw_code! {
1044    /// (v0.6) `horizon: "ocean"` declared without a `boundary` (spec-0013):
1045    /// validation-tier (exit 1). An infinite swimmable sea with no return rule is
1046    /// an authoring error. Grouped in the DW032x world/region family by domain;
1047    /// unlike the compiler-tier DW030x geometry codes it is raised at DSL
1048    /// validation, so it exits 1.
1049    pub const OCEAN_NO_BOUNDARY: DwCode = DwCode::new("DW0320", ExitTier::Build);
1050}
1051
1052crate::dw_code! {
1053    /// (v0.6) `boundary.margin` outside the `0..=64` range (spec-0013):
1054    /// validation-tier (exit 1).
1055    pub const BOUNDARY_MARGIN: DwCode = DwCode::new("DW0321", ExitTier::Build);
1056}
1057
1058crate::dw_code! {
1059    /// A stage-1 `horizon` param is out of range, or is a param of a base other
1060    /// than the one declared (spec-0026): validation-tier (exit 1).
1061    pub const HORIZON_PARAM: DwCode = DwCode::new("DW0853", ExitTier::Build);
1062}
1063
1064crate::dw_code! {
1065    /// A `horizon` whose base BUILDS terrain, on a campaign that states no
1066    /// extent for that terrain to stand around (spec-0026): validation-tier
1067    /// (exit 1).
1068    ///
1069    /// A surround rings a declared extent — a site plan's `region`. A campaign
1070    /// that seats its pieces with `areas[]` declares none, and the union of
1071    /// whatever gets placed is not a substitute: it is an artifact of the
1072    /// compiler's fixed area stride, mostly the void between areas, so ringing
1073    /// it builds a mountain range around empty space.
1074    pub const SURROUND_NO_REGION: DwCode = DwCode::new("DW0855", ExitTier::Build);
1075}
1076
1077crate::dw_code! {
1078    /// (v0.6, spec-0018) `world.min_players` outside the `1..=4` range. A delve is
1079    /// played by ONE party of 1–4 (ADR/CLAUDE.md product definition), so a declared
1080    /// mandatory party size can never sit outside it. Validation-tier (exit 1).
1081    pub const PARTY_SIZE: DwCode = DwCode::new("DW0356", ExitTier::Build);
1082}
1083
1084crate::dw_code! {
1085    /// (spec-0077 §7) **A respawn wait that cannot be honoured.**
1086    /// `world.respawn_wait.seconds` lies outside `1..=120`, or the campaign
1087    /// declares a `respawn_wait` and no `set-checkpoint` or `bonfire` for a
1088    /// fallen player to come back to (the wait hangs off the checkpoint respawn
1089    /// edge, so with none it is a silently dead declaration). One rule about what
1090    /// a wait needs, two ways to break it. Validation-tier (exit 1). The build's
1091    /// own self-check that a shipped selector cannot read a waiter is `DW0926`.
1092    /// Prescription: a value in `1..=120`, or a checkpoint, or drop the field.
1093    pub const RESPAWN_WAIT_INVALID: DwCode = DwCode::new("DW0925", ExitTier::Build);
1094}
1095
1096crate::dw_code! {
1097    /// (v0.6) `world.difficulty` is `peaceful`. On
1098    /// peaceful the server discards every hostile-category mob as it is ticked —
1099    /// `/summon`ed, `NoAI`, `PersistenceRequired`, all of it — so a peaceful delve
1100    /// is one in which every wave, every hostile actor and every ambush silently
1101    /// ceases to exist. There is no delve that wants that, so the keyword is
1102    /// refused rather than honoured. Validation-tier (exit 1).
1103    pub const DIFFICULTY_INVALID: DwCode = DwCode::new("DW0468", ExitTier::Build);
1104}
1105
1106crate::dw_code! {
1107    /// (spec-0084 §6.1) A `world.textures[]` row's `replaces` names a texture the
1108    /// pinned client does not ship — not the `minecraft` namespace, a path with
1109    /// `textures/` or `.png` left on, another version's path, a misspelling — or
1110    /// two rows replace one texture. Judged against the census vendored from the
1111    /// pinned client jar (`delvec::compiler::textures`); the duplicate half is
1112    /// judged in validation, where no census is needed.
1113    pub const TEXTURE_PATH: DwCode = DwCode::new("DW0939", ExitTier::Build);
1114}
1115
1116/// **How far this campaign is from being one piece**, in a clause — the half of
1117/// `DW0855` that tells a creator which of the three moves is one step away.
1118///
1119/// It names the count it read, so a reader can see what the refusal counted
1120/// rather than being told a category.
1121fn one_piece_gap(c: &Campaign) -> String {
1122    let areas = &c.world.content.areas;
1123    match areas.len() {
1124        0 => ", and no area is declared at all".to_string(),
1125        1 => format!(
1126            ", and its one area `{id}` draws from a pool rather than binding a single `prefab`",
1127            id = areas[0].id.as_str(),
1128        ),
1129        n => format!(", which is {n} areas rather than one"),
1130    }
1131}
1132
1133/// The spec-0026 **horizon library**: a declared horizon's params are
1134/// range-checked here, and a param that belongs to another base is refused.
1135pub(crate) fn horizon_param_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
1136    use crate::{HorizonBase, horizon_defaults};
1137
1138    let Some(h) = c.world.content.horizon.as_ref() else {
1139        return;
1140    };
1141
1142    let r = h.resolved();
1143
1144    // Params foreign to the declared base. The wire shape is flat — one schema
1145    // rather than one per base — so this is where a param finds out it is not
1146    // for the base beside it. Silently ignoring it is the worse answer: an
1147    // author who wrote `rim_height` on an `ocean` believes something is being
1148    // read.
1149    if let crate::Horizon::Spec(spec) = h {
1150        let mut foreign: Vec<&str> = Vec::new();
1151        if !matches!(r.base, HorizonBase::Valley) {
1152            if spec.ratio.is_some() {
1153                foreign.push("ratio");
1154            }
1155            if spec.rim_height.is_some() {
1156                foreign.push("rim_height");
1157            }
1158        }
1159        for name in foreign {
1160            d.push(Diagnostic::error(
1161                HORIZON_PARAM,
1162                "world",
1163                format!("/content/horizon/{name}"),
1164                format!(
1165                    "`{name}` is a `valley` param and this horizon declares base `{base}`, which \
1166                     reads nothing from it. Remove it, or declare `base: \"valley\"` — a param \
1167                     nothing reads is a statement the author believes is taking effect.",
1168                    base = r.base.token()
1169                ),
1170            ));
1171        }
1172    }
1173
1174    // A base that BUILDS terrain needs a map to build it around, and whether
1175    // this campaign states one is `crate::placement::Extent`'s answer — the same
1176    // one `compiler::plan::surround_rect` derives the rectangle from, so the
1177    // tier that refuses and the tier that builds cannot disagree about which
1178    // campaigns have an extent. Refused here rather than at the build because it
1179    // is a fact about the documents: nothing has to be placed to know that
1180    // nothing states an extent.
1181    if r.base.has_surround() && !crate::placement::Extent::of(c).is_stated() {
1182        d.push(Diagnostic::error(
1183            SURROUND_NO_REGION,
1184            "world",
1185            "/content/horizon/base",
1186            format!(
1187                "`horizon` base `{base}` builds terrain around the map, and this campaign never \
1188                 says how big the map is. A surround rings a DECLARED extent, and this campaign \
1189                 declares none: it places {n} area(s) with `areas[]`{how}. The union of whatever \
1190                 those place is not a substitute — areas sit on the compiler's fixed stride with \
1191                 void between them, and a pool's footprint is whatever the solver drew — so that \
1192                 union is mostly nothing and the horizon would be a mountain range built around \
1193                 empty space. There are three moves and all three are reachable from here: make \
1194                 the map ONE PIECE — a single area bound to a single `prefab`, whose own declared \
1195                 region is then the map's extent, which is how a site (a building with its \
1196                 island, its moat and its banks in one box) is placed; or give the campaign a \
1197                 site plan and declare `areas` empty, which is the same choice `DW0839` asks for; \
1198                 or set `horizon` to `void` or `ocean`, which need no map to be a horizon of.",
1199                base = r.base.token(),
1200                n = c.world.content.areas.len(),
1201                how = one_piece_gap(c),
1202            ),
1203        ));
1204    }
1205
1206    // Ranges. Checked on the RESOLVED view so a shorthand is judged by the same
1207    // rule as the object form it desugars to.
1208    if r.base.has_surround() {
1209        if !(horizon_defaults::RATIO_MIN..=horizon_defaults::RATIO_MAX).contains(&r.ratio)
1210            || !r.ratio.is_finite()
1211        {
1212            d.push(Diagnostic::error(
1213                HORIZON_PARAM,
1214                "world",
1215                "/content/horizon/ratio",
1216                format!(
1217                    "`ratio` = {} is out of range — set it within {}..={} ({} is the default). \
1218                     It is the surround's total footprint as a multiple of the \
1219                     map's: under {} there is no room for a gap floor and a slope run \
1220                     both, and over {} the surround is mostly terrain no body reaches, at a cost \
1221                     that is all shipped bytes.",
1222                    r.ratio,
1223                    horizon_defaults::RATIO_MIN,
1224                    horizon_defaults::RATIO_MAX,
1225                    horizon_defaults::RATIO,
1226                    horizon_defaults::RATIO_MIN,
1227                    horizon_defaults::RATIO_MAX,
1228                ),
1229            ));
1230        }
1231        if !(horizon_defaults::RIM_HEIGHT_MIN..=horizon_defaults::RIM_HEIGHT_MAX)
1232            .contains(&r.rim_height)
1233        {
1234            d.push(Diagnostic::error(
1235                HORIZON_PARAM,
1236                "world",
1237                "/content/horizon/rim_height",
1238                format!(
1239                    "`rim_height` = {} is out of range — set it within {}..={} ({} is the \
1240                     default). It is the crest's height over the gap floor: under {} \
1241                     the rim does not close the horizon from a body standing on that floor, and \
1242                     over {} the surround stops fitting under whatever the map puts above it.",
1243                    r.rim_height,
1244                    horizon_defaults::RIM_HEIGHT_MIN,
1245                    horizon_defaults::RIM_HEIGHT_MAX,
1246                    horizon_defaults::RIM_HEIGHT,
1247                    horizon_defaults::RIM_HEIGHT_MIN,
1248                    horizon_defaults::RIM_HEIGHT_MAX,
1249                ),
1250            ));
1251        }
1252    }
1253}
1254
1255/// spec-0084: the half of a `world.textures[]` row that needs neither the
1256/// pinned client's census nor the campaign's files — the id (`DW0190`, the rule
1257/// a skin's `texture_id` already has), one row per replaced texture (`DW0939`),
1258/// and the licence (`DW0741`). The census half (`DW0939`, `DW0940`) and the file
1259/// (`DW0309`) are judged where the files are read, `delvec::compiler::textures`.
1260fn texture_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
1261    let mut ids: BTreeSet<&str> = BTreeSet::new();
1262    let mut replaced: BTreeMap<&str, usize> = BTreeMap::new();
1263    for (i, t) in c.world.content.textures.iter().enumerate() {
1264        if !is_kebab(&t.id) {
1265            d.push(Diagnostic::error(
1266                codes::SKIN_INVALID,
1267                "world",
1268                format!("/content/textures/{i}/id"),
1269                format!(
1270                    "texture `id` `{}` is malformed — it must be a bare kebab token (e.g. \
1271                     `red-moon`), matching the `textures/<id>.png` filename",
1272                    t.id
1273                ),
1274            ));
1275        } else if !ids.insert(t.id.as_str()) {
1276            d.push(Diagnostic::error(
1277                codes::SKIN_INVALID,
1278                "world",
1279                format!("/content/textures/{i}/id"),
1280                format!(
1281                    "duplicate texture `id` `{}` — each row names its own image; rename one \
1282                     (and its `textures/<id>.png`)",
1283                    t.id
1284                ),
1285            ));
1286        }
1287        if let Some(first) = replaced.insert(t.replaces.as_str(), i) {
1288            d.push(Diagnostic::error(
1289                TEXTURE_PATH,
1290                "world",
1291                format!("/content/textures/{i}/replaces"),
1292                format!(
1293                    "texture `{}` replaces `{}`, which `world.textures[{first}]` already \
1294                     replaces — a texture is drawn one way, so remove one of the two rows",
1295                    t.id, t.replaces
1296                ),
1297            ));
1298        }
1299        for reason in crate::license::image_license_refusals(&t.license) {
1300            d.push(Diagnostic::error(
1301                codes::LICENSE_REFUSED,
1302                "world",
1303                format!("/content/textures/{i}/license"),
1304                format!("texture `{}` (replaces `{}`): {reason}", t.id, t.replaces),
1305            ));
1306        }
1307    }
1308}
1309
1310/// Stage-1 `horizon`/`boundary` validation (spec-0013), the party size
1311/// (spec-0018), the declared difficulty and the declared textures (spec-0084).
1312pub(crate) fn world_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
1313    texture_checks(c, d);
1314    // spec-0091: the declared view distance's range, and every site-plan line
1315    // of sight judged against the radius it serves. The binding it states is
1316    // printed by the CLI, which asks for it again without the diagnostics.
1317    crate::viewdistance::checks(c, d);
1318    // spec-0018: a delve is played by ONE party of 1–4, so a declared
1319    // mandatory size outside that range can never be honoured.
1320    if let Some(n) = c.world.content.min_players
1321        && !(1..=4).contains(&n)
1322    {
1323        d.push(Diagnostic::error(
1324            PARTY_SIZE,
1325            "world",
1326            "/content/min_players".to_string(),
1327            format!(
1328                "`min_players` = {n} is out of range — a delve is played by one party of 1–4, \
1329                 so set it to a value in 1..=4 (absent = 1, a party of one)"
1330            ),
1331        ));
1332    }
1333    // spec-0077 §7: a respawn wait is `1..=120` seconds, and it hangs off the
1334    // checkpoint respawn edge, so it needs a checkpoint or bonfire to exist.
1335    if let Some(w) = c.world.content.respawn_wait {
1336        if !(1..=120).contains(&w.seconds) {
1337            d.push(Diagnostic::error(
1338                RESPAWN_WAIT_INVALID,
1339                "world",
1340                "/content/respawn_wait/seconds".to_string(),
1341                format!(
1342                    "`respawn_wait.seconds` = {} is out of range — a fallen player waits 1 to 120 \
1343                     seconds, so set it to a value in 1..=120, or drop `respawn_wait` for no wait",
1344                    w.seconds
1345                ),
1346            ));
1347        }
1348        if !crate::declares_checkpoint(c) {
1349            d.push(Diagnostic::error(
1350                RESPAWN_WAIT_INVALID,
1351                "world",
1352                "/content/respawn_wait".to_string(),
1353                "`respawn_wait` is declared but this campaign declares no `set-checkpoint` or \
1354                 `bonfire` — the wait begins on the checkpoint respawn edge, so with nothing to \
1355                 come back to it never runs. Add the checkpoint or bonfire a fallen player \
1356                 returns to, or drop `respawn_wait`."
1357                    .to_string(),
1358            ));
1359        }
1360    }
1361    // Declared combat difficulty. `peaceful` is the
1362    // one keyword the compiler refuses: on peaceful the server discards every
1363    // hostile-category mob as it ticks it — summoned, `NoAI` and
1364    // `PersistenceRequired` are all irrelevant — so a peaceful delve is one in
1365    // which the entire cast of threats quietly does not exist.
1366    if matches!(
1367        c.world.content.difficulty,
1368        Some(crate::WorldDifficulty::Peaceful)
1369    ) {
1370        d.push(Diagnostic::error(
1371            DIFFICULTY_INVALID,
1372            "world",
1373            "/content/difficulty".to_string(),
1374            "`difficulty: \"peaceful\"` is refused: on peaceful the server discards every \
1375             hostile-category mob as it ticks it — being `/summon`ed, `NoAI` or \
1376             `PersistenceRequired` does not save one — so every wave, hostile actor and \
1377             ambush in this campaign would silently cease to exist. Declare `easy`, `normal` \
1378             or `hard`; for a delve that is genuinely combat-free, simply omit `difficulty` \
1379             (a campaign that fields no wave and stages no body peaceful discards ships \
1380             peaceful by derivation)"
1381                .to_string(),
1382        ));
1383    }
1384    // A horizon whose ambient a body can ENTER needs a return rule. The
1385    // question is the ambient's, never the base's name: an ocean is an
1386    // infinite swimmable sea, and a valley's gap floor is walkable ground
1387    // that runs to the foot of the rim. `void` is the only base a body
1388    // cannot enter, because there is nothing out there to stand on.
1389    let entered_base = match crate::horizon_base(&c.world.content.horizon) {
1390        crate::HorizonBase::Void => None,
1391        crate::HorizonBase::Ocean => Some((
1392            "ocean",
1393            "an infinite swimmable sea with no return rule lets players wander off the map",
1394        )),
1395        crate::HorizonBase::Valley => Some((
1396            "valley",
1397            "the gap floor between the map and the rim is walkable ground, and with no \
1398             return rule a player who steps off the map is simply outside it",
1399        )),
1400    };
1401    if let Some((base, why)) = entered_base
1402        && c.world.content.boundary.is_none()
1403    {
1404        d.push(Diagnostic::error(
1405            OCEAN_NO_BOUNDARY,
1406            "world",
1407            "/content/horizon".to_string(),
1408            format!(
1409                "`horizon` base `{base}` needs a `boundary` — {why}. Add a `boundary` (a \
1410                 bare `{{}}` uses the default margin), or set `horizon` to `void`"
1411            ),
1412        ));
1413    }
1414    // `margin` range check (0..=64).
1415    if let Some(b) = &c.world.content.boundary
1416        && !(0..=64).contains(&b.margin)
1417    {
1418        d.push(Diagnostic::error(
1419            BOUNDARY_MARGIN,
1420            "world",
1421            "/content/boundary/margin".to_string(),
1422            format!(
1423                "`boundary.margin` = {} is out of range — set it to a value in 0..=64 (16 is \
1424                 the default)",
1425                b.margin
1426            ),
1427        ));
1428    }
1429}
1430
1431/// Per-area `lighting` (spec-0010): `min_light` is range-checked (1..=14,
1432/// `DW0196`).
1433pub(crate) fn lighting_range_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
1434    // Range-check min_light (1..=14) where a lighting block is declared.
1435    for (i, area) in c.world.content.areas.iter().enumerate() {
1436        if let Some(lighting) = &area.lighting
1437            && !(1..=14).contains(&lighting.min_light)
1438        {
1439            d.push(Diagnostic::error(
1440                codes::LIGHTING_RANGE,
1441                "world",
1442                format!("/content/areas/{i}/lighting/min_light"),
1443                format!(
1444                    "area `{}` `lighting.min_light` = {} is out of range — set it to a value \
1445                     in 1..=14 (7 is the default)",
1446                    area.id, lighting.min_light
1447                ),
1448            ));
1449        }
1450    }
1451}
1452
1453pub(crate) fn prefab_binding(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
1454    for (i, a) in c.world.content.areas.iter().enumerate() {
1455        // Exactly one of `prefab` / `prefab_pool`.
1456        match (&a.prefab, &a.prefab_pool) {
1457            (Some(_), Some(_)) => d.push(Diagnostic::error(
1458                PREFAB_BINDING,
1459                "world",
1460                format!("/content/areas/{i}"),
1461                format!(
1462                    "area `{}` binds both `prefab` and `prefab_pool`; bind exactly one",
1463                    a.id
1464                ),
1465            )),
1466            (None, None) => d.push(Diagnostic::error(
1467                PREFAB_BINDING,
1468                "world",
1469                format!("/content/areas/{i}"),
1470                format!(
1471                    "area `{}` binds neither `prefab` nor `prefab_pool`; bind exactly one",
1472                    a.id
1473                ),
1474            )),
1475            _ => {}
1476        }
1477        // A bound PIECE must resolve against the prefab-metadata surface, on
1478        // exactly the terms the pool arm below already demands. The asymmetry
1479        // this replaces was not a missing message — it was a missing message
1480        // that TOOK A PROOF WITH IT. An area whose prefab the registry does not
1481        // hold contributes no set to [`AnchorProviders`], and every per-area
1482        // anchor check reads a missing set as *defer to the compiler* and
1483        // skips. So one mistyped character in `world.json` turned seven
1484        // `DW0142` refusals into silence on the gallery, and left the campaign
1485        // green in a way that is strictly less checked than a correct name —
1486        // the unbound vacuity mode, one keystroke away.
1487        //
1488        // `has_prefab` is asked rather than `anchors_for` because only the
1489        // first distinguishes *the library does not hold this* from *this
1490        // registry cannot say*: a subset registry answers `None` and nothing is
1491        // refused on its word.
1492        if let Some(prefab) = &a.prefab
1493            && prefab.is_valid_syntax()
1494            && anchors.has_prefab(prefab) == Some(false)
1495        {
1496            d.push(Diagnostic::error(
1497                PREFAB_UNKNOWN,
1498                "world",
1499                format!("/content/areas/{i}/prefab"),
1500                format!(
1501                    "area `{}` binds `prefab` `{prefab}`, which is not declared in the prefab \
1502                     metadata — bind a piece that exists in the prefabs dir, or add `{prefab}` \
1503                     to the prefab library. This is a prefab-library/naming issue, not a \
1504                     quest-logic one. It is refused rather than deferred because an area whose \
1505                     piece is absent declares NO anchors, so every anchor a quest in this area \
1506                     names would be accepted without being examined — a misspelling here \
1507                     switches the anchor proof (`DW0142`) off for the whole area instead of \
1508                     failing it",
1509                    a.id
1510                ),
1511            ));
1512        }
1513        // A bound pool must resolve against the prefab-metadata surface.
1514        if let Some(pool) = &a.prefab_pool
1515            && pool.is_valid_syntax()
1516            && !anchors.has_pool(pool)
1517        {
1518            d.push(Diagnostic::error(
1519                POOL_UNKNOWN,
1520                "world",
1521                format!("/content/areas/{i}/prefab_pool"),
1522                format!(
1523                    "area `prefab_pool` `{pool}` is not declared in the prefab metadata — bind a \
1524                     pool that exists in the prefabs dir, or add `{pool}` to the prefab library. \
1525                     This is a prefab-library/naming issue, not a quest-logic one"
1526                ),
1527            ));
1528        }
1529    }
1530}
1531
1532/// **Every area id this campaign declares** — `world.areas[]`, plus the one
1533/// place a site plan lays out ([`crate::siteplan::SITE_AREA`]).
1534///
1535/// A site-plan campaign has no `areas[]` — `DW0839` refuses one that does —
1536/// and exactly one place instead: the site the plan lays out. NPCs and
1537/// planned quests name it like any other area, so it is a declared area id
1538/// here for the same reason `areas[]` entries are.
1539pub(crate) fn declared_area_ids(c: &Campaign) -> BTreeSet<&str> {
1540    let mut area_ids: BTreeSet<&str> = c
1541        .world
1542        .content
1543        .areas
1544        .iter()
1545        .map(|a| a.id.as_str())
1546        .collect();
1547    if c.site_plan.is_some() {
1548        area_ids.insert(crate::siteplan::SITE_AREA);
1549    }
1550    area_ids
1551}
1552
1553/// `DW0110` over the world's ids: each area's id and the prefab or pool it
1554/// binds, and each atmosphere's id.
1555pub(crate) fn world_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
1556    for (i, a) in c.world.content.areas.iter().enumerate() {
1557        crate::ids::id_syntax!(d, a.id, "world", format!("/content/areas/{i}/id"));
1558        if let Some(prefab) = &a.prefab {
1559            crate::ids::id_syntax!(d, prefab, "world", format!("/content/areas/{i}/prefab"));
1560        }
1561        if let Some(pool) = &a.prefab_pool {
1562            crate::ids::id_syntax!(d, pool, "world", format!("/content/areas/{i}/prefab_pool"));
1563        }
1564    }
1565    for (i, a) in c.world.content.atmospheres.iter().enumerate() {
1566        crate::ids::id_syntax!(d, a.id, "world", format!("/content/atmospheres/{i}/id"));
1567    }
1568}
1569
1570/// `DW0111` over the area ids.
1571pub(crate) fn world_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
1572    crate::ids::dup_check(
1573        c.world
1574            .content
1575            .areas
1576            .iter()
1577            .enumerate()
1578            .map(|(i, a)| (a.id.as_str(), format!("/content/areas/{i}/id"))),
1579        "world",
1580        "area",
1581        d,
1582    );
1583}
1584
1585/// Every id a `place` reference may name (spec-0080, spec-0102): each area of
1586/// `world.areas[]` and each site-plan box's `node/…`. The one set a
1587/// [`crate::PlaceRef`]'s `place` resolves against.
1588pub(crate) fn place_ids(c: &Campaign) -> BTreeSet<&str> {
1589    let mut out: BTreeSet<&str> = c
1590        .world
1591        .content
1592        .areas
1593        .iter()
1594        .map(|a| a.id.as_str())
1595        .collect();
1596    if let Some(sp) = &c.site_plan {
1597        out.extend(sp.content.boxes.iter().map(|b| b.node.as_str()));
1598    }
1599    out
1600}
1601
1602/// `DW0112` over every atmosphere reference (spec-0080): an atmosphere is named
1603/// by a place for its first tick and by a `set-atmosphere` for a repaint, and a
1604/// repaint's `place` names an area or a site-plan box. Each is the plain
1605/// unresolved-reference shape.
1606pub(crate) fn atmosphere_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
1607    use crate::ids::dangling;
1608    let atmosphere_ids: BTreeSet<&str> = c
1609        .world
1610        .content
1611        .atmospheres
1612        .iter()
1613        .map(|a| a.id.as_str())
1614        .collect();
1615    let atmosphere_remedy = |id: &str| {
1616        format!(
1617            "unknown atmosphere `{id}` — declare it in `world.atmospheres[]` or correct the \
1618             reference"
1619        )
1620    };
1621    for (i, a) in c.world.content.areas.iter().enumerate() {
1622        if let Some(id) = &a.atmosphere {
1623            dangling(
1624                d,
1625                atmosphere_ids.contains(id.as_str()),
1626                "world",
1627                format!("/content/areas/{i}/atmosphere"),
1628                atmosphere_remedy(id.as_str()),
1629            );
1630        }
1631    }
1632    let place_ids = place_ids(c);
1633    if let Some(sp) = &c.site_plan {
1634        for (i, b) in sp.content.boxes.iter().enumerate() {
1635            if let Some(id) = &b.atmosphere {
1636                dangling(
1637                    d,
1638                    atmosphere_ids.contains(id.as_str()),
1639                    "site-plan",
1640                    format!("/content/boxes/{i}/atmosphere"),
1641                    atmosphere_remedy(id.as_str()),
1642                );
1643            }
1644        }
1645    }
1646    crate::for_each_campaign_effect(c, &mut |path, site, e| {
1647        let crate::Verb::SetAtmosphere {
1648            atmosphere,
1649            at: crate::PlaceRef { place, .. },
1650        } = &e.verb
1651        else {
1652            return;
1653        };
1654        let stage = match site {
1655            crate::EffectSite::DialogueRespawn { .. } => "dialogue",
1656            _ => "quests",
1657        };
1658        if let Some(id) = atmosphere {
1659            dangling(
1660                d,
1661                atmosphere_ids.contains(id.as_str()),
1662                stage,
1663                format!("{path}/atmosphere"),
1664                atmosphere_remedy(id.as_str()),
1665            );
1666        }
1667        if let Some(place) = place {
1668            dangling(
1669                d,
1670                place_ids.contains(place.as_str()),
1671                stage,
1672                format!("{path}/place"),
1673                format!(
1674                    "`set-atmosphere` repaints unknown place `{place}` — name an `area/…` from \
1675                     `world.areas[]` or a site-plan box's `node/…`"
1676                ),
1677            );
1678        }
1679    });
1680}