delvewright_dsl/world_edits.rs
1//! Stage 7 — world edits (the map editor's edit script, DSL v0.6, spec-0017).
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{AnchorId, AreaId, EditBatchId, Fixture, PrefabId, RegionId};
7
8/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
9///
10/// The artifact of record for L3 world detailing: an ordered list of edit
11/// batches the compiler replays deterministically **after** world assembly.
12/// The world files are never truth — same DSL + same edits + same seed →
13/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
14/// builds byte-identically to one from before this stage existed.
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct WorldEditsContent {
18 /// Ordered edit batches, replayed in order. After every batch the post-edit
19 /// invariants re-prove (walkability, sealing + relight, boundary safety),
20 /// so each batch is a valid, snapshot-reviewable world state.
21 pub batches: Vec<EditBatch>,
22}
23
24/// One edit batch: an ordered group of edit verbs applied to a single area,
25/// checked and snapshot-rendered as a unit.
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
27#[serde(deny_unknown_fields)]
28pub struct EditBatch {
29 /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
30 /// snapshot name and seed-stream label — renaming a batch deliberately
31 /// reseeds its noise.
32 pub id: EditBatchId,
33 /// The stage-1 area this batch edits. Frames and regions resolve against
34 /// this area's placed pieces and anchors.
35 pub area: AreaId,
36 /// Authoring context (why this batch exists). Machine-ignored; **excluded**
37 /// from l10n like `theme`/`premise`.
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub note: Option<String>,
40 /// Ordered edit verbs. `select` defines named regions; later verbs in the
41 /// same batch refer back to them (strictly backward, like every DSL ref).
42 pub edits: Vec<WorldEdit>,
43}
44
45/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
46/// seeded verb derives its noise stream from the campaign seed + its script
47/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
49#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
50pub enum WorldEdit {
51 /// Define a named region (`region/<kebab>`) for later verbs in this batch.
52 Select {
53 /// The region name being defined (unique within the batch).
54 name: RegionId,
55 /// The region's shape (box, surface band, palette match, or a
56 /// composition of earlier regions).
57 shape: RegionShape,
58 },
59 /// Fill every cell of a region from a seeded palette recipe (value-noise
60 /// keyed, so picks cluster into patches — never a uniform fill).
61 Fill {
62 /// The region (an earlier `select` in this batch) to fill.
63 region: RegionId,
64 /// The palette recipe to fill with.
65 recipe: PaletteRecipe,
66 },
67 /// Like `fill`, but only rewrites cells whose current block matches one of
68 /// `matching` (base ids; blockstate suffixes are ignored when matching).
69 Replace {
70 /// The region (an earlier `select` in this batch) to edit.
71 region: RegionId,
72 /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
73 matching: Vec<String>,
74 /// The palette recipe to rewrite them with.
75 recipe: PaletteRecipe,
76 },
77 /// Clear a region to air. Sealing-aware: the carved region re-enters the
78 /// sealing + relight passes and every walkability invariant re-proves.
79 Carve {
80 /// The region (an earlier `select` in this batch) to clear.
81 region: RegionId,
82 },
83 /// Reshape terrain surface within a region (raise / lower / smooth).
84 Morph {
85 /// The region (an earlier `select` in this batch) whose columns to
86 /// reshape. The region defines the footprint and where each column's
87 /// surface is read (the highest occupied cell in the region's y-range);
88 /// `raise`/`smooth` may add cells above the region's top — reshaping
89 /// upward is the point — while removal only touches region cells.
90 region: RegionId,
91 /// The surface operation.
92 op: MorphOp,
93 },
94 /// Seeded dressing scatter (spec-0017): drop weighted single-block
95 /// dressing (flora, rocks, props) onto standable cells of a region —
96 /// air cells with an occupied cell directly below — honoring keep-clear
97 /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
98 /// speckle, not the fill verbs' clustered patches), deterministic from the
99 /// campaign seed + script position.
100 Scatter {
101 /// The region (an earlier `select` in this batch) to dress.
102 region: RegionId,
103 /// Weighted dressing blocks (blockstate suffixes allowed).
104 items: Vec<PaletteBlock>,
105 /// Per-candidate placement probability in `(0, 1]`.
106 density: f64,
107 /// Keep-clear envelopes: earlier regions whose cells (and columns —
108 /// matched by `(x, z)`) never receive dressing.
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 avoid: Vec<RegionId>,
111 /// Optional minimum spacing: when set, candidates are taken in
112 /// descending noise order and one is rejected while another accepted
113 /// candidate is closer than this on **both** horizontal axes (the
114 /// generators' spread rule).
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 spacing: Option<u32>,
117 /// Optional cap on how many items are placed (highest-noise first).
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 limit: Option<u32>,
120 },
121 /// Structural flora (spec-0017): plant hand-shaped trees via the
122 /// lean-or-grow canopy rules — a canopy that would reach a
123 /// keep-clear (`avoid`) column first leans one block away from it; if that
124 /// still covers the corridor the tree grows tall instead, arching its
125 /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
126 Plant {
127 /// The region (an earlier `select` in this batch) to plant in. Trunk
128 /// cells are standable region cells (air over an occupied cell).
129 region: RegionId,
130 /// The tree species (canopy shape rules are per-species).
131 tree: TreeKind,
132 /// How many trees to plant (≥ 1; highest-noise candidates first).
133 count: u32,
134 /// Keep-clear envelopes: trunks never stand in these columns and
135 /// canopies lean/grow to clear them.
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 avoid: Vec<RegionId>,
138 /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 spacing: Option<u32>,
141 },
142 /// Stamp a prefab fragment (spec-0017): copy a library prefab's
143 /// non-air cells into the world at a frame-resolved position. The fragment
144 /// is a first-class library prefab — its provenance/license metadata is
145 /// recorded and validated exactly like any placed prefab (ADR-0013);
146 /// nothing outside the library can be stamped.
147 Fragment {
148 /// The library prefab to stamp.
149 prefab: PrefabId,
150 /// The frame `at` resolves in.
151 frame: EditFrame,
152 /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
153 at: [i32; 3],
154 /// Placement rotation (default `none`), the same quarter-turn set as
155 /// `/place template`.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 rotation: Option<FragmentRotation>,
158 },
159 /// Explicit region relight (spec-0017, spec-0010 machinery): run the
160 /// deterministic fixture-placement pass over ONE region and bake the
161 /// resulting fixtures into the edit script's writes — authorial control of
162 /// where fixtures land, instead of the whole-area pass's greedy siting.
163 /// (The whole-area relight still re-proves after every batch either way.)
164 Relight {
165 /// The region (an earlier `select` in this batch) to relight: its
166 /// reachable walkable cells are brought to `min_light`.
167 region: RegionId,
168 /// Fixture override; default = the area's declared `lighting.fixture`.
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 fixture: Option<Fixture>,
171 /// Target light override (1..=14); default = the area's declared
172 /// `lighting.min_light`. Required (with `fixture`) when the area
173 /// declares no `lighting`.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 min_light: Option<u8>,
176 },
177 /// L2 massing (spec-0017): replace a placed piece with another
178 /// library prefab that re-mates every currently-mated socket at its exact
179 /// world pose (any rotation; overlap-checked). Applied at **plan** time —
180 /// the whole downstream ladder (anchors, gate reachability, assembly,
181 /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
182 /// verbs live in massing-only batches, ordered before every detailing
183 /// batch.
184 SwapPiece {
185 /// The piece's placement index in its area's solved layout (0-based,
186 /// entry first).
187 piece: u32,
188 /// The prefab the indexed piece must currently be (drift guard).
189 prefab: PrefabId,
190 /// The library prefab to swap in.
191 with: PrefabId,
192 },
193 /// L2 massing (spec-0017): attach a new piece at a specific **open**
194 /// (unmated) socket of an existing piece — the targeted form of the
195 /// solver's frontier attach. The socket opens (its seal becomes a
196 /// passage); the new piece's other sockets seal.
197 InsertPiece {
198 /// The host piece's placement index.
199 at_piece: u32,
200 /// The prefab the host piece must currently be (drift guard).
201 prefab: PrefabId,
202 /// The host's connector index (prefab metadata `connectors` order).
203 socket: u32,
204 /// The library prefab to attach.
205 insert: PrefabId,
206 },
207 /// L2 massing (spec-0017): remove a **leaf** piece (exactly one
208 /// mated socket; never the entry piece). The neighbour's socket unmates
209 /// and re-seals. Removal shifts later placement indices — order removals
210 /// before other index-referencing massing verbs.
211 RemovePiece {
212 /// The piece's placement index.
213 piece: u32,
214 /// The prefab the indexed piece must currently be (drift guard).
215 prefab: PrefabId,
216 },
217 /// L2 massing (spec-0017): override one socket's seal — `open`
218 /// clears the opening to a passage, `sealed` walls it up — independent of
219 /// its mated state (sealing a mated doorway makes a wall between joined
220 /// pieces; opening an unmated exterior socket exposes the outside, which
221 /// the boundary-safety proof then judges).
222 RewireSocket {
223 /// The piece's placement index.
224 piece: u32,
225 /// The prefab the indexed piece must currently be (drift guard).
226 prefab: PrefabId,
227 /// The connector index (prefab metadata `connectors` order).
228 socket: u32,
229 /// The socket's new state.
230 state: SocketState,
231 },
232 /// L2 massing (spec-0017): re-pick this piece from its area pool's
233 /// compatible members (weighted, seeded from the campaign seed + this
234 /// verb's script position — moving the verb deliberately re-rolls). The
235 /// current prefab is excluded, so a reseed always changes the piece or
236 /// errors loudly.
237 ReseedPiece {
238 /// The piece's placement index.
239 piece: u32,
240 /// The prefab the indexed piece must currently be (drift guard).
241 prefab: PrefabId,
242 },
243}
244
245/// A socket seal state for `rewire-socket` (spec-0017).
246#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
247#[serde(rename_all = "kebab-case")]
248pub enum SocketState {
249 /// The opening is cleared to a passage.
250 Open,
251 /// The opening is walled up.
252 Sealed,
253}
254
255/// A tree species for the `plant` verb (spec-0017). One species per
256/// canopy-rule implementation; the shipped rule set is the lean-or-grow oak.
257#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
258#[serde(rename_all = "kebab-case")]
259pub enum TreeKind {
260 /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
261 /// lean-or-grow corridor rules.
262 Oak,
263}
264
265/// A `fragment` stamp rotation (spec-0017) — the `/place template`
266/// quarter-turn set.
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
268#[serde(rename_all = "kebab-case")]
269pub enum FragmentRotation {
270 /// No rotation.
271 None,
272 /// 90° clockwise.
273 Clockwise90,
274 /// 180°.
275 Clockwise180,
276 /// 90° counterclockwise.
277 Counterclockwise90,
278}
279
280/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
281/// frame; compositions combine earlier regions of the same batch.
282#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
283#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
284pub enum RegionShape {
285 /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
286 Box {
287 /// The coordinate frame `min`/`max` resolve in.
288 frame: EditFrame,
289 /// Inclusive minimum corner (frame coordinates).
290 min: [i32; 3],
291 /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
292 max: [i32; 3],
293 },
294 /// The band of cells at `from..=to` blocks relative to each column's
295 /// terrain surface (the highest non-air cell of the column) within an
296 /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
297 /// surface; `from: 0, to: 0` is the surface cells themselves; negative
298 /// offsets reach below the surface.
299 SurfaceBand {
300 /// The earlier region whose columns are scanned.
301 over: RegionId,
302 /// Inclusive band start, relative to each column's surface y.
303 from: i32,
304 /// Inclusive band end (≥ `from`), relative to each column's surface y.
305 to: i32,
306 },
307 /// The cells of an earlier region whose current block matches one of
308 /// `blocks` (base ids; blockstate suffixes ignored when matching).
309 PaletteMatch {
310 /// The earlier region to filter.
311 within: RegionId,
312 /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
313 blocks: Vec<String>,
314 },
315 /// The union of earlier regions.
316 Union {
317 /// The earlier regions to unite (≥ 2).
318 of: Vec<RegionId>,
319 },
320 /// The intersection of earlier regions.
321 Intersect {
322 /// The earlier regions to intersect (≥ 2).
323 of: Vec<RegionId>,
324 },
325 /// An earlier region minus other earlier regions.
326 Subtract {
327 /// The earlier region to start from.
328 base: RegionId,
329 /// The earlier regions to remove from it (≥ 1).
330 remove: Vec<RegionId>,
331 },
332}
333
334/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
335/// piece-local or anchor-relative — never raw world coordinates, so an edit
336/// script survives a layout's world placement moving.
337#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
338#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
339pub enum EditFrame {
340 /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
341 /// the piece's structure origin, axes as authored (the compiler applies
342 /// the piece's placed rotation).
343 PieceLocal {
344 /// The piece's placement index in the area's solved layout (0-based,
345 /// entry piece first — the order `delvec snapshot`'s manifest lists).
346 piece: u32,
347 /// The prefab the indexed piece must be (a drift guard: if a re-solve
348 /// changed the layout, the mismatch is a loud compile error, never a
349 /// silently misplaced edit).
350 prefab: PrefabId,
351 },
352 /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
353 /// anchor cell, axes world-aligned.
354 AnchorRelative {
355 /// The anchor (prefab metadata, resolved by the compiler).
356 anchor: AnchorId,
357 },
358}
359
360/// A surface operation for the `morph` verb (spec-0017).
361#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
362#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
363pub enum MorphOp {
364 /// Raise each column's surface by up to `by` blocks, drawing the added
365 /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
366 /// as natural strata, not an extruded slab).
367 Raise {
368 /// How many blocks to raise each column (≥ 1).
369 by: u32,
370 /// The palette recipe for the added cells.
371 recipe: PaletteRecipe,
372 },
373 /// Lower each column's surface by up to `by` blocks (carving the topmost
374 /// solid cells to air).
375 Lower {
376 /// How many blocks to lower each column (≥ 1).
377 by: u32,
378 },
379 /// Relax each column's surface toward the mean of its cardinal neighbours
380 /// (one block per pass), turning steps into slopes. Added cells draw from
381 /// `recipe`; removed cells carve to air. Deterministic double-buffered
382 /// passes in fixed scan order.
383 Smooth {
384 /// Relaxation passes (≥ 1).
385 passes: u32,
386 /// The palette recipe for cells a pass adds.
387 recipe: PaletteRecipe,
388 },
389}
390
391/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
392/// smooth value-noise sample, the island/cave generators' proven primitive —
393/// picks cluster into strata/patches instead of per-cell speckle, and a
394/// single-entry recipe is the degenerate (discouraged) uniform case.
395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
396#[serde(deny_unknown_fields)]
397pub struct PaletteRecipe {
398 /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
399 pub blocks: Vec<PaletteBlock>,
400 /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
401 /// across). Larger = smaller patches. Must be finite and > 0.
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub scale: Option<f64>,
404}
405
406/// One weighted entry of a [`PaletteRecipe`].
407#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
408#[serde(deny_unknown_fields)]
409pub struct PaletteBlock {
410 /// Vanilla block id (validated against the pinned 1.21.11 registry), with
411 /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
412 pub block: String,
413 /// Relative weight (finite, > 0).
414 pub weight: f64,
415}
416
417// ---------------------------------------------------------------------------
418// Validation
419// ---------------------------------------------------------------------------
420
421use std::collections::BTreeSet;
422
423use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
424use crate::envelope::{Campaign, Stage};
425use crate::registry::BlockRegistry;
426use crate::validate::split_blockstate;
427
428crate::dw_code! {
429 /// (v0.6, spec-0017) A stage-7 edit script is structurally invalid: an edit
430 /// names a region no earlier `select` in its batch defined, a composition
431 /// (`union`/`intersect`/`subtract`) lists too few regions, a box `min`
432 /// exceeds `max` on an axis, a surface band's `from` exceeds `to`, a palette
433 /// recipe is empty / carries a non-positive or non-finite weight or `scale`,
434 /// a `matching` list is empty, or a morph `by`/`passes` is 0. (Unknown block
435 /// ids in recipes reuse [`BLOCK_UNKNOWN`] / `DW0193`; id-syntax and
436 /// duplicate-name violations reuse `DW0110`/`DW0111`.)
437 pub const EDIT_INVALID: DwCode = DwCode::new("DW0162", ExitTier::Build);
438}
439
440/// Structural validation of the stage-7 edit script: id syntax/uniqueness
441/// (`DW0110`/`DW0111`), area refs (`DW0112`), strictly-backward region refs and
442/// shape/recipe well-formedness (`DW0162`) and block ids (`DW0193`).
443/// Frame/region *resolution* against the solved layout
444/// is the compiler's job (`DW0323`) — validation never needs prefabs.
445pub(crate) fn world_edits_checks(
446 c: &Campaign,
447 blocks: &dyn BlockRegistry,
448 d: &mut Vec<Diagnostic>,
449) {
450 let Some(env) = &c.world_edits else {
451 return;
452 };
453 let stage = Stage::WorldEdits.name();
454
455 let mut areas: BTreeSet<&str> = c
456 .world
457 .content
458 .areas
459 .iter()
460 .map(|a| a.id.as_str())
461 .collect();
462 // A site-plan campaign has no `areas[]` — `DW0839` refuses one that does —
463 // and exactly one place instead: the site the plan lays out. A batch names
464 // it like any other area, so it is a declared area id here for the same
465 // reason `areas[]` entries are.
466 //
467 // One of two area-id sets (the other is [`crate::world::declared_area_ids`]),
468 // and the only one that used to omit this. The pair it made was unsatisfiable: `DW0839` REQUIRES a
469 // site-plan campaign to declare no `areas[]`, and every batch of a stage-7
470 // edit script was then checked against a set that could only be empty. So no
471 // site-plan campaign could carry an edit script at all, and the repair the
472 // message prescribes — use one of the world stage's area ids — names a set
473 // the other rule guarantees is empty. Each gate was right on its own terms;
474 // the union had no green state. What it cost is every build-tier check a
475 // stage-7 script is the only route to: content could not reach them from a
476 // site-plan campaign at all.
477 if c.site_plan.is_some() {
478 areas.insert(crate::siteplan::SITE_AREA);
479 }
480
481 // Small helpers, each pushing at most one diagnostic.
482 fn bad_syntax(d: &mut Vec<Diagnostic>, stage: &str, path: String, what: &str, id: &str) {
483 d.push(Diagnostic::error(
484 codes::ID_SYNTAX,
485 stage,
486 path,
487 format!(
488 "malformed {what} id `{id}` (expected `{}`)",
489 what_pattern(what)
490 ),
491 ));
492 }
493 fn what_pattern(what: &str) -> String {
494 format!("{what}/<kebab>")
495 }
496 fn check_region_ref(
497 d: &mut Vec<Diagnostic>,
498 stage: &str,
499 regions: &BTreeSet<&str>,
500 path: String,
501 r: &crate::ids::RegionId,
502 ) {
503 if !r.is_valid_syntax() {
504 bad_syntax(d, stage, path, "region", r.as_str());
505 } else if !regions.contains(r.as_str()) {
506 d.push(Diagnostic::error(
507 EDIT_INVALID,
508 stage,
509 path,
510 format!(
511 "region `{r}` is not defined by an earlier `select` in this batch — every \
512 region reference is strictly backward within its batch; add a `select` verb \
513 naming `{r}` above this edit (or fix the name)"
514 ),
515 ));
516 }
517 }
518 fn check_recipe(
519 d: &mut Vec<Diagnostic>,
520 stage: &str,
521 blocks: &dyn BlockRegistry,
522 path: &str,
523 recipe: &crate::PaletteRecipe,
524 ) {
525 if recipe.blocks.is_empty() {
526 d.push(Diagnostic::error(
527 EDIT_INVALID,
528 stage,
529 format!("{path}/blocks"),
530 "palette recipe has no entries — give it at least one weighted block (and \
531 prefer ≥ 2 so the seeded noise reads as natural variation, never a uniform \
532 fill)"
533 .to_string(),
534 ));
535 }
536 for (i, b) in recipe.blocks.iter().enumerate() {
537 if !(b.weight.is_finite() && b.weight > 0.0) {
538 d.push(Diagnostic::error(
539 EDIT_INVALID,
540 stage,
541 format!("{path}/blocks/{i}/weight"),
542 format!(
543 "palette weight `{}` for `{}` must be a finite number > 0",
544 b.weight, b.block
545 ),
546 ));
547 }
548 check_edit_block(
549 d,
550 stage,
551 blocks,
552 format!("{path}/blocks/{i}/block"),
553 &b.block,
554 );
555 }
556 if let Some(scale) = recipe.scale
557 && !(scale.is_finite() && scale > 0.0)
558 {
559 d.push(Diagnostic::error(
560 EDIT_INVALID,
561 stage,
562 format!("{path}/scale"),
563 format!("recipe `scale` `{scale}` must be a finite number > 0 (blocks⁻¹)"),
564 ));
565 }
566 }
567 fn check_edit_block(
568 d: &mut Vec<Diagnostic>,
569 stage: &str,
570 blocks: &dyn BlockRegistry,
571 path: String,
572 block: &str,
573 ) {
574 match split_blockstate(block) {
575 Ok(base) => {
576 if !blocks.contains(base) {
577 d.push(Diagnostic::error(
578 codes::BLOCK_UNKNOWN,
579 stage,
580 path,
581 format!(
582 "block `{block}` is not a known 1.21.11 block id — use a valid \
583 namespaced block id (e.g. `minecraft:mossy_stone_bricks`)"
584 ),
585 ));
586 }
587 }
588 Err(reason) => {
589 d.push(Diagnostic::error(codes::BLOCK_UNKNOWN, stage, path, reason));
590 }
591 }
592 }
593
594 // A verb's phase: L2 massing (applied at plan time, over the jigsaw
595 // layout) vs L3 detailing (applied at replay time, over the assembled
596 // blocks). A batch never mixes phases, and every massing batch precedes
597 // every detailing batch — the replay applies all massing first by
598 // construction, so an interleaved script would misrepresent its own order.
599 fn is_massing(edit: &WorldEdit) -> bool {
600 matches!(
601 edit,
602 WorldEdit::SwapPiece { .. }
603 | WorldEdit::InsertPiece { .. }
604 | WorldEdit::RemovePiece { .. }
605 | WorldEdit::RewireSocket { .. }
606 | WorldEdit::ReseedPiece { .. }
607 )
608 }
609
610 let mut batch_ids: BTreeSet<&str> = BTreeSet::new();
611 let mut seen_detailing = false;
612 for (bi, batch) in env.content.batches.iter().enumerate() {
613 let bpath = format!("/batches/{bi}");
614 let massing_count = batch.edits.iter().filter(|e| is_massing(e)).count();
615 if massing_count > 0 && massing_count < batch.edits.len() {
616 d.push(Diagnostic::error(
617 EDIT_INVALID,
618 stage,
619 format!("{bpath}/edits"),
620 format!(
621 "batch `{}` mixes L2 massing and L3 detailing verbs — massing applies at \
622 plan time (before assembly), detailing at replay time, so a mixed batch \
623 cannot execute in its written order. Split it into a massing batch and a \
624 detailing batch",
625 batch.id
626 ),
627 ));
628 }
629 if massing_count > 0 && seen_detailing {
630 d.push(Diagnostic::error(
631 EDIT_INVALID,
632 stage,
633 bpath.to_string(),
634 format!(
635 "massing batch `{}` follows a detailing batch — every massing batch must \
636 precede every detailing batch (massing reshapes the layout the detailing \
637 verbs' frames resolve against). Move it up the script",
638 batch.id
639 ),
640 ));
641 }
642 if massing_count == 0 && !batch.edits.is_empty() {
643 seen_detailing = true;
644 }
645 if !batch.id.is_valid_syntax() {
646 bad_syntax(d, stage, format!("{bpath}/id"), "batch", batch.id.as_str());
647 } else if !batch_ids.insert(batch.id.as_str()) {
648 d.push(Diagnostic::error(
649 codes::ID_DUPLICATE,
650 stage,
651 format!("{bpath}/id"),
652 format!(
653 "duplicate batch id `{}` — batch ids are unique across the edit script \
654 (they name snapshots and seed streams)",
655 batch.id
656 ),
657 ));
658 }
659 if !areas.contains(batch.area.as_str()) {
660 d.push(Diagnostic::error(
661 codes::DANGLING_REF,
662 stage,
663 format!("{bpath}/area"),
664 format!(
665 "batch `{}` targets area `{}` which this campaign does not declare — {}",
666 batch.id,
667 batch.area,
668 crate::placement::Placement::of(c).area_remedy(),
669 ),
670 ));
671 }
672
673 // Regions defined so far in THIS batch (strictly backward references).
674 let mut regions: BTreeSet<&str> = BTreeSet::new();
675 for (ei, edit) in batch.edits.iter().enumerate() {
676 let epath = format!("{bpath}/edits/{ei}");
677 match edit {
678 WorldEdit::Select { name, shape } => {
679 match shape {
680 RegionShape::Box { frame, min, max } => {
681 if min.iter().zip(max).any(|(lo, hi)| lo > hi) {
682 d.push(Diagnostic::error(
683 EDIT_INVALID,
684 stage,
685 format!("{epath}/shape"),
686 format!(
687 "box region `{name}` has min {min:?} > max {max:?} on \
688 an axis — corners are inclusive with min ≤ max per axis"
689 ),
690 ));
691 }
692 match frame {
693 EditFrame::PieceLocal { prefab, .. } => {
694 if !prefab.is_valid_syntax() {
695 bad_syntax(
696 d,
697 stage,
698 format!("{epath}/shape/frame/prefab"),
699 "prefab",
700 prefab.as_str(),
701 );
702 }
703 }
704 EditFrame::AnchorRelative { anchor } => {
705 if !anchor.is_valid_syntax() {
706 bad_syntax(
707 d,
708 stage,
709 format!("{epath}/shape/frame/anchor"),
710 "anchor",
711 anchor.as_str(),
712 );
713 }
714 }
715 }
716 }
717 RegionShape::SurfaceBand { over, from, to } => {
718 check_region_ref(
719 d,
720 stage,
721 ®ions,
722 format!("{epath}/shape/over"),
723 over,
724 );
725 if from > to {
726 d.push(Diagnostic::error(
727 EDIT_INVALID,
728 stage,
729 format!("{epath}/shape"),
730 format!(
731 "surface band `{name}` has from {from} > to {to} — the \
732 band is inclusive with from ≤ to (offsets relative to \
733 each column's surface)"
734 ),
735 ));
736 }
737 }
738 RegionShape::PaletteMatch { within, blocks: bl } => {
739 check_region_ref(
740 d,
741 stage,
742 ®ions,
743 format!("{epath}/shape/within"),
744 within,
745 );
746 if bl.is_empty() {
747 d.push(Diagnostic::error(
748 EDIT_INVALID,
749 stage,
750 format!("{epath}/shape/blocks"),
751 format!(
752 "palette-match region `{name}` lists no blocks — name \
753 at least one base block id to match"
754 ),
755 ));
756 }
757 for (i, b) in bl.iter().enumerate() {
758 check_edit_block(
759 d,
760 stage,
761 blocks,
762 format!("{epath}/shape/blocks/{i}"),
763 b,
764 );
765 }
766 }
767 RegionShape::Union { of } | RegionShape::Intersect { of } => {
768 if of.len() < 2 {
769 d.push(Diagnostic::error(
770 EDIT_INVALID,
771 stage,
772 format!("{epath}/shape/of"),
773 format!(
774 "composition region `{name}` lists {} region(s) — a \
775 union/intersection needs at least 2 (a single-region \
776 composition is just the region; use it directly)",
777 of.len()
778 ),
779 ));
780 }
781 for (i, r) in of.iter().enumerate() {
782 check_region_ref(
783 d,
784 stage,
785 ®ions,
786 format!("{epath}/shape/of/{i}"),
787 r,
788 );
789 }
790 }
791 RegionShape::Subtract { base, remove } => {
792 check_region_ref(
793 d,
794 stage,
795 ®ions,
796 format!("{epath}/shape/base"),
797 base,
798 );
799 if remove.is_empty() {
800 d.push(Diagnostic::error(
801 EDIT_INVALID,
802 stage,
803 format!("{epath}/shape/remove"),
804 format!(
805 "subtract region `{name}` removes nothing — list at \
806 least one region to subtract (or use `base` directly)"
807 ),
808 ));
809 }
810 for (i, r) in remove.iter().enumerate() {
811 check_region_ref(
812 d,
813 stage,
814 ®ions,
815 format!("{epath}/shape/remove/{i}"),
816 r,
817 );
818 }
819 }
820 }
821 if !name.is_valid_syntax() {
822 bad_syntax(d, stage, format!("{epath}/name"), "region", name.as_str());
823 } else if !regions.insert(name.as_str()) {
824 d.push(Diagnostic::error(
825 codes::ID_DUPLICATE,
826 stage,
827 format!("{epath}/name"),
828 format!(
829 "duplicate region name `{name}` in batch `{}` — region names \
830 are unique within their batch",
831 batch.id
832 ),
833 ));
834 }
835 }
836 WorldEdit::Fill { region, recipe } => {
837 check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
838 check_recipe(d, stage, blocks, &format!("{epath}/recipe"), recipe);
839 }
840 WorldEdit::Replace {
841 region,
842 matching,
843 recipe,
844 } => {
845 check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
846 if matching.is_empty() {
847 d.push(Diagnostic::error(
848 EDIT_INVALID,
849 stage,
850 format!("{epath}/matching"),
851 "replace matches no blocks — list at least one base block id to \
852 rewrite (an unconditional rewrite is `fill`)"
853 .to_string(),
854 ));
855 }
856 for (i, b) in matching.iter().enumerate() {
857 check_edit_block(d, stage, blocks, format!("{epath}/matching/{i}"), b);
858 }
859 check_recipe(d, stage, blocks, &format!("{epath}/recipe"), recipe);
860 }
861 WorldEdit::Carve { region } => {
862 check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
863 }
864 WorldEdit::Morph { region, op } => {
865 check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
866 match op {
867 MorphOp::Raise { by, recipe } => {
868 if *by == 0 {
869 d.push(Diagnostic::error(
870 EDIT_INVALID,
871 stage,
872 format!("{epath}/op/by"),
873 "morph raise `by` is 0 — a zero raise is a no-op; give a \
874 positive height (or drop the edit)"
875 .to_string(),
876 ));
877 }
878 check_recipe(d, stage, blocks, &format!("{epath}/op/recipe"), recipe);
879 }
880 MorphOp::Lower { by } => {
881 if *by == 0 {
882 d.push(Diagnostic::error(
883 EDIT_INVALID,
884 stage,
885 format!("{epath}/op/by"),
886 "morph lower `by` is 0 — a zero lower is a no-op; give a \
887 positive depth (or drop the edit)"
888 .to_string(),
889 ));
890 }
891 }
892 MorphOp::Smooth { passes, recipe } => {
893 if *passes == 0 {
894 d.push(Diagnostic::error(
895 EDIT_INVALID,
896 stage,
897 format!("{epath}/op/passes"),
898 "morph smooth `passes` is 0 — a zero-pass smooth is a \
899 no-op; give a positive pass count (or drop the edit)"
900 .to_string(),
901 ));
902 }
903 check_recipe(d, stage, blocks, &format!("{epath}/op/recipe"), recipe);
904 }
905 }
906 }
907 WorldEdit::Scatter {
908 region,
909 items,
910 density,
911 avoid,
912 spacing: _,
913 limit,
914 } => {
915 check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
916 for (i, r) in avoid.iter().enumerate() {
917 check_region_ref(d, stage, ®ions, format!("{epath}/avoid/{i}"), r);
918 }
919 if items.is_empty() {
920 d.push(Diagnostic::error(
921 EDIT_INVALID,
922 stage,
923 format!("{epath}/items"),
924 "scatter has no items — give it at least one weighted dressing \
925 block"
926 .to_string(),
927 ));
928 }
929 for (i, b) in items.iter().enumerate() {
930 if !(b.weight.is_finite() && b.weight > 0.0) {
931 d.push(Diagnostic::error(
932 EDIT_INVALID,
933 stage,
934 format!("{epath}/items/{i}/weight"),
935 format!(
936 "scatter item weight `{}` for `{}` must be a finite \
937 number > 0",
938 b.weight, b.block
939 ),
940 ));
941 }
942 check_edit_block(
943 d,
944 stage,
945 blocks,
946 format!("{epath}/items/{i}/block"),
947 &b.block,
948 );
949 }
950 if !(density.is_finite() && *density > 0.0 && *density <= 1.0) {
951 d.push(Diagnostic::error(
952 EDIT_INVALID,
953 stage,
954 format!("{epath}/density"),
955 format!(
956 "scatter `density` `{density}` must be in (0, 1] — it is the \
957 per-candidate placement probability"
958 ),
959 ));
960 }
961 if let Some(limit) = limit
962 && *limit == 0
963 {
964 d.push(Diagnostic::error(
965 EDIT_INVALID,
966 stage,
967 format!("{epath}/limit"),
968 "scatter `limit` is 0 — a zero-item scatter is a no-op; give a \
969 positive cap (or drop the field for no cap)"
970 .to_string(),
971 ));
972 }
973 }
974 WorldEdit::Plant {
975 region,
976 tree: _,
977 count,
978 avoid,
979 spacing: _,
980 } => {
981 check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
982 for (i, r) in avoid.iter().enumerate() {
983 check_region_ref(d, stage, ®ions, format!("{epath}/avoid/{i}"), r);
984 }
985 if *count == 0 {
986 d.push(Diagnostic::error(
987 EDIT_INVALID,
988 stage,
989 format!("{epath}/count"),
990 "plant `count` is 0 — a zero-tree plant is a no-op; give a \
991 positive count (or drop the edit)"
992 .to_string(),
993 ));
994 }
995 }
996 WorldEdit::Fragment {
997 prefab,
998 frame,
999 at: _,
1000 rotation: _,
1001 } => {
1002 if !prefab.is_valid_syntax() {
1003 bad_syntax(
1004 d,
1005 stage,
1006 format!("{epath}/prefab"),
1007 "prefab",
1008 prefab.as_str(),
1009 );
1010 }
1011 match frame {
1012 EditFrame::PieceLocal { prefab, .. } => {
1013 if !prefab.is_valid_syntax() {
1014 bad_syntax(
1015 d,
1016 stage,
1017 format!("{epath}/frame/prefab"),
1018 "prefab",
1019 prefab.as_str(),
1020 );
1021 }
1022 }
1023 EditFrame::AnchorRelative { anchor } => {
1024 if !anchor.is_valid_syntax() {
1025 bad_syntax(
1026 d,
1027 stage,
1028 format!("{epath}/frame/anchor"),
1029 "anchor",
1030 anchor.as_str(),
1031 );
1032 }
1033 }
1034 }
1035 }
1036 WorldEdit::Relight {
1037 region,
1038 fixture,
1039 min_light,
1040 } => {
1041 check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
1042 if let Some(ml) = min_light
1043 && !(1..=14).contains(ml)
1044 {
1045 d.push(Diagnostic::error(
1046 EDIT_INVALID,
1047 stage,
1048 format!("{epath}/min_light"),
1049 format!(
1050 "relight `min_light` {ml} out of range — vanilla block light \
1051 is 1..=14 (15 is only at the emitter itself)"
1052 ),
1053 ));
1054 }
1055 // Without an area `lighting` declaration the verb has no
1056 // fixture/target to fall back on — both overrides required.
1057 let area_lighting = c
1058 .world
1059 .content
1060 .areas
1061 .iter()
1062 .find(|a| a.id.as_str() == batch.area.as_str())
1063 .and_then(|a| a.lighting);
1064 if area_lighting.is_none() && (fixture.is_none() || min_light.is_none()) {
1065 d.push(Diagnostic::error(
1066 EDIT_INVALID,
1067 stage,
1068 epath.to_string(),
1069 format!(
1070 "relight in batch `{}`: area `{}` declares no `lighting`, so \
1071 the verb must carry BOTH `fixture` and `min_light` (there is \
1072 nothing to default to). Declare area lighting or add the \
1073 overrides",
1074 batch.id, batch.area
1075 ),
1076 ));
1077 }
1078 }
1079 WorldEdit::SwapPiece {
1080 piece: _,
1081 prefab,
1082 with,
1083 } => {
1084 for (what, id) in [("prefab", prefab.as_str()), ("prefab", with.as_str())] {
1085 if !crate::ids::is_prefixed(id, "prefab") {
1086 bad_syntax(d, stage, epath.to_string(), what, id);
1087 }
1088 }
1089 }
1090 WorldEdit::InsertPiece {
1091 at_piece: _,
1092 prefab,
1093 socket: _,
1094 insert,
1095 } => {
1096 for id in [prefab.as_str(), insert.as_str()] {
1097 if !crate::ids::is_prefixed(id, "prefab") {
1098 bad_syntax(d, stage, epath.to_string(), "prefab", id);
1099 }
1100 }
1101 }
1102 WorldEdit::RemovePiece { piece: _, prefab }
1103 | WorldEdit::ReseedPiece { piece: _, prefab }
1104 | WorldEdit::RewireSocket { prefab, .. } => {
1105 if !prefab.is_valid_syntax() {
1106 bad_syntax(
1107 d,
1108 stage,
1109 format!("{epath}/prefab"),
1110 "prefab",
1111 prefab.as_str(),
1112 );
1113 }
1114 }
1115 }
1116 }
1117 }
1118}