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}