Skip to main content

delvewright_dsl/
prefab.rs

1//! The prefab metadata document (`<id>.json`, beside the structure `.nbt`) —
2//! the **one** definition of its shape.
3//!
4//! A prefab is a *pair* of files: a gzip-framed structure template and this
5//! sibling JSON that says what the template is, where its anchors and sockets
6//! are, how lit it is, what it claims about the space inside it, and what
7//! regenerates it. Both halves are produced and consumed by several tools of
8//! several ages — the grammar back end and the hand-written generators write the
9//! pair from scratch, `delvec prefab` reads it and writes it back after every
10//! admission step, `delvec` reads it to plan a world, `delvec render` reads it to
11//! aim a camera — so the document's shape is defined once, here, and every one
12//! of them reads that definition instead of a copy of it.
13//!
14//! # Why the definition lives in the DSL crate
15//!
16//! Not because prefab metadata is DSL surface — it is a library-asset document —
17//! but because this is the only crate every reader can depend on. `delvec` is
18//! published to crates.io and may only depend on published crates, and this crate
19//! is the one it already depends on. The alternative was a copy inside `delvec`,
20//! which is what existed and what this module replaces. The crate already owns
21//! the document's `lighting` block ([`crate::registry::Lighting`], whose field
22//! names are this file's field names) and the anchor surface DSL validation
23//! resolves refs against ([`crate::registry::AnchorRegistry`]), so the document's
24//! remaining blocks join a shape that was already half here.
25//!
26//! # Reading is total, writing preserves
27//!
28//! Every field a producer may legitimately omit is `Option`/`default` and is
29//! omitted (never `null`) on write, so a legacy prefab that predates a field
30//! still loads and a piece that has never been probed does not have to invent a
31//! measurement. Field order is the emission order, and it is the order the
32//! library's checked-in prefabs already use, so a reviewer diffing a generated
33//! piece against a hand-built one sees only values change.
34//!
35//! Keys this version has never heard of are **kept**, in [`PrefabMeta::extra`]
36//! and [`Anchor::extra`], and written back out. That is not politeness to the
37//! future; it is the only behaviour that is neither an outage nor silent data
38//! loss. See the `deny_unknown_fields` note below.
39//!
40//! # `deny_unknown_fields`, decided rather than inherited
41//!
42//! The attribute is right on a document whose reader is also its **owner**: a
43//! campaign stage document is authored against a versioned schema, a typo there
44//! is the bug the attribute exists to catch, and forward compatibility is
45//! handled by the `dsl_version` fence instead. Every stage struct in
46//! [`crate::stages`] keeps it for exactly that reason.
47//!
48//! It is wrong on a **consumer that is not the owner**, which is what every
49//! reader of this document is. Here a new key is not a typo — it is a newer
50//! producer meeting an older reader, which happens on every mixed-version pair
51//! of engine and content library. Refusing turns a forward addition into a hard
52//! failure at the layer with the least context; the compiler's private copy of
53//! this shape did exactly that, and the first grammar-exported prefab carrying a
54//! new key would have failed every campaign build.
55//!
56//! Tolerating alone is not the fix either, because a tool that reads this
57//! document, edits one block and writes it back deletes everything it does not
58//! model — and does so while every test it has passes. That is not
59//! hypothetical: `license.generated_by` was dropped that way once, and
60//! `waterline_y` — a field five shipped island prefabs carry and the
61//! ocean-horizon placement check keys off — was being dropped that way at the
62//! time this module was written.
63//!
64//! So the rule is: **this document's structs neither refuse an unknown key nor
65//! discard it.** They keep it, and the reader that wants to say something about
66//! it says it as a diagnostic (`DW0543`) rather than as a parse failure. The
67//! blocks whose own definition lives elsewhere are the exception and say why at
68//! their field.
69
70use std::collections::BTreeMap;
71use std::path::Path;
72
73use serde::{Deserialize, Serialize};
74
75use crate::registry::Lighting;
76use crate::split::TileSet;
77
78/// The one `.json` in a prefab library that is **not** a prefab document:
79/// the pool declaration (`{"pools": {...}}`), read by the compiler's registry.
80///
81/// Named once because more than one tool walks the library directory — the
82/// registry, `delvec view`'s page builder, `delvec render batch` — and each of
83/// them opens every `.json` it finds. A walker that does not know this name
84/// hands a pool file to [`PrefabMeta::from_json`] and reports it as a malformed
85/// prefab, which is a true statement about the bytes and a wrong one about the
86/// file.
87pub const POOLS_FILE: &str = "pools.json";
88
89/// A prefab's sibling metadata file.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct PrefabMeta {
92    /// The DSL prefab id, `prefab/<id>`.
93    pub prefab_id: String,
94    /// The structure-template reference, for a piece whose blocks fit one
95    /// template.
96    ///
97    /// Exactly one of this and [`Self::structure_set`] is present — see the
98    /// type's own note on the two packagings, and [`Self::from_json`], which is
99    /// where "exactly one" is enforced.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub structure: Option<StructureMeta>,
102    /// The tile set, for a piece whose blocks did not fit one template.
103    ///
104    /// **Packaging, not authoring.** A zone past the 48-per-axis structure cap
105    /// ships as several `.nbt` files plus this manifest; everything else about
106    /// the document — the id, the zone-local `anchors`, the `connectors`, the
107    /// one `lighting` block, the one provenance row — is what it is for a
108    /// single-template piece, because it describes the same building. Nothing
109    /// that refers to a piece may ask which of the two it is: read
110    /// [`Self::templates`].
111    ///
112    /// This was a second document type (`TileSetMeta`, in the schem crate),
113    /// field-for-field this one with `structure` swapped for `structure_set`.
114    /// The copy had already lost `waterline_y`, so a tiled shore could not
115    /// declare the waterline the ocean-horizon invariant (`DW0344`) keys off and
116    /// went silently unchecked. One document is what makes that class of drift
117    /// unrepresentable.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub structure_set: Option<TileSet>,
120    /// Named anchors, keyed by DSL anchor name. `{}` for a piece that declares
121    /// none.
122    #[serde(default)]
123    pub anchors: BTreeMap<String, Anchor>,
124    /// Jigsaw sockets. `[]` for a piece that is placed directly rather than
125    /// drawn from a pool.
126    #[serde(default)]
127    pub connectors: Vec<Connector>,
128    /// The lighting declaration.
129    ///
130    /// Absent means legacy metadata that predates the field, which is a
131    /// different claim from `{"profile": "unmeasured"}` — the positive statement
132    /// that a measurement is owed.
133    ///
134    /// The block's own shape is [`Lighting`], and it is **the one part of this
135    /// document that still refuses a key it does not know**. Its job is a rule
136    /// about values — a measured profile must carry its measurement, an
137    /// `unmeasured` one must not — so a misspelled measurement key there is a
138    /// claim quietly becoming its own absence, which the profile/measurement
139    /// agreement alone does not catch for `rationale` or `method`. The cost is
140    /// real and is stated where an author will meet it
141    /// (`docs/reference/prefab-procedure.md` §9): a key added inside `lighting`
142    /// is a hard parse failure for an older engine, so adding one is a
143    /// `dsl_version` matter rather than a metadata edit.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub lighting: Option<Lighting>,
146    /// Licence, provenance prose, and the machine-readable provenance row.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub license: Option<License>,
149    /// The local y of this piece's **top authored water block** — its waterline
150    /// — for open-air pieces built to a tileset convention that authors a sea.
151    /// Consumed by the ocean-horizon placement invariant (`DW0344`): in a
152    /// `horizon: ocean` world the declared waterline must land at world sea
153    /// level. Absent for pieces that author no sea, which are then not checked.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub waterline_y: Option<i32>,
156    /// The piece's spatial contract, when it declares one.
157    ///
158    /// Absent means legacy metadata — the piece makes no spatial claim — exactly
159    /// as an absent `lighting` block differs from `unmeasured`.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub spatial_contract: Option<SpatialContract>,
162    /// **The size class of box this piece is built to fill** — a name from the
163    /// metrics table's `size-class.*` ladder (spec-0050 §5).
164    ///
165    /// Optional for the library at large, and that is deliberate rather than
166    /// lax: every piece in the library predates the field, and `DW0848` binds
167    /// only where the claim is made. What is *not* optional is the claim being
168    /// true — a piece declaring a class its own bytes could serve no box of is
169    /// refused at admission and again wherever a detail plan consumes it, so a
170    /// pre-check-era piece cannot be consumed unjudged.
171    ///
172    /// Absent means what absence means everywhere in this document: the claim is
173    /// not made. A piece bound by a `details[]` row is still checked for exact
174    /// frame equality (`DW0843`) whether or not it declares — that is the
175    /// consumer's exact check, and this is the library's approximate one.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub footprint_class: Option<String>,
178    /// Every top-level key this version does not model, kept verbatim so that
179    /// reading and writing the document is not the same as editing it.
180    ///
181    /// A reader that wants to report one has it in hand; a reader that does not
182    /// care carries it through. Emitted after the modelled keys, in key order.
183    #[serde(flatten)]
184    pub extra: BTreeMap<String, serde_json::Value>,
185}
186
187/// A piece's declared spaces, out-of-walk regions and edges, **already
188/// resolved**: every box is a local cell range of these exact bytes.
189///
190/// Resolved rather than parametric on purpose. A grammar program's declarations
191/// are scope-bound and mean different boxes at different parameters, so the only
192/// contract that can describe *this* `.nbt` is the one its own expansion
193/// produced. That is also what lets a hand-built piece carry the same block: it
194/// has no parameters to resolve, so the two routes write the same shape and one
195/// reader serves both.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct SpatialContract {
198    /// The space a body enters at.
199    pub entry: String,
200    /// Named spaces.
201    #[serde(default)]
202    pub spaces: BTreeMap<String, ContractSpace>,
203    /// Named standable-but-out-of-walk regions.
204    #[serde(default)]
205    pub no_body: BTreeMap<String, ContractNoBody>,
206    /// The graph, in declaration order.
207    #[serde(default)]
208    pub edges: Vec<ContractEdge>,
209    /// **The piece's face contract**: every `exterior` edge, as the side of the
210    /// piece it is on and the opening it leaves there.
211    ///
212    /// Derived from the edges and the blocks at export time and written out, so
213    /// that assembly can ask whether two pieces fit without opening either
214    /// `.nbt`. It is the thing an `exterior` edge IS from the outside: an edge
215    /// with no cells is a claim nothing can mate with, and one whose opening
216    /// does not answer its neighbour's is two pieces that were each approved
217    /// alone and do not assemble.
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub faces: Vec<ContractFace>,
220    /// The author's acknowledgement that this piece is mostly out-of-walk.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub no_body_majority_ack: Option<String>,
223}
224
225/// `DW0848`: a piece's declared footprint class disagrees with its bytes.
226pub const DW_FOOTPRINT_CLASS: crate::DwCode = crate::DwCode::new("DW0848", crate::ExitTier::Build);
227
228/// **Judge a piece's declared `footprint_class` against its own structure
229/// size.**
230///
231/// One authority with two doors, on the pattern spec-0036 §1c fixed for the
232/// spatial contract: `delvec prefab audit` asks it at the admission event, where
233/// the library's integrity lives, and `delvewright_compiler::detail` asks it
234/// again wherever a `detail-plan` row consumes the piece. Two implementations
235/// that agreed until they did not is the failure this shape removes.
236///
237/// What it asks, and each half is a fact about the geometry rather than a
238/// preference:
239///
240/// 1. **The name is in the table** — otherwise `DW0812`, as for any document
241///    naming a table entry.
242/// 2. **The horizontal extents could be a box of that class.** A detail frame's
243///    footprint IS its box's footprint (`Frame::of` grows the play space
244///    downward only), so a piece whose `x` or `z` falls outside the class's
245///    `min_footprint..=max_footprint` could fill no box of it.
246/// 3. **They sit on the kit grid.** A site-plan box's extent is a multiple of
247///    the grid quantum (`DW0825`), so a piece off the grid could fill no box at
248///    all, of any class.
249/// 4. **The height leaves the class its clearance.** A frame is the play space
250///    plus one floor course, so a piece under `min_clearance + 1` is short of
251///    the shallowest box of its class.
252///
253/// Returns `None` for a piece that declares no class — which is the honest
254/// answer, and why the caller states how many pieces declared one against how
255/// many it examined.
256#[must_use]
257pub fn check_footprint_class(
258    meta: &PrefabMeta,
259    stage: &str,
260    path: &str,
261    reads: &mut crate::metrics::Reads,
262) -> Option<crate::Diagnostic> {
263    let named = meta.footprint_class.as_deref()?;
264    let table = crate::metrics::Metrics::table();
265    let entry = match table.resolve(crate::metrics::MetricKind::SizeClass, named) {
266        Ok(e) => e,
267        Err(unknown) => return Some(unknown.diagnostic(stage, path)),
268    };
269    let crate::metrics::MetricValue::SizeClass(class) = *entry.value(reads) else {
270        return None; // an internal table defect, which `Metrics::self_check` owns.
271    };
272    let size = meta.size();
273    let grid = table.grid(reads);
274    let q = grid.map_or(1, |g| i64::from(g.quantum).max(1));
275    let (sx, sy, sz) = (i64::from(size[0]), i64::from(size[1]), i64::from(size[2]));
276    let (minf, maxf) = (class.min_footprint, class.max_footprint);
277    let mut why: Vec<String> = Vec::new();
278    if sx < i64::from(minf[0]) || sx > i64::from(maxf[0]) {
279        why.push(format!(
280            "its x extent is {sx}, and a `{named}` box is {}..={} on x",
281            minf[0], maxf[0]
282        ));
283    }
284    if sz < i64::from(minf[1]) || sz > i64::from(maxf[1]) {
285        why.push(format!(
286            "its z extent is {sz}, and a `{named}` box is {}..={} on z",
287            minf[1], maxf[1]
288        ));
289    }
290    if sx % q != 0 || sz % q != 0 {
291        why.push(format!(
292            "its footprint {sx}x{sz} is off the kit grid, whose quantum is {q} — every site-plan \
293             box's extent is a multiple of it (`DW0825`)"
294        ));
295    }
296    let least = i64::from(class.min_clearance) + 1;
297    if sy < least {
298        why.push(format!(
299            "it is {sy} cells tall, and the shallowest `{named}` frame is {least} — {} of \
300             clearance plus the one floor course a piece owns",
301            class.min_clearance
302        ));
303    }
304    if why.is_empty() {
305        return None;
306    }
307    Some(crate::Diagnostic::error(
308        DW_FOOTPRINT_CLASS,
309        stage,
310        path,
311        format!(
312            "`{id}` declares `footprint_class: \"{named}\"` and its own bytes could serve no box \
313             of that class: {why}. The declaration is a claim about what this piece is FOR, and a \
314             site plan hands a piece the exact frame of the box it fills — so a piece whose \
315             extents no box of the class can have is a piece no `details[]` row could ever bind. \
316             Either correct the class name, or rebuild the piece to a frame of the class it \
317             claims. Structure size is {sx}x{sy}x{sz}.",
318            id = meta.prefab_id,
319            why = why.join("; "),
320        ),
321    ))
322}
323
324/// One face of the piece's face contract.
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326pub struct ContractFace {
327    /// The space the way in or out belongs to.
328    pub space: String,
329    /// The edge's class: `walk` | `stair` | `drop` | `barred` | `vision`.
330    pub class: String,
331    /// Which side of the piece: `east` | `west` | `up` | `down` | `south` |
332    /// `north`.
333    pub dir: String,
334    /// The opening, as an inclusive local cell range flat in the face's own
335    /// axis.
336    pub opening: Region,
337}
338
339/// One entry of `spatial_contract.spaces`.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct ContractSpace {
342    /// `enclosed` | `open_top` | `open`.
343    pub envelope: String,
344    /// The cells it covers.
345    pub boxes: Vec<Region>,
346}
347
348/// One entry of `spatial_contract.no_body`.
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350pub struct ContractNoBody {
351    /// Why these cells are out of play, in the author's words. Which exemption
352    /// the region qualifies for is a fact about the blocks and is not recorded
353    /// here.
354    pub reason: String,
355    /// The cells it covers.
356    pub boxes: Vec<Region>,
357}
358
359/// One entry of `spatial_contract.edges`.
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361pub struct ContractEdge {
362    /// A declared space name, or `exterior`.
363    pub a: String,
364    /// A declared space name, or `exterior`.
365    pub b: String,
366    /// `walk` | `stair` | `drop` | `barred` | `vision`.
367    pub class: String,
368    /// The declared level change, on the classes that carry one.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub rise: Option<i64>,
371    /// The opening or transit volume, when the edge declares one.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub via: Option<ContractVolume>,
374    /// The bar, on a `barred` edge.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub bar: Option<ContractBar>,
377    /// **The contingency**, on a traversal edge that content opens: the region
378    /// the edge is severed by as built, and which direction opening it goes.
379    ///
380    /// Absent on every edge that is what it claims to be as shipped, which is
381    /// why a piece that declares none writes no key at all and its metadata is
382    /// byte-for-byte what it was.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub way: Option<ContractWay>,
385}
386
387/// A contingent edge's way: the region that decides whether the edge is
388/// crossable, and which direction opening it moves in.
389///
390/// The dual of [`ContractBar`], and the reason `bar` is not extended in place:
391/// an existing piece's metadata says `bar` and keeps saying `bar`. The
392/// **checker** normalises the two into one prover; the document keeps both
393/// spellings, so nothing already written moves a byte.
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
395pub struct ContractWay {
396    /// `laid` — the region is empty as built and opening fills it with
397    /// [`block`](ContractWay::block); or `cleared` — the region stands in
398    /// `block` as built and opening voids it.
399    pub opens: String,
400    /// The region's name, which is what content addresses.
401    pub region: String,
402    /// The cells it covers.
403    pub boxes: Vec<Region>,
404    /// The palette role the way is made of, in the author's own vocabulary.
405    ///
406    /// Provenance for a reader, and never a second authority: what an opening
407    /// writes is [`block`](ContractWay::block), because a role name means
408    /// nothing outside the program that bound it. Recorded because a reviewer
409    /// reading this document otherwise has no way back to the declaration —
410    /// `minecraft:oak_planks` says what the cells become and `"tread"` says
411    /// what the author called it.
412    ///
413    /// Optional for the reason [`License::generated_by`] is: a role is a
414    /// *program's* vocabulary, so an expansion always has one and a hand-built
415    /// or ingested piece — which names its blocks directly — has none at all.
416    /// Writing an invented role there would be a fact about nothing.
417    ///
418    /// [`ContractBar`] carries no such field, and deliberately: adding one
419    /// would move the exported bytes of every piece that already declares a
420    /// bar, which spec-0042 §2.3 forbids. The checker reads neither.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub role: Option<String>,
423    /// The block state the way is made of: what a `laid` way is filled with,
424    /// and what a `cleared` way stands in.
425    pub block: String,
426}
427
428/// An edge's own volume — an opening, a stair's treads, a fall column.
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
430pub struct ContractVolume {
431    /// The region's name, which is what content binds to.
432    pub region: String,
433    /// The cells it covers.
434    pub boxes: Vec<Region>,
435}
436
437/// A `barred` edge's bar: the region that stands in the way, and its block.
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
439pub struct ContractBar {
440    /// The region's name.
441    pub region: String,
442    /// The cells it covers.
443    pub boxes: Vec<Region>,
444    /// The block state the bar is built from.
445    pub block: String,
446}
447
448/// The `structure` block: which file, how big, for which MC version.
449#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
450pub struct StructureMeta {
451    /// The `.nbt` filename, relative to this metadata file.
452    pub file: String,
453    /// The datapack structure id (a path segment).
454    pub id: String,
455    /// Structure extent `[x, y, z]`.
456    pub size: [i32; 3],
457    /// The MC data version the structure targets (ADR-0009).
458    pub data_version: i32,
459    /// Provenance breadcrumb: what wrote the `.nbt`.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub generator: Option<String>,
462}
463
464/// One structure template a piece's blocks arrive in, and where in the piece it
465/// sits.
466///
467/// **The unit every placer works in.** A single-template prefab has exactly one,
468/// at `offset` `[0, 0, 0]`; a tiled zone has one per tile at its manifest
469/// offset. Nothing that places, stamps or reads a piece's blocks needs to know
470/// which of the two it was handed — that is the whole point of the type, and the
471/// reason [`PrefabMeta::templates`] is the only way to reach a `.nbt` filename.
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub struct PieceTemplate<'a> {
474    /// The datapack structure id (a path segment).
475    pub id: &'a str,
476    /// The `.nbt` filename, relative to the metadata file.
477    pub file: &'a str,
478    /// This template's origin in **piece-local** coordinates — add it to a
479    /// template-local cell to get the piece cell. `[0, 0, 0]` for a
480    /// single-template piece.
481    pub offset: [i32; 3],
482    /// The template's extent `[x, y, z]`.
483    pub size: [i32; 3],
484}
485
486/// **What an anchor is FOR**, when the compiler has to find it without being
487/// told its name (spec-0046).
488///
489/// A closed vocabulary the compiler owns, and deliberately small: a role is
490/// added by the change that teaches the compiler to resolve it, never by a
491/// producer that wants a label. Deserialising is therefore the whole
492/// validation — a term this engine does not know is a `serde` error naming the
493/// terms it does, which the prefab registry reports as `DW0346` against the
494/// file that wrote it, rather than a string ridden through into a resolution
495/// that silently never matches.
496///
497/// Distinct from [`ContractWay::role`], which is a *palette* role in a
498/// program's own vocabulary and means nothing outside it. This one is the
499/// engine's vocabulary, which is exactly why it is closed.
500#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
501#[serde(rename_all = "kebab-case")]
502pub enum AnchorRole {
503    /// **The cell a body arrives at when it enters the area this piece is
504    /// placed in.** One per area: `setworldspawn`, the class-apply teleport,
505    /// first-join placement, inter-area transport, the POV planner's first
506    /// frame and the trap-safety start set all resolve it.
507    Entry,
508}
509
510impl AnchorRole {
511    /// Every term in the vocabulary, in declaration order — what a refusal
512    /// lists, so the message cannot drift from the type.
513    pub const ALL: &'static [AnchorRole] = &[AnchorRole::Entry];
514
515    /// The term as it is written in a document.
516    pub fn as_str(self) -> &'static str {
517        match self {
518            AnchorRole::Entry => "entry",
519        }
520    }
521
522    /// The terms a refusal lists, comma-separated — one rendering, so a message
523    /// written at a command line and a message written by the prefab registry
524    /// cannot name different vocabularies.
525    pub fn vocabulary() -> String {
526        AnchorRole::ALL
527            .iter()
528            .map(|r| format!("`{r}`"))
529            .collect::<Vec<_>>()
530            .join(", ")
531    }
532}
533
534/// A role typed at a command line is the same closed vocabulary a role written
535/// into a document is, read from the same table — so `delvec prefab anchor
536/// --role` refuses exactly what deserialising the document refuses, by name,
537/// and the two cannot come to know different terms.
538impl std::str::FromStr for AnchorRole {
539    type Err = String;
540
541    fn from_str(s: &str) -> Result<AnchorRole, String> {
542        AnchorRole::ALL
543            .iter()
544            .copied()
545            .find(|r| r.as_str() == s)
546            .ok_or_else(|| {
547                format!(
548                    "unknown anchor role `{s}` — the engine's vocabulary is {}",
549                    AnchorRole::vocabulary()
550                )
551            })
552    }
553}
554
555impl std::fmt::Display for AnchorRole {
556    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
557        f.write_str(self.as_str())
558    }
559}
560
561/// One entry of the `anchors` map.
562///
563/// A point anchor carries `pos` (+ optionally `facing`); a gate anchor carries a
564/// `region` (+ optionally `block`); a trap anchor also carries the hardware the
565/// prefab pre-wired for it. All of those are the same object class — a named
566/// place in a piece — so they live in one type and each writes only the keys it
567/// means.
568#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
569pub struct Anchor {
570    /// Local cell `[x, y, z]`, relative to the structure origin.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub pos: Option<[i32; 3]>,
573    /// Cardinal facing keyword.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub facing: Option<String>,
576    /// **What this anchor is for**, when the compiler has to find it without
577    /// being told its name (spec-0046).
578    ///
579    /// A campaign addresses an anchor by its name, and for everything a
580    /// campaign addresses that is the whole story. The entry point is the one
581    /// place a campaign does *not* name — the compiler has to find it — and
582    /// finding it by matching a spelling is a fact about the producer that
583    /// wrote the piece rather than about the piece. So the piece declares it,
584    /// every producer can write it, and none has to agree with another about
585    /// how it is spelled.
586    ///
587    /// Absent on every piece that predates the role, which is what keeps the
588    /// shipped library building byte-for-byte what it built before: the
589    /// compiler falls back to the name list when no anchor in an area declares
590    /// a role.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub role: Option<AnchorRole>,
593    /// Local cell range, for a gate anchor.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub region: Option<Region>,
596    /// Block id filling a gate region (e.g. `minecraft:iron_bars`).
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub block: Option<String>,
599    /// **Which element of the piece's spatial contract this anchor lands in** —
600    /// `space:<name>`, `no_body:<name>`, `via:<name>` or `bar:<name>`.
601    ///
602    /// A campaign binds content to an anchor by name; what says whether that
603    /// place is play space, a door or exterior dressing is the contract, and a
604    /// reader who has only the anchor list cannot tell. Absent on a piece that
605    /// declares no contract, and on an anchor that lands in nothing the contract
606    /// accounts for — which is a finding the checker raises rather than a
607    /// silence.
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub resolves_to: Option<String>,
610    /// The pre-wired dispenser socket cell (local coords) for an `anchor/trap`
611    /// marker. `pos` is the trap's trigger/hazard cell (the plate, tripwire or
612    /// chest modelled as the hazard); `dispenser` is the separate cell holding
613    /// the empty dispenser whose payload is filled at compile time. Absent for
614    /// every non-trap anchor.
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub dispenser: Option<[i32; 3]>,
617    /// The block the prefab wired as this `anchor/trap`'s **trigger** — the
618    /// plate or tripwire sitting on `pos` — with its full blockstate exactly as
619    /// authored (`minecraft:oak_pressure_plate[powered=false]`), because
620    /// flag-gating a trap physically removes and restores this block and must
621    /// put back what was there. The gate-anchor `block` above is the same
622    /// contract for a sealed gate. Absent for every non-trap anchor.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub trigger_block: Option<String>,
625    /// **One line of prose about this place, for a person reading the piece.**
626    ///
627    /// The engine never reads it: nothing routes, places, lights or refuses
628    /// anything because of what it says. It is modelled all the same, because
629    /// a document whose only reader is a machine is a document a person cannot
630    /// review, and a producer that writes the key unmodelled makes every build
631    /// report `DW0543` — "this library is newer than this engine" — about a
632    /// sentence. A tripwire that fires on the normal state of the tree is not a
633    /// tripwire.
634    ///
635    /// It is prose, so it is deliberately unconstrained and deliberately not
636    /// inventoried for translation: it is addressed to whoever opens the `.json`,
637    /// never to a player.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub note: Option<String>,
640    /// Every anchor key this version does not model, kept verbatim. The anchor
641    /// block is where this document has grown most often — `resolves_to`,
642    /// `dispenser` and `trigger_block` were each a new key on a shipped
643    /// document — so it captures for the same reason [`PrefabMeta::extra`]
644    /// does.
645    #[serde(flatten)]
646    pub extra: BTreeMap<String, serde_json::Value>,
647}
648
649impl Anchor {
650    /// The point-anchor shape: a cell and a facing.
651    pub fn point(pos: [i32; 3], facing: impl Into<String>) -> Anchor {
652        Anchor {
653            pos: Some(pos),
654            facing: Some(facing.into()),
655            ..Anchor::default()
656        }
657    }
658
659    /// Declare what this anchor is for ([`Anchor::role`]).
660    pub fn with_role(mut self, role: AnchorRole) -> Anchor {
661        self.role = Some(role);
662        self
663    }
664}
665
666/// A gate anchor's region and fill block, in **piece-local** coordinates — the
667/// answer [`PrefabMeta::gate_anchor`] gives, and the only shape either reader
668/// works from.
669#[derive(Debug, Clone, PartialEq, Eq)]
670pub struct GateAnchor {
671    /// Low corner of the region, piece-local.
672    pub from: [i32; 3],
673    /// High corner of the region, piece-local, inclusive.
674    pub to: [i32; 3],
675    /// The block the region is filled with and cleared of.
676    pub block: String,
677}
678
679impl GateAnchor {
680    /// The region as a reader sees it in a diagnostic.
681    fn extent(&self) -> String {
682        format!(
683            "[{},{},{}]..[{},{},{}]",
684            self.from[0], self.from[1], self.from[2], self.to[0], self.to[1], self.to[2]
685        )
686    }
687}
688
689/// The contract-element name a `bar:<region>` [`Anchor::resolves_to`] carries,
690/// and nothing else's. Every other element kind is a place a body stands, looks
691/// through or walks over — not a thing content fills.
692fn bar_name(resolves_to: &str) -> Option<&str> {
693    resolves_to.strip_prefix("bar:")
694}
695
696/// The one box `boxes` exactly fills, or `None` when they do not fill one.
697///
698/// A contract region is a **list** of boxes, and a gate is a **single** box: the
699/// compiler fills it and clears it as one region, and every consumer of a
700/// resolved gate — the assembler that voids it, the seal that rebuilds it, the
701/// nav model that walks through it — is written against two corners. So the
702/// question is not "what box contains these" but "do these BE a box": a bounding
703/// box that the members do not fill would hand every one of those consumers
704/// cells the contract never called bar, and the assembler would delete them.
705///
706/// Exact, not approximate: the members must be pairwise disjoint and their
707/// volumes must sum to the bounding box's. A doorway declared as a lintel row
708/// plus the two jambs under it is one box and passes; a doorway plus a
709/// threshold nub hanging off its corner is not, and is refused.
710fn one_box(boxes: &[Region]) -> Option<Region> {
711    let first = boxes.first()?;
712    let mut from = first.from;
713    let mut to = first.to;
714    for b in &boxes[1..] {
715        for a in 0..3 {
716            from[a] = from[a].min(b.from[a]).min(b.to[a]);
717            to[a] = to[a].max(b.from[a]).max(b.to[a]);
718        }
719    }
720    let vol = |f: [i32; 3], t: [i32; 3]| -> i64 {
721        (0..3)
722            .map(|a| i64::from(t[a] - f[a]) + 1)
723            .try_fold(1i64, |acc, n| if n > 0 { acc.checked_mul(n) } else { None })
724            .unwrap_or(0)
725    };
726    let total: i64 = boxes.iter().map(|b| vol(b.from, b.to)).sum();
727    if total != vol(from, to) {
728        return None;
729    }
730    for (i, a) in boxes.iter().enumerate() {
731        for b in &boxes[i + 1..] {
732            if (0..3).all(|k| a.from[k] <= b.to[k] && b.from[k] <= a.to[k]) {
733                return None;
734            }
735        }
736    }
737    Some(Region { from, to })
738}
739
740/// Where an anchor is and what it is for — the parts of an anchor an editing
741/// tool declares.
742///
743/// Deliberately a different type from [`Anchor`]: the whole anchor is what a
744/// caller must not be able to hand an editing step, because constructing one
745/// means filling in — and therefore erasing — the hardware, provenance and
746/// unknown keys the caller knows nothing about. See [`PrefabMeta::edit_anchor`].
747#[derive(Debug, Clone, Default, PartialEq)]
748pub struct AnchorEdit {
749    /// Local cell `[x, y, z]`, for a point anchor.
750    pub pos: Option<[i32; 3]>,
751    /// Cardinal facing keyword.
752    pub facing: Option<String>,
753    /// Local cell range, for a gate anchor.
754    pub region: Option<Region>,
755    /// Block id filling a gate region.
756    pub block: Option<String>,
757    /// **What the anchor is for** ([`Anchor::role`]), tri-state because the
758    /// place and the purpose are two properties and an edit may speak about
759    /// either without speaking about the other:
760    ///
761    /// * `None` — the edit says nothing about the role, so an existing one is
762    ///   kept. Moving a cell is not a statement that the piece stopped being
763    ///   the place a party arrives at, and silently answering it as one is the
764    ///   deletion this type exists to prevent.
765    /// * `Some(None)` — the edit says the anchor has **no** role, and an
766    ///   existing one is removed. This is the remedy `DW0804` prescribes when
767    ///   two anchors in one area both claim a role, and it is reachable here
768    ///   rather than only by hand-editing the document.
769    /// * `Some(Some(role))` — the anchor is declared to have that role.
770    pub role: Option<Option<AnchorRole>>,
771}
772
773/// An inclusive local cell range.
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
775pub struct Region {
776    /// Low corner `[x, y, z]`.
777    pub from: [i32; 3],
778    /// High corner `[x, y, z]`.
779    pub to: [i32; 3],
780}
781
782/// One jigsaw socket declared by a prefab.
783///
784/// `local_pos` is the socket's wall cell (bottom-centre of the opening) in the
785/// prefab's local coordinates; `facing` is the cardinal direction the opening
786/// faces outward. Two sockets mate by placing the child so its socket sits one
787/// block beyond the parent's, facing the opposite way.
788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
789pub struct Connector {
790    /// Jigsaw `name`.
791    pub name: String,
792    /// Jigsaw `target`.
793    pub target: String,
794    /// The socket's wall cell, local coords `[x, y, z]`.
795    pub local_pos: [i32; 3],
796    /// Cardinal direction the opening faces outward.
797    pub facing: String,
798    /// Opening extent `[width, height]`.
799    pub opening: [i32; 2],
800    /// Jigsaw joint.
801    pub joint: String,
802}
803
804/// The profile of a prefab whose light nothing has measured.
805pub const UNMEASURED: &str = "unmeasured";
806
807/// The `license` block: the human half and the machine half of provenance.
808#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
809pub struct License {
810    /// Where the asset came from (`original`, or a named upstream).
811    pub source: String,
812    /// SPDX id (ADR-0013).
813    pub spdx: String,
814    /// Human note.
815    pub note: String,
816    /// Human-readable provenance sentence.
817    pub provenance: String,
818    /// The machine-readable provenance row: what regenerates these exact bytes.
819    ///
820    /// Absent for a piece nothing can regenerate — an ingested community build,
821    /// or a hand-edited one. Present, it is the ADR-0006 claim in a form a tool
822    /// can act on rather than a sentence a human can read.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub generated_by: Option<GeneratedBy>,
825}
826
827/// Everything needed to reproduce the `.nbt` byte for byte (ADR-0006).
828///
829/// **Every input that reaches the bytes, and nothing that does not.** A record
830/// missing one of them is worse than no record: it names a set of inputs, and a
831/// re-expansion from that set produces a different artifact while the document
832/// claims byte reproducibility. The set is the source program, the overrides
833/// applied to it, the region and the seed.
834#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
835pub struct GeneratedBy {
836    /// The back end that produced the bytes.
837    pub generator: String,
838    /// The source program's name.
839    pub program: String,
840    /// `sha256:<64 hex>` over the canonical JSON of the program **as expanded**
841    /// — the source document with [`Self::params`] and [`Self::roles`] already
842    /// applied. It is therefore the checksum of the reproduction rather than a
843    /// second statement of it: apply the overrides to the named document and
844    /// this is the hash you must get.
845    pub program_hash: String,
846    /// The expansion seed.
847    pub seed: u64,
848    /// The region the program was expanded over, `[x, y, z]`. An independent
849    /// input: the same program at the same seed over a different box is a
850    /// different building.
851    pub region: [i32; 3],
852    /// Integer parameters overridden on the way in (`delvec grammar expand
853    /// --param`), by name. Empty — and absent from the document — where the
854    /// program was expanded as written.
855    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
856    pub params: BTreeMap<String, i64>,
857    /// Palette roles rebound on the way in (`--role`), by name, each the block
858    /// state as the caller wrote it. The axis frame is not recorded because it
859    /// is not an input: a rebind inherits the frame of the binding it replaces,
860    /// which the named source document already carries.
861    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
862    pub roles: BTreeMap<String, String>,
863}
864
865impl PrefabMeta {
866    /// Parse metadata from JSON text.
867    ///
868    /// **The one reader of both packagings.** "Which shape is this" has exactly
869    /// two answers and no third, so a document declaring neither block, or both,
870    /// is refused here rather than handed half-read to a step that will place
871    /// some of its blocks. A tile set is validated as it is read
872    /// ([`TileSet::validate`]) for the same reason: a manifest that does not
873    /// tile its own volume reassembles into a building with a hole in it and
874    /// reports success.
875    pub fn from_json(text: &str) -> Result<PrefabMeta, String> {
876        let meta: PrefabMeta =
877            serde_json::from_str(text).map_err(|e| format!("invalid prefab metadata: {e}"))?;
878        match (&meta.structure, &meta.structure_set) {
879            (None, None) => {
880                return Err("prefab metadata has neither a `structure` block nor a \
881                            `structure_set` block — it does not say what blocks it describes"
882                    .to_string());
883            }
884            (Some(_), Some(_)) => {
885                return Err(
886                    "prefab metadata has BOTH a `structure` block and a `structure_set` \
887                            block — a piece's blocks arrive one way or the other, and a reader \
888                            cannot be asked which one is the building"
889                        .to_string(),
890                );
891            }
892            (None, Some(set)) => set
893                .validate()
894                .map_err(|e| format!("`structure_set`: {e}"))?,
895            (Some(_), None) => {}
896        }
897        Ok(meta)
898    }
899
900    /// Every structure template this piece's blocks arrive in, in a
901    /// deterministic order (grid order for a tile set).
902    ///
903    /// Empty only for a value built in code that declares neither block, which
904    /// [`Self::from_json`] refuses — nothing read from disk is in that state.
905    pub fn templates(&self) -> Vec<PieceTemplate<'_>> {
906        if let Some(s) = &self.structure {
907            return vec![PieceTemplate {
908                id: &s.id,
909                file: &s.file,
910                offset: [0, 0, 0],
911                size: s.size,
912            }];
913        }
914        self.structure_set
915            .iter()
916            .flat_map(|set| {
917                set.parts.iter().map(|p| PieceTemplate {
918                    id: &p.id,
919                    file: &p.file,
920                    offset: p.offset,
921                    size: p.size,
922                })
923            })
924            .collect()
925    }
926
927    /// The piece's extent `[x, y, z]` — the WHOLE building, whichever packaging
928    /// its blocks arrived in. `[0, 0, 0]` only for the value
929    /// [`Self::templates`] documents as unreachable from disk.
930    pub fn size(&self) -> [i32; 3] {
931        match (&self.structure, &self.structure_set) {
932            (Some(s), _) => s.size,
933            (None, Some(set)) => set.size,
934            (None, None) => [0, 0, 0],
935        }
936    }
937
938    /// The MC data version the piece's templates target (ADR-0009), when it
939    /// declares one.
940    pub fn data_version(&self) -> Option<i32> {
941        match (&self.structure, &self.structure_set) {
942            (Some(s), _) => Some(s.data_version),
943            (None, Some(set)) => Some(set.data_version),
944            (None, None) => None,
945        }
946    }
947
948    /// True when the piece's blocks arrive as several templates — a fact about
949    /// packaging that only a tool reporting on packaging may ask.
950    pub fn is_tiled(&self) -> bool {
951        self.structure_set.is_some()
952    }
953
954    /// The filename stem the piece's files are named from — the single
955    /// template's `id`, or the tile set's `base`. They are the same concept
956    /// under two keys, so a diagnostic that wants to name the piece's document
957    /// asks here rather than reaching into one packaging.
958    pub fn base(&self) -> &str {
959        match (&self.structure, &self.structure_set) {
960            (Some(s), _) => &s.id,
961            (None, Some(set)) => &set.base,
962            (None, None) => "",
963        }
964    }
965
966    /// The tile grid `[x, y, z]`; `[1, 1, 1]` for a piece that fit one
967    /// template. Packaging, like [`Self::is_tiled`].
968    pub fn grid(&self) -> [i32; 3] {
969        self.structure_set
970            .as_ref()
971            .map_or([1, 1, 1], |set| set.grid)
972    }
973
974    /// Read the document at `path`, or `Ok(None)` when there is no file there.
975    pub fn read(path: &Path) -> Result<Option<PrefabMeta>, String> {
976        if !path.exists() {
977            return Ok(None);
978        }
979        let text =
980            std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
981        PrefabMeta::from_json(&text).map(Some)
982    }
983
984    /// Load `<nbt_path>.json` (the sibling metadata), or `Ok(None)` when absent.
985    pub fn beside_nbt(nbt_path: &Path) -> Result<Option<PrefabMeta>, String> {
986        let json_path = nbt_path.with_extension("json");
987        if !json_path.exists() {
988            return Ok(None);
989        }
990        let text = std::fs::read_to_string(&json_path)
991            .map_err(|e| format!("read {}: {e}", json_path.display()))?;
992        Ok(Some(PrefabMeta::from_json(&text)?))
993    }
994
995    /// Serialize as canonical pretty JSON with a trailing newline.
996    pub fn to_json(&self) -> String {
997        serde_json::to_string_pretty(self).expect("prefab metadata serializes") + "\n"
998    }
999
1000    /// Every key of this document — top level and per anchor — that this version
1001    /// does not model, as `(where, key)` pairs in a stable order.
1002    ///
1003    /// `where` is `""` for a top-level key and the anchor's name for an anchor
1004    /// key. A reader that wants to say something about a key it kept asks here;
1005    /// nothing has to re-open the file to find out.
1006    pub fn unknown_keys(&self) -> Vec<(&str, &str)> {
1007        let mut out: Vec<(&str, &str)> = self
1008            .extra
1009            .keys()
1010            .map(|k| ("", k.as_str()))
1011            .collect::<Vec<_>>();
1012        for (name, anchor) in &self.anchors {
1013            for key in anchor.extra.keys() {
1014                out.push((name.as_str(), key.as_str()));
1015            }
1016        }
1017        out
1018    }
1019
1020    /// A minimal skeleton for a freshly admitted external piece.
1021    pub fn skeleton(
1022        id: &str,
1023        size: [i32; 3],
1024        data_version: i32,
1025        generator: &str,
1026        license: License,
1027    ) -> PrefabMeta {
1028        PrefabMeta {
1029            prefab_id: format!("prefab/{id}"),
1030            structure: Some(StructureMeta {
1031                file: format!("{id}.nbt"),
1032                id: id.to_string(),
1033                size,
1034                data_version,
1035                generator: Some(generator.to_string()),
1036            }),
1037            structure_set: None,
1038            anchors: BTreeMap::new(),
1039            connectors: Vec::new(),
1040            lighting: Some(Lighting {
1041                method: Some("not yet probed".to_string()),
1042                ..Lighting::unmeasured()
1043            }),
1044            license: Some(license),
1045            waterline_y: None,
1046            spatial_contract: None,
1047            // A freshly admitted piece makes no claim about which size class of
1048            // box it fills, and inventing one from its bytes would be the
1049            // inference this document does not do: the claim is the author's.
1050            footprint_class: None,
1051            extra: BTreeMap::new(),
1052        }
1053    }
1054
1055    /// Annotate a named anchor's **place and purpose**, creating the anchor
1056    /// when it is not there yet.
1057    ///
1058    /// An anchor is an object, not a value. A tool that names where the anchor
1059    /// is has said nothing about the hardware the prefab wired at it
1060    /// ([`Anchor::dispenser`], [`Anchor::trigger_block`]), about which contract
1061    /// element an exporter resolved it into ([`Anchor::resolves_to`]), or about
1062    /// any key this version has never heard of ([`Anchor::extra`]) — so none of
1063    /// those is touched. Replacing the whole anchor instead is the same silent
1064    /// deletion this type exists to prevent at the top level, one level down,
1065    /// and on the block of the document that has grown most often.
1066    ///
1067    /// The place itself is one property expressed two ways — a cell or a region
1068    /// — so an edit redeclares all four of its fields together and a `pos` does
1069    /// supersede a stale `region`.
1070    ///
1071    /// The **role** is a fifth field and not a fifth way of saying where: what
1072    /// an anchor is for is a property of the anchor, so it is written when the
1073    /// edit speaks about it, cleared when the edit says it has none, and left
1074    /// alone when the edit says nothing ([`AnchorEdit::role`]).
1075    pub fn edit_anchor(&mut self, name: &str, edit: AnchorEdit) {
1076        let anchor = self.anchors.entry(name.to_string()).or_default();
1077        anchor.pos = edit.pos;
1078        anchor.facing = edit.facing;
1079        anchor.region = edit.region;
1080        anchor.block = edit.block;
1081        if let Some(role) = edit.role {
1082            anchor.role = role;
1083        }
1084    }
1085
1086    /// **The ONE authority on the region and block a gate anchor names**, in
1087    /// piece-local coordinates.
1088    ///
1089    /// A gate anchor is declared in one of two forms, and the compiler must read
1090    /// both identically:
1091    ///
1092    /// * **explicitly** — the anchor carries its own [`region`](Anchor::region)
1093    ///   and [`block`](Anchor::block), which is how a hand-authored piece has
1094    ///   always written one;
1095    /// * **through the piece's spatial contract** — the anchor carries a
1096    ///   [`resolves_to`](Anchor::resolves_to) of `bar:<region>`, which is what an
1097    ///   exporter writes. The cells and the block already live in that edge's
1098    ///   [`ContractBar`], so repeating them on the anchor would be a second
1099    ///   authority for one fact, and the exporter rightly does not.
1100    ///
1101    /// Nothing derived either form from the other, and the whole compiler read
1102    /// only the first. A piece declaring its gates the second way therefore had
1103    /// no gate at all: the anchor resolved to a bare point, every verb that fills
1104    /// or clears a gate was refused (`DW0343`), and the information the refusal
1105    /// asked for was sitting in the same document.
1106    ///
1107    /// Both readers ask this function and nothing else — the planner, which
1108    /// resolves the anchor to world cells, and
1109    /// [`AnchorRegistry`](crate::AnchorRegistry)'s prefab implementation, which
1110    /// answers whether the compiler can fill it — so the two cannot come to
1111    /// disagree about what a gate is or where it stands.
1112    ///
1113    /// Three answers, and the third is the one that earns the `Result`:
1114    ///
1115    /// * `Ok(None)` — not a gate anchor. A point anchor, a trap anchor, or an
1116    ///   anchor whose `resolves_to` names a space, a `no_body` region, a `via`
1117    ///   volume or a `way`. None of those is a thing content fills or clears.
1118    /// * `Ok(Some(gate))` — a gate, and here is the box and the block.
1119    /// * `Err(why)` — declared as a gate and **not resolvable to one fillable
1120    ///   box**. Refused rather than guessed, because every alternative writes
1121    ///   blocks somewhere the document did not ask for. `why` is a clause the
1122    ///   caller folds into its own diagnostic.
1123    ///
1124    /// A `way` is deliberately not a gate here. Its
1125    /// [`opens`](ContractWay::opens) is a direction — `laid` cells are absent as
1126    /// built and appear when opened, `cleared` cells stand and are voided — and
1127    /// the single-region gate model carries no direction to put it in. Reading
1128    /// one as a gate would silently pick a direction for the author.
1129    pub fn gate_anchor(&self, name: &str) -> Result<Option<GateAnchor>, String> {
1130        let Some(anchor) = self.anchors.get(name) else {
1131            return Ok(None);
1132        };
1133        let contract_bar = match anchor.resolves_to.as_deref().and_then(bar_name) {
1134            Some(region) => Some(self.contract_bar(name, region)?),
1135            None => None,
1136        };
1137        match (&anchor.region, contract_bar) {
1138            (None, None) => Ok(None),
1139            // The contract form. The block is the contract's, which is the block
1140            // the piece was actually built out of.
1141            (None, Some(bar)) => Ok(Some(bar)),
1142            // The explicit form, unchanged since it was the only one. A region
1143            // with no `block` is what `DW0343` has always refused: the compiler
1144            // is being asked to fill cells with a block nothing names.
1145            (Some(region), None) => match &anchor.block {
1146                Some(block) => Ok(Some(GateAnchor {
1147                    from: region.from,
1148                    to: region.to,
1149                    block: block.clone(),
1150                })),
1151                None => Err(format!(
1152                    "gate anchor `{name}` declares a `region` and no `block`, so nothing says \
1153                     what the region is filled with"
1154                )),
1155            },
1156            // Both forms. Agreement is fine and is what a piece that was
1157            // hand-authored and later exported looks like; a disagreement is
1158            // refused rather than resolved by precedence, because whichever one
1159            // this function preferred would be a rule no reader of the document
1160            // could see.
1161            (Some(region), Some(bar)) => {
1162                let explicit = GateAnchor {
1163                    from: region.from,
1164                    to: region.to,
1165                    block: anchor.block.clone().unwrap_or_default(),
1166                };
1167                if explicit == bar {
1168                    return Ok(Some(bar));
1169                }
1170                Err(format!(
1171                    "gate anchor `{name}` declares a `region` and also resolves into contract bar \
1172                     `{bar_region}`, and the two disagree — the anchor says {ea} filled with \
1173                     `{eb}`, the bar says {ba} filled with `{bb}`. One place stands in one way: \
1174                     delete the anchor's `region`/`block` and let the contract say it, or correct \
1175                     the contract",
1176                    bar_region = anchor
1177                        .resolves_to
1178                        .as_deref()
1179                        .and_then(bar_name)
1180                        .unwrap_or_default(),
1181                    ea = explicit.extent(),
1182                    eb = explicit.block,
1183                    ba = bar.extent(),
1184                    bb = bar.block,
1185                ))
1186            }
1187        }
1188    }
1189
1190    /// The bar `region` of this piece's spatial contract, as one fillable box.
1191    fn contract_bar(&self, anchor: &str, region: &str) -> Result<GateAnchor, String> {
1192        let bar = self
1193            .spatial_contract
1194            .as_ref()
1195            .into_iter()
1196            .flat_map(|c| c.edges.iter())
1197            .filter_map(|e| e.bar.as_ref())
1198            .find(|b| b.region == region)
1199            .ok_or_else(|| {
1200                format!(
1201                    "gate anchor `{anchor}` resolves into contract bar `{region}`, and this \
1202                     piece's spatial contract declares no bar of that name — the anchor's \
1203                     `resolves_to` is written from the contract, so the two have come apart. \
1204                     Re-export the piece"
1205                )
1206            })?;
1207        let one = one_box(&bar.boxes).ok_or_else(|| {
1208            format!(
1209                "gate anchor `{anchor}` resolves into contract bar `{region}`, whose {n} boxes do \
1210                 not fill their own bounding box — so there is no single region the compiler can \
1211                 fill or clear without writing blocks into cells the contract does not call bar. \
1212                 Declare the bar as one box, or as boxes that tile one",
1213                n = bar.boxes.len()
1214            )
1215        })?;
1216        Ok(GateAnchor {
1217            from: one.from,
1218            to: one.to,
1219            block: bar.block.clone(),
1220        })
1221    }
1222
1223    /// Append a socket connector (idempotent by `local_pos` + `facing`).
1224    pub fn add_connector(&mut self, c: Connector) {
1225        if !self
1226            .connectors
1227            .iter()
1228            .any(|x| x.local_pos == c.local_pos && x.facing == c.facing)
1229        {
1230            self.connectors.push(c);
1231        }
1232    }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238
1239    /// The whole reason this type is not two types: an editing tool reads a
1240    /// document, changes one block of it, and writes it back. Anything it does
1241    /// not model is deleted, and nothing says so.
1242    #[test]
1243    fn a_read_modify_write_round_trip_keeps_every_field() {
1244        let text = r#"{
1245  "prefab_id": "prefab/chapel-ward",
1246  "structure": {
1247    "file": "chapel-ward.nbt",
1248    "id": "chapel-ward",
1249    "size": [16, 9, 26],
1250    "data_version": 4671,
1251    "generator": "crates/grammar"
1252  },
1253  "anchors": {
1254    "anchor/bell": { "pos": [3, 1, 4], "facing": "north" },
1255    "anchor/ward": { "region": { "from": [0, 0, 0], "to": [2, 2, 2] }, "block": "minecraft:stone" }
1256  },
1257  "connectors": [],
1258  "lighting": { "profile": "unmeasured" },
1259  "license": {
1260    "source": "original",
1261    "spdx": "GPL-3.0-or-later",
1262    "note": "n",
1263    "provenance": "p",
1264    "generated_by": {
1265      "generator": "grammar",
1266      "program": "bell_chapel_ward",
1267      "program_hash": "sha256:00",
1268      "seed": 1,
1269      "region": [11, 6, 13]
1270    }
1271  },
1272  "waterline_y": 2
1273}
1274"#;
1275        let mut meta = PrefabMeta::from_json(text).unwrap();
1276        meta.lighting = Some(Lighting {
1277            profile: crate::registry::LightingProfile::Dark,
1278            measured_min_light: Some(0),
1279            measured: Some("2026-08-11".to_string()),
1280            rationale: None,
1281            method: Some("static estimate".to_string()),
1282        });
1283        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
1284        let before: serde_json::Value = serde_json::from_str(text).unwrap();
1285        assert_eq!(
1286            after["license"]["generated_by"], before["license"]["generated_by"],
1287            "the provenance row must survive an edit to an unrelated block"
1288        );
1289        assert_eq!(after["anchors"], before["anchors"]);
1290        assert_eq!(after["structure"], before["structure"]);
1291        assert_eq!(
1292            after["waterline_y"], before["waterline_y"],
1293            "a declared waterline must survive an edit to an unrelated block"
1294        );
1295    }
1296
1297    /// The same guarantee for a key no version of this type has ever heard of.
1298    /// This is the general form of the `waterline_y` and `generated_by` losses:
1299    /// the type cannot enumerate what has not been invented, so it keeps it.
1300    #[test]
1301    fn a_key_this_version_does_not_model_survives_the_round_trip() {
1302        let text = r#"{
1303  "prefab_id": "prefab/x",
1304  "structure": { "file": "x.nbt", "id": "x", "size": [3, 3, 3], "data_version": 4671 },
1305  "anchors": { "anchor/a": { "pos": [1, 1, 1], "acoustics": "reverberant" } },
1306  "connectors": [],
1307  "lighting": { "profile": "unmeasured" },
1308  "from_the_future": { "nested": [1, 2, 3] }
1309}
1310"#;
1311        let mut meta = PrefabMeta::from_json(text).unwrap();
1312        assert_eq!(
1313            meta.unknown_keys(),
1314            vec![("", "from_the_future"), ("anchor/a", "acoustics")],
1315            "both unknown keys must be reportable, top level and per anchor"
1316        );
1317        meta.connectors.push(Connector {
1318            name: "keep:socket".to_string(),
1319            target: "keep:socket".to_string(),
1320            local_pos: [0, 0, 0],
1321            facing: "north".to_string(),
1322            opening: [3, 3],
1323            joint: "aligned".to_string(),
1324        });
1325        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
1326        let before: serde_json::Value = serde_json::from_str(text).unwrap();
1327        assert_eq!(after["from_the_future"], before["from_the_future"]);
1328        assert_eq!(
1329            after["anchors"]["anchor/a"]["acoustics"],
1330            before["anchors"]["anchor/a"]["acoustics"]
1331        );
1332    }
1333
1334    /// The same guarantee **inside** an anchor, which is where the document's
1335    /// round trip is finest-grained and where the top-level guarantee above says
1336    /// nothing at all.
1337    ///
1338    /// An editing step that re-annotates an anchor already on the piece names
1339    /// only where it is. The dispenser cell the prefab wired, the trigger block
1340    /// it must put back, the contract element the exporter resolved, and a key
1341    /// no version has heard of are all properties of the anchor and not of the
1342    /// edit — so all four survive, and only the place changes.
1343    #[test]
1344    fn re_annotating_an_anchor_keeps_the_hardware_the_piece_carries() {
1345        let text = r#"{
1346  "prefab_id": "prefab/trap-room",
1347  "structure": { "file": "trap-room.nbt", "id": "trap-room", "size": [7, 5, 7], "data_version": 4671 },
1348  "anchors": {
1349    "anchor/trap": {
1350      "pos": [3, 1, 3],
1351      "facing": "north",
1352      "resolves_to": "space:hall",
1353      "dispenser": [3, 2, 4],
1354      "trigger_block": "minecraft:oak_pressure_plate[powered=false]",
1355      "acoustics": "reverberant"
1356    }
1357  },
1358  "connectors": [],
1359  "lighting": { "profile": "unmeasured" }
1360}
1361"#;
1362        let mut meta = PrefabMeta::from_json(text).unwrap();
1363        meta.edit_anchor(
1364            "anchor/trap",
1365            AnchorEdit {
1366                pos: Some([4, 1, 3]),
1367                facing: Some("south".to_string()),
1368                ..AnchorEdit::default()
1369            },
1370        );
1371        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
1372        let a = &after["anchors"]["anchor/trap"];
1373        assert_eq!(
1374            a["pos"],
1375            serde_json::json!([4, 1, 3]),
1376            "the place is edited"
1377        );
1378        assert_eq!(a["facing"], serde_json::json!("south"));
1379        assert_eq!(
1380            a["dispenser"],
1381            serde_json::json!([3, 2, 4]),
1382            "the pre-wired dispenser cell is the piece's hardware, not the edit's"
1383        );
1384        assert_eq!(
1385            a["trigger_block"],
1386            serde_json::json!("minecraft:oak_pressure_plate[powered=false]"),
1387            "flag-gating a trap has to put this exact block back"
1388        );
1389        assert_eq!(a["resolves_to"], serde_json::json!("space:hall"));
1390        assert_eq!(
1391            a["acoustics"],
1392            serde_json::json!("reverberant"),
1393            "a key this version does not model is the anchor's too"
1394        );
1395
1396        // A gate anchor's region and a point anchor's cell are one property, so
1397        // naming the cell supersedes the region rather than leaving both.
1398        meta.edit_anchor(
1399            "anchor/gate",
1400            AnchorEdit {
1401                region: Some(Region {
1402                    from: [0, 0, 0],
1403                    to: [1, 2, 0],
1404                }),
1405                block: Some("minecraft:iron_bars".to_string()),
1406                ..AnchorEdit::default()
1407            },
1408        );
1409        meta.edit_anchor(
1410            "anchor/gate",
1411            AnchorEdit {
1412                pos: Some([0, 1, 0]),
1413                ..AnchorEdit::default()
1414            },
1415        );
1416        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
1417        let g = &after["anchors"]["anchor/gate"];
1418        assert_eq!(g["pos"], serde_json::json!([0, 1, 0]));
1419        assert!(g.get("region").is_none(), "{g}");
1420        assert!(g.get("block").is_none(), "{g}");
1421    }
1422
1423    /// A tiled zone's manifest is a prefab document like any other: it is read,
1424    /// one block of it is edited, and it is written back whole — and the keys
1425    /// this version does not model survive.
1426    ///
1427    /// It used to be a *second type* (`TileSetMeta`), field-for-field this one.
1428    /// The copy is what this test is really about: the same round trip, on the
1429    /// same struct, is what makes a block added here reach both packagings.
1430    #[test]
1431    fn a_tile_set_manifest_round_trips_through_an_edit() {
1432        let text = r#"{
1433  "prefab_id": "prefab/notre-dame",
1434  "structure_set": {
1435    "base": "notre-dame",
1436    "size": [31, 48, 93],
1437    "part_max": 48,
1438    "grid": [1, 1, 2],
1439    "data_version": 4671,
1440    "generator": "crates/grammar",
1441    "parts": [
1442      { "file": "notre-dame.x0y0z0.nbt", "id": "a", "grid_index": [0,0,0], "offset": [0,0,0], "size": [31,48,48] },
1443      { "file": "notre-dame.x0y0z1.nbt", "id": "b", "grid_index": [0,0,1], "offset": [0,0,48], "size": [31,48,45] }
1444    ]
1445  },
1446  "anchors": { "anchor/crossing": { "pos": [15, 1, 56], "facing": "south" } },
1447  "connectors": [],
1448  "lighting": { "profile": "unmeasured" },
1449  "waterline_y": 12,
1450  "license": {
1451    "source": "original",
1452    "spdx": "GPL-3.0-or-later",
1453    "note": "n",
1454    "provenance": "p",
1455    "generated_by": {
1456      "generator": "grammar",
1457      "program": "nd",
1458      "program_hash": "sha256:00",
1459      "seed": 1,
1460      "region": [3, 3, 3]
1461    }
1462  },
1463  "a_key_no_engine_models": { "kept": true }
1464}
1465"#;
1466        let mut meta = PrefabMeta::from_json(text).unwrap();
1467        assert!(meta.is_tiled());
1468        assert_eq!(meta.size(), [31, 48, 93]);
1469        assert_eq!(meta.data_version(), Some(4671));
1470        assert_eq!(meta.license.as_ref().unwrap().spdx, "GPL-3.0-or-later");
1471        // The whole point of one document: a block the copy had lost is here.
1472        assert_eq!(meta.waterline_y, Some(12));
1473
1474        // The templates, in grid order, with their piece-local offsets.
1475        let templates = meta.templates();
1476        assert_eq!(templates.len(), 2);
1477        assert_eq!(templates[0].file, "notre-dame.x0y0z0.nbt");
1478        assert_eq!(templates[0].offset, [0, 0, 0]);
1479        assert_eq!(templates[1].id, "b");
1480        assert_eq!(templates[1].offset, [0, 0, 48]);
1481        assert_eq!(templates[1].size, [31, 48, 45]);
1482
1483        meta.lighting = Some(Lighting {
1484            profile: crate::registry::LightingProfile::Lit,
1485            measured_min_light: Some(6),
1486            measured: Some(String::new()),
1487            rationale: None,
1488            method: Some("static estimate".to_string()),
1489        });
1490        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
1491        let before: serde_json::Value = serde_json::from_str(text).unwrap();
1492        assert_eq!(after["license"], before["license"]);
1493        assert_eq!(after["structure_set"], before["structure_set"]);
1494        assert_eq!(after["anchors"], before["anchors"]);
1495        assert_eq!(after["waterline_y"], before["waterline_y"]);
1496        assert_eq!(after["lighting"]["profile"], "lit");
1497        assert!(
1498            after.get("structure").is_none(),
1499            "a tiled document must not grow an empty `structure` key: {after}"
1500        );
1501        // Reading is total here too: a key this version has never heard of
1502        // survives an edit rather than being deleted by the tool that made it.
1503        assert_eq!(
1504            after["a_key_no_engine_models"], before["a_key_no_engine_models"],
1505            "an unmodelled key must survive a read-modify-write"
1506        );
1507    }
1508
1509    /// A single-template piece is one template at the origin, so nothing that
1510    /// places blocks has to ask which packaging it was handed.
1511    #[test]
1512    fn a_single_template_piece_is_one_template_at_the_origin() {
1513        let text = r#"{
1514  "prefab_id": "prefab/x",
1515  "structure": { "file": "x.nbt", "id": "x", "size": [3, 4, 5], "data_version": 4671 }
1516}
1517"#;
1518        let meta = PrefabMeta::from_json(text).unwrap();
1519        assert!(!meta.is_tiled());
1520        assert_eq!(meta.size(), [3, 4, 5]);
1521        assert_eq!(
1522            meta.templates(),
1523            vec![PieceTemplate {
1524                id: "x",
1525                file: "x.nbt",
1526                offset: [0, 0, 0],
1527                size: [3, 4, 5],
1528            }]
1529        );
1530    }
1531
1532    /// "Which shape is this" has exactly two answers and no third: a document
1533    /// with neither block, and one with both, are refusals rather than a
1534    /// half-read document handed to a step that places some of its blocks.
1535    #[test]
1536    fn a_document_that_does_not_say_what_blocks_it_describes_is_refused() {
1537        let err = PrefabMeta::from_json(r#"{"prefab_id":"prefab/x"}"#).unwrap_err();
1538        assert!(err.contains("structure_set"), "{err}");
1539        assert!(err.contains("structure"), "{err}");
1540
1541        let both = r#"{
1542  "prefab_id": "prefab/x",
1543  "structure": { "file": "x.nbt", "id": "x", "size": [3, 3, 3], "data_version": 4671 },
1544  "structure_set": {
1545    "base": "x", "size": [3, 3, 3], "part_max": 48, "grid": [1, 1, 1],
1546    "data_version": 4671, "generator": "g",
1547    "parts": [ { "file": "x.x0y0z0.nbt", "id": "x0", "grid_index": [0,0,0], "offset": [0,0,0], "size": [3,3,3] } ]
1548  }
1549}
1550"#;
1551        let err = PrefabMeta::from_json(both).unwrap_err();
1552        assert!(err.contains("BOTH"), "{err}");
1553    }
1554
1555    /// A manifest that does not tile its own zone is refused **by the reader**,
1556    /// so every consumer of the document meets it at the same place — and none
1557    /// of them reassembles a building with a hole in it and reports success.
1558    #[test]
1559    fn a_manifest_that_does_not_tile_its_zone_is_refused_by_the_reader() {
1560        let text = r#"{
1561  "prefab_id": "prefab/holed",
1562  "structure_set": {
1563    "base": "holed", "size": [4, 4, 100], "part_max": 48, "grid": [1, 1, 1],
1564    "data_version": 4671, "generator": "g",
1565    "parts": [ { "file": "holed.x0y0z0.nbt", "id": "h0", "grid_index": [0,0,0], "offset": [0,0,0], "size": [4,4,48] } ]
1566  }
1567}
1568"#;
1569        let err = PrefabMeta::from_json(text).unwrap_err();
1570        assert!(err.contains("cover"), "{err}");
1571        assert!(err.contains("hole"), "{err}");
1572    }
1573
1574    /// A piece nothing has regenerated has no row, and the key is absent rather
1575    /// than `null` — `null` reads as "measured, and the answer is nothing".
1576    #[test]
1577    fn absent_optional_fields_are_omitted_not_nulled() {
1578        let meta = PrefabMeta::skeleton(
1579            "ingested",
1580            [3, 3, 3],
1581            4671,
1582            "delvec prefab (external admission)",
1583            License {
1584                source: "unknown".to_string(),
1585                spdx: "UNKNOWN".to_string(),
1586                note: String::new(),
1587                provenance: String::new(),
1588                generated_by: None,
1589            },
1590        );
1591        let json = meta.to_json();
1592        assert!(!json.contains("generated_by"), "{json}");
1593        assert!(!json.contains("null"), "{json}");
1594        assert!(json.contains("\"connectors\": []"), "{json}");
1595        assert_eq!(PrefabMeta::from_json(&json).unwrap(), meta);
1596    }
1597
1598    /// The role vocabulary is closed **by the type**, so a term this engine does
1599    /// not know is a parse failure naming the terms it does — not a string
1600    /// carried into a resolution that then silently never matches.
1601    ///
1602    /// The mechanism is worth pinning rather than assuming: [`Anchor`] carries
1603    /// a `#[serde(flatten)]` catch-all, which buffers the whole object, and a
1604    /// buffered unknown enum variant is easy to believe would land in `extra`
1605    /// instead of erroring. It does not.
1606    #[test]
1607    fn a_role_outside_the_vocabulary_is_refused_by_name() {
1608        let anchor: Anchor =
1609            serde_json::from_str(r#"{"pos": [1, 2, 3], "role": "entry"}"#).unwrap();
1610        assert_eq!(anchor.role, Some(AnchorRole::Entry));
1611        assert!(anchor.extra.is_empty(), "a modelled key is not `extra`");
1612
1613        let err = serde_json::from_str::<Anchor>(r#"{"pos": [1, 2, 3], "role": "spawn"}"#)
1614            .expect_err("`spawn` is a NAME, and was never a role");
1615        let msg = err.to_string();
1616        assert!(msg.contains("unknown variant"), "{msg}");
1617        for role in AnchorRole::ALL {
1618            assert!(
1619                msg.contains(role.as_str()),
1620                "the refusal lists `{role}`: {msg}"
1621            );
1622        }
1623    }
1624
1625    /// An anchor's `note` is a modelled key, so a piece that explains its own
1626    /// places is not a piece reporting `DW0543` six times a build.
1627    ///
1628    /// Both halves matter and only one is obvious. It must PARSE into the field
1629    /// (or `unknown_keys` reports it, which is the defect), and it must survive
1630    /// a round trip unchanged — a modelled key that serialises back differently
1631    /// would move every prefab document the first time anything rewrote one.
1632    #[test]
1633    fn an_anchor_note_is_modelled_and_is_not_an_unknown_key() {
1634        let text = r#"{
1635  "prefab_id": "prefab/x",
1636  "structure": { "file": "x.nbt", "id": "x", "size": [3, 3, 3], "data_version": 4671 },
1637  "anchors": { "anchor/a": { "pos": [1, 1, 1], "note": "where a wave forms up" } },
1638  "connectors": [],
1639  "lighting": { "profile": "unmeasured" }
1640}
1641"#;
1642        let meta = PrefabMeta::from_json(text).unwrap();
1643        assert_eq!(
1644            meta.anchors["anchor/a"].note.as_deref(),
1645            Some("where a wave forms up")
1646        );
1647        assert_eq!(
1648            meta.unknown_keys(),
1649            Vec::<(&str, &str)>::new(),
1650            "`note` is modelled, so nothing is left for `DW0543` to report"
1651        );
1652        assert_eq!(
1653            serde_json::from_str::<serde_json::Value>(&meta.to_json()).unwrap(),
1654            serde_json::from_str::<serde_json::Value>(text).unwrap()
1655        );
1656        // An anchor that says nothing writes no key: every piece admitted before
1657        // the field stays byte-for-byte what it was.
1658        let plain = serde_json::to_string(&Anchor::point([1, 2, 3], "north")).unwrap();
1659        assert!(!plain.contains("note"), "{plain}");
1660    }
1661
1662    /// An anchor that declares no role writes no key, which is what keeps every
1663    /// piece in the shipped library byte-for-byte what it was (spec-0046 §4.5).
1664    #[test]
1665    fn an_anchor_without_a_role_writes_no_role_key() {
1666        let plain = serde_json::to_string(&Anchor::point([1, 2, 3], "north")).unwrap();
1667        assert!(!plain.contains("role"), "{plain}");
1668        let declared =
1669            serde_json::to_string(&Anchor::point([1, 2, 3], "north").with_role(AnchorRole::Entry))
1670                .unwrap();
1671        assert!(declared.contains(r#""role":"entry""#), "{declared}");
1672    }
1673
1674    /// A role typed at a command line goes through the **same** closed table a
1675    /// role written into a document does, so the two cannot come to know
1676    /// different terms — and a term the engine does not know is refused with
1677    /// both the term and the vocabulary in the message.
1678    #[test]
1679    fn a_role_parsed_from_a_word_is_the_same_vocabulary_as_a_role_read_from_a_document() {
1680        for role in AnchorRole::ALL {
1681            assert_eq!(role.as_str().parse::<AnchorRole>(), Ok(*role));
1682        }
1683        let err = "dispenser".parse::<AnchorRole>().expect_err("not a term");
1684        assert!(err.contains("dispenser"), "{err}");
1685        for role in AnchorRole::ALL {
1686            assert!(err.contains(role.as_str()), "{err}");
1687        }
1688        // The name the compiler once matched is a name and has never been a
1689        // role, so it is refused here exactly as it is refused by `serde`.
1690        assert!("spawn".parse::<AnchorRole>().is_err());
1691    }
1692
1693    /// `edit_anchor`'s role is TRI-state, and the middle state is the one that
1694    /// matters: an edit that says nothing about the role keeps it. A tool that
1695    /// moved an anchor's cell would otherwise delete the piece's entry point,
1696    /// which is the silent deletion [`AnchorEdit`] exists to prevent.
1697    #[test]
1698    fn an_edit_that_says_nothing_about_the_role_keeps_it() {
1699        let mut meta = PrefabMeta::skeleton(
1700            "x",
1701            [3, 3, 3],
1702            4671,
1703            "test",
1704            License {
1705                source: "original".to_string(),
1706                spdx: "GPL-3.0-or-later".to_string(),
1707                note: String::new(),
1708                provenance: String::new(),
1709                generated_by: None,
1710            },
1711        );
1712        let place = |role| AnchorEdit {
1713            pos: Some([1, 1, 1]),
1714            role,
1715            ..AnchorEdit::default()
1716        };
1717
1718        meta.edit_anchor("anchor/a", place(Some(Some(AnchorRole::Entry))));
1719        assert_eq!(meta.anchors["anchor/a"].role, Some(AnchorRole::Entry));
1720
1721        // Silent: kept.
1722        meta.edit_anchor(
1723            "anchor/a",
1724            AnchorEdit {
1725                pos: Some([2, 1, 1]),
1726                ..AnchorEdit::default()
1727            },
1728        );
1729        assert_eq!(meta.anchors["anchor/a"].pos, Some([2, 1, 1]));
1730        assert_eq!(meta.anchors["anchor/a"].role, Some(AnchorRole::Entry));
1731
1732        // Said to have none: removed — the remedy `DW0804` prescribes.
1733        meta.edit_anchor("anchor/a", place(Some(None)));
1734        assert_eq!(meta.anchors["anchor/a"].role, None);
1735    }
1736}