Skip to main content

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                                &regions,
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                                &regions,
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                                    &regions,
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                                &regions,
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                                    &regions,
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, &regions, 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, &regions, 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, &regions, format!("{epath}/region"), region);
863                }
864                WorldEdit::Morph { region, op } => {
865                    check_region_ref(d, stage, &regions, 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, &regions, format!("{epath}/region"), region);
916                    for (i, r) in avoid.iter().enumerate() {
917                        check_region_ref(d, stage, &regions, 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, &regions, format!("{epath}/region"), region);
982                    for (i, r) in avoid.iter().enumerate() {
983                        check_region_ref(d, stage, &regions, 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, &regions, 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}