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