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