delvewright-dsl 0.22.0

Staged JSON DSL types and schemas for Delvewright adventure-map campaigns — the format the delvec compiler reads.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
//! The prefab metadata document (`<id>.json`, beside the structure `.nbt`) —
//! the **one** definition of its shape.
//!
//! A prefab is a *pair* of files: a gzip-framed structure template and this
//! sibling JSON that says what the template is, where its anchors and sockets
//! are, how lit it is, what it claims about the space inside it, and what
//! regenerates it. Both halves are produced and consumed by several tools of
//! several ages — the grammar back end and the hand-written generators write the
//! pair from scratch, `delvec prefab` reads it and writes it back after every
//! admission step, `delvec` reads it to plan a world, `delvec render` reads it to
//! aim a camera — so the document's shape is defined once, here, and every one
//! of them reads that definition instead of a copy of it.
//!
//! # Why the definition lives in the DSL crate
//!
//! Not because prefab metadata is DSL surface — it is a library-asset document —
//! but because this is the only crate every reader can depend on. `delvec` is
//! published to crates.io and may only depend on published crates, and this crate
//! is the one it already depends on. The alternative was a copy inside `delvec`,
//! which is what existed and what this module replaces. The crate already owns
//! the document's `lighting` block ([`crate::registry::Lighting`], whose field
//! names are this file's field names) and the anchor surface DSL validation
//! resolves refs against ([`crate::registry::AnchorRegistry`]), so the document's
//! remaining blocks join a shape that was already half here.
//!
//! # Reading is total, writing preserves
//!
//! Every field a producer may legitimately omit is `Option`/`default` and is
//! omitted (never `null`) on write, so a legacy prefab that predates a field
//! still loads and a piece that has never been probed does not have to invent a
//! measurement. Field order is the emission order, and it is the order the
//! library's checked-in prefabs already use, so a reviewer diffing a generated
//! piece against a hand-built one sees only values change.
//!
//! Keys this version has never heard of are **kept**, in [`PrefabMeta::extra`]
//! and [`Anchor::extra`], and written back out. That is not politeness to the
//! future; it is the only behaviour that is neither an outage nor silent data
//! loss. See the `deny_unknown_fields` note below.
//!
//! # `deny_unknown_fields`, decided rather than inherited
//!
//! The attribute is right on a document whose reader is also its **owner**: a
//! campaign stage document is authored against a versioned schema, a typo there
//! is the bug the attribute exists to catch, and forward compatibility is
//! handled by the `dsl_version` fence instead. Every stage struct in
//! [`crate::stages`] keeps it for exactly that reason.
//!
//! It is wrong on a **consumer that is not the owner**, which is what every
//! reader of this document is. Here a new key is not a typo — it is a newer
//! producer meeting an older reader, which happens on every mixed-version pair
//! of engine and content library. Refusing turns a forward addition into a hard
//! failure at the layer with the least context; the compiler's private copy of
//! this shape did exactly that, and the first grammar-exported prefab carrying a
//! new key would have failed every campaign build.
//!
//! Tolerating alone is not the fix either, because a tool that reads this
//! document, edits one block and writes it back deletes everything it does not
//! model — and does so while every test it has passes. That is not
//! hypothetical: `license.generated_by` was dropped that way once, and
//! `waterline_y` — a field five shipped island prefabs carry and the
//! ocean-horizon placement check keys off — was being dropped that way at the
//! time this module was written.
//!
//! So the rule is: **this document's structs neither refuse an unknown key nor
//! discard it.** They keep it, and the reader that wants to say something about
//! it says it as a diagnostic (`DW0543`) rather than as a parse failure. The
//! blocks whose own definition lives elsewhere are the exception and say why at
//! their field.

use std::collections::BTreeMap;
use std::path::Path;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::registry::Lighting;
use crate::split::TileSet;

/// The one `.json` in a prefab library that is **not** a prefab document:
/// the pool declaration (`{"pools": {...}}`), read by the compiler's registry.
///
/// Named once because more than one tool walks the library directory — the
/// registry, `delvec view`'s page builder, `delvec render batch` — and each of
/// them opens every `.json` it finds. A walker that does not know this name
/// hands a pool file to [`PrefabMeta::from_json`] and reports it as a malformed
/// prefab, which is a true statement about the bytes and a wrong one about the
/// file.
pub const POOLS_FILE: &str = "pools.json";

/// What the exported prefab-metadata schema tells its reader the document IS —
/// stated on the schema rather than only in a reference document, because the
/// schema is what an author actually opens.
const SCHEMA_DESCRIPTION: &str = "\
A prefab's sibling metadata file, `<prefab-id>.json`, beside the structure \
`.nbt` in a prefab library.

A LIBRARY ASSET, NOT A CAMPAIGN STAGE DOCUMENT. It carries no `dsl_version`, no \
`campaign_id` and no `stage`; it is not authored against the campaign DSL's \
staging (ADR-0002) and is therefore absent from `--stage all`.

Three of its declarations are the piece's claims about its own outside, and \
they are different claims rather than one claim written three ways \
(spec-0060 §4):

  `walk_y`       the piece's own walk plane, in local y. Owed on every base. \
It is the number an area's origin is DERIVED from where the horizon's datum is \
a walk plane, so it has no default: a default would be right for the tileset it \
was copied from and silently wrong for every other. Missing, a campaign that \
seats the piece is refused with `DW0886`.

  `waterline_y`  the local y of the piece's TOP AUTHORED WATER BLOCK. Owed only \
where the piece really writes water that meets a sea. It is a claim about the \
bytes and is checked against them (`DW0887`), and its placement is checked \
against sea level (`DW0344`). A piece that authors no water has no waterline to \
state, and writing one anyway is a fiction the engine refuses.

  `shown_faces`  which of the piece's six sides are finished exterior surface. \
Absent means NO side is shown, which is the strict answer: a piece authored to \
be buried writes nothing here, and what discharges its obligation is the world \
burying it (`DW0885`).

Keys this engine does not model are KEPT and written back out, so a newer \
producer meeting an older reader is a `DW0543` warning rather than a parse \
failure. The `lighting` block is the one exception and refuses a key it does \
not know.
";

/// A prefab's sibling metadata file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PrefabMeta {
    /// The DSL prefab id, `prefab/<id>`.
    pub prefab_id: String,
    /// The structure-template reference, for a piece whose blocks fit one
    /// template.
    ///
    /// Exactly one of this and [`Self::structure_set`] is present — see the
    /// type's own note on the two packagings, and [`Self::from_json`], which is
    /// where "exactly one" is enforced.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub structure: Option<StructureMeta>,
    /// The tile set, for a piece whose blocks did not fit one template.
    ///
    /// **Packaging, not authoring.** A zone past the 48-per-axis structure cap
    /// ships as several `.nbt` files plus this manifest; everything else about
    /// the document — the id, the zone-local `anchors`, the `connectors`, the
    /// one `lighting` block, the one provenance row — is what it is for a
    /// single-template piece, because it describes the same building. Nothing
    /// that refers to a piece may ask which of the two it is: read
    /// [`Self::templates`].
    ///
    /// This was a second document type (`TileSetMeta`, in the schem crate),
    /// field-for-field this one with `structure` swapped for `structure_set`.
    /// The copy had already lost `waterline_y`, so a tiled shore could not
    /// declare the waterline the ocean-horizon invariant (`DW0344`) keys off and
    /// went silently unchecked. One document is what makes that class of drift
    /// unrepresentable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub structure_set: Option<TileSet>,
    /// Named anchors, keyed by DSL anchor name. `{}` for a piece that declares
    /// none.
    #[serde(default)]
    pub anchors: BTreeMap<String, Anchor>,
    /// Jigsaw sockets. `[]` for a piece that is placed directly rather than
    /// drawn from a pool.
    #[serde(default)]
    pub connectors: Vec<Connector>,
    /// The lighting declaration.
    ///
    /// Absent means legacy metadata that predates the field, which is a
    /// different claim from `{"profile": "unmeasured"}` — the positive statement
    /// that a measurement is owed.
    ///
    /// The block's own shape is [`Lighting`], and it is **the one part of this
    /// document that still refuses a key it does not know**. Its job is a rule
    /// about values — a measured profile must carry its measurement, an
    /// `unmeasured` one must not — so a misspelled measurement key there is a
    /// claim quietly becoming its own absence, which the profile/measurement
    /// agreement alone does not catch for `rationale` or `method`. The cost is
    /// real and is stated where an author will meet it
    /// (`docs/reference/prefab-procedure.md` §9): a key added inside `lighting`
    /// is a hard parse failure for an older engine, so adding one is a
    /// `dsl_version` matter rather than a metadata edit.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lighting: Option<Lighting>,
    /// Licence, provenance prose, and the machine-readable provenance row.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub license: Option<License>,
    /// **The local y of this piece's own walk plane** — the cell a body's feet
    /// occupy when it stands on the piece's principal floor (spec-0060 §4).
    ///
    /// This is the number an area's origin is DERIVED from on a horizon whose
    /// datum is a walk plane: an `ocean` world's walk plane is `SEA_LEVEL + 1`,
    /// so an area seating this piece is placed at `walk_ref_y - walk_y` and the
    /// piece stands one block above the sea, which is the vanilla-normal beach
    /// relationship. A keep interior declaring `1` is seated at 62 and stands
    /// dry at 63; an island piece declaring `3` is seated at 60.
    ///
    /// **It has no default, and that is the decision** (spec-0060 §4.1). A
    /// default is the retired global datum wearing a different name: it would
    /// be right for the one tileset it was copied from and silently wrong for
    /// every other, and the piece that lands under the sea because of it floods
    /// on boot with nothing looking. A piece a campaign seats without one is
    /// `DW0886`.
    ///
    /// It is a MEASUREMENT of the piece, so it is written by the generator that
    /// built the piece and never typed by hand
    /// (`CLAUDE.md`: a census derivable from the object is never hand-written).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub walk_y: Option<i32>,
    /// The local y of this piece's **top authored water block** — its waterline
    /// — for open-air pieces built to a tileset convention that authors a sea.
    ///
    /// Two rules read it, and they ask different questions. `DW0887` asks
    /// whether the claim is TRUE — whether the piece's own bytes put a water
    /// block at that plane and none above it — and refuses a declaration that
    /// is a fiction wherever the document and its `.nbt` are read together.
    /// `DW0344` asks whether the PLACEMENT honours it: in a `horizon: ocean`
    /// world the declared waterline must land at world sea level.
    ///
    /// Absent for pieces that author no sea, which neither rule then judges. A
    /// piece that authors water and declares nothing is not refused by
    /// `DW0887`; under `ocean` its placement is `DW0344`'s subject and under
    /// `void` its runoff is `DW0318`'s.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub waterline_y: Option<i32>,
    /// **The sides of this piece the player is meant to see** — the piece's own
    /// claim that a given face is finished exterior surface rather than the cut
    /// edge of something that belongs inside a hill.
    ///
    /// Local side names, in the piece's own frame, from the same six-word
    /// vocabulary [`ContractFace::dir`] uses: `east` `west` `up` `down` `south`
    /// `north`. They turn with the placement, so a piece rotated a quarter turn
    /// shows the side it was built to show.
    ///
    /// It is the third thing a piece says about its own outside, and the three
    /// are different claims about the same object rather than one claim written
    /// three ways: [`Self::waterline_y`] says where the piece meets the sea,
    /// [`SpatialContract::faces`] says where a body crosses a side, and this
    /// says which sides are finished. None of the others can stand in for it —
    /// a cave with a mouth declares one `walk` face and is still a block of rock
    /// on the other five — which is why `DW0885` reads this and not them.
    ///
    /// **Absent means no side is shown**, and that is the load-bearing default:
    /// a piece authored to be buried is exactly a piece that writes nothing
    /// here, so the silence has to be the strict answer or the defect declares
    /// itself by omission. What discharges the obligation for such a piece is
    /// the world burying it, which is geometry the declaration cannot fake.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shown_faces: Vec<String>,
    /// The piece's spatial contract, when it declares one.
    ///
    /// Absent means legacy metadata — the piece makes no spatial claim — exactly
    /// as an absent `lighting` block differs from `unmeasured`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub spatial_contract: Option<SpatialContract>,
    /// **The size class of box this piece is built to fill** — a name from the
    /// metrics table's `size-class.*` ladder (spec-0050 §5).
    ///
    /// Optional for the library at large, and that is deliberate rather than
    /// lax: every piece in the library predates the field, and `DW0848` binds
    /// only where the claim is made. What is *not* optional is the claim being
    /// true — a piece declaring a class its own bytes could serve no box of is
    /// refused at admission and again wherever a detail plan consumes it, so a
    /// pre-check-era piece cannot be consumed unjudged.
    ///
    /// Absent means what absence means everywhere in this document: the claim is
    /// not made. A piece bound by a `details[]` row is still checked for exact
    /// frame equality (`DW0843`) whether or not it declares — that is the
    /// consumer's exact check, and this is the library's approximate one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub footprint_class: Option<String>,
    /// Every top-level key this version does not model, kept verbatim so that
    /// reading and writing the document is not the same as editing it.
    ///
    /// A reader that wants to report one has it in hand; a reader that does not
    /// care carries it through. Emitted after the modelled keys, in key order.
    #[serde(flatten)]
    pub extra: BTreeMap<String, serde_json::Value>,
}

/// A piece's declared spaces, out-of-walk regions and edges, **already
/// resolved**: every box is a local cell range of these exact bytes.
///
/// Resolved rather than parametric on purpose. A grammar program's declarations
/// are scope-bound and mean different boxes at different parameters, so the only
/// contract that can describe *this* `.nbt` is the one its own expansion
/// produced. That is also what lets a hand-built piece carry the same block: it
/// has no parameters to resolve, so the two routes write the same shape and one
/// reader serves both.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SpatialContract {
    /// The space a body enters at.
    pub entry: String,
    /// Named spaces.
    #[serde(default)]
    pub spaces: BTreeMap<String, ContractSpace>,
    /// Named standable-but-out-of-walk regions.
    #[serde(default)]
    pub no_body: BTreeMap<String, ContractNoBody>,
    /// The graph, in declaration order.
    #[serde(default)]
    pub edges: Vec<ContractEdge>,
    /// **The piece's face contract**: every `exterior` edge, as the side of the
    /// piece it is on and the opening it leaves there.
    ///
    /// Derived from the edges and the blocks at export time and written out, so
    /// that assembly can ask whether two pieces fit without opening either
    /// `.nbt`. It is the thing an `exterior` edge IS from the outside: an edge
    /// with no cells is a claim nothing can mate with, and one whose opening
    /// does not answer its neighbour's is two pieces that were each approved
    /// alone and do not assemble.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub faces: Vec<ContractFace>,
    /// The author's acknowledgement that this piece is mostly out-of-walk.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub no_body_majority_ack: Option<String>,
}

/// `DW0848`: a piece's declared footprint class disagrees with its bytes.
pub const DW_FOOTPRINT_CLASS: crate::DwCode = crate::DwCode::new("DW0848", crate::ExitTier::Build);

/// **Judge a piece's declared `footprint_class` against its own structure
/// size.**
///
/// One authority with two doors, on the pattern spec-0036 §1c fixed for the
/// spatial contract: `delvec prefab audit` asks it at the admission event, where
/// the library's integrity lives, and `delvec::compiler::detail` asks it
/// again wherever a `detail-plan` row consumes the piece. Two implementations
/// that agreed until they did not is the failure this shape removes.
///
/// What it asks, and each half is a fact about the geometry rather than a
/// preference:
///
/// 1. **The name is in the table** — otherwise `DW0812`, as for any document
///    naming a table entry.
/// 2. **The horizontal extents could be a box of that class.** A detail frame's
///    footprint IS its box's footprint (`Frame::of` grows the play space
///    downward only), so a piece whose `x` or `z` falls outside the class's
///    `min_footprint..=max_footprint` could fill no box of it.
/// 3. **They sit on the kit grid.** A site-plan box's extent is a multiple of
///    the grid quantum (`DW0825`), so a piece off the grid could fill no box at
///    all, of any class.
/// 4. **The height leaves the class its clearance.** A frame is the play space
///    plus one floor course, so a piece under `min_clearance + 1` is short of
///    the shallowest box of its class.
///
/// Returns `None` for a piece that declares no class — which is the honest
/// answer, and why the caller states how many pieces declared one against how
/// many it examined.
#[must_use]
pub fn check_footprint_class(
    meta: &PrefabMeta,
    stage: &str,
    path: &str,
    reads: &mut crate::metrics::Reads,
) -> Option<crate::Diagnostic> {
    let named = meta.footprint_class.as_deref()?;
    let table = crate::metrics::Metrics::table();
    let entry = match table.resolve(crate::metrics::MetricKind::SizeClass, named) {
        Ok(e) => e,
        Err(unknown) => return Some(unknown.diagnostic(stage, path)),
    };
    let crate::metrics::MetricValue::SizeClass(class) = *entry.value(reads) else {
        return None; // an internal table defect, which `Metrics::self_check` owns.
    };
    let size = meta.size();
    let grid = table.grid(reads);
    let q = grid.map_or(1, |g| i64::from(g.quantum).max(1));
    let (sx, sy, sz) = (i64::from(size[0]), i64::from(size[1]), i64::from(size[2]));
    let (minf, maxf) = (class.min_footprint, class.max_footprint);
    let mut why: Vec<String> = Vec::new();
    if sx < i64::from(minf[0]) || sx > i64::from(maxf[0]) {
        why.push(format!(
            "its x extent is {sx}, and a `{named}` box is {}..={} on x",
            minf[0], maxf[0]
        ));
    }
    if sz < i64::from(minf[1]) || sz > i64::from(maxf[1]) {
        why.push(format!(
            "its z extent is {sz}, and a `{named}` box is {}..={} on z",
            minf[1], maxf[1]
        ));
    }
    if sx % q != 0 || sz % q != 0 {
        why.push(format!(
            "its footprint {sx}x{sz} is off the kit grid, whose quantum is {q} — every site-plan \
             box's extent is a multiple of it (`DW0825`)"
        ));
    }
    let least = i64::from(class.min_clearance) + 1;
    if sy < least {
        why.push(format!(
            "it is {sy} cells tall, and the shallowest `{named}` frame is {least} — {} of \
             clearance plus the one floor course a piece owns",
            class.min_clearance
        ));
    }
    if why.is_empty() {
        return None;
    }
    Some(crate::Diagnostic::error(
        DW_FOOTPRINT_CLASS,
        stage,
        path,
        format!(
            "`{id}` declares `footprint_class: \"{named}\"` and its own bytes could serve no box \
             of that class: {why}. The declaration is a claim about what this piece is FOR, and a \
             site plan hands a piece the exact frame of the box it fills — so a piece whose \
             extents no box of the class can have is a piece no `details[]` row could ever bind. \
             Either correct the class name, or rebuild the piece to a frame of the class it \
             claims. Structure size is {sx}x{sy}x{sz}.",
            id = meta.prefab_id,
            why = why.join("; "),
        ),
    ))
}

/// One face of the piece's face contract.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractFace {
    /// The space the way in or out belongs to.
    pub space: String,
    /// The edge's class: `walk` | `stair` | `drop` | `barred` | `vision`.
    pub class: String,
    /// Which side of the piece: `east` | `west` | `up` | `down` | `south` |
    /// `north`.
    pub dir: String,
    /// The opening, as an inclusive local cell range flat in the face's own
    /// axis.
    pub opening: Region,
}

/// One entry of `spatial_contract.spaces`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractSpace {
    /// `enclosed` | `open_top` | `open`.
    pub envelope: String,
    /// The cells it covers.
    pub boxes: Vec<Region>,
}

/// One entry of `spatial_contract.no_body`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractNoBody {
    /// Why these cells are out of play, in the author's words. Which exemption
    /// the region qualifies for is a fact about the blocks and is not recorded
    /// here.
    pub reason: String,
    /// The cells it covers.
    pub boxes: Vec<Region>,
}

/// One entry of `spatial_contract.edges`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractEdge {
    /// A declared space name, or `exterior`.
    pub a: String,
    /// A declared space name, or `exterior`.
    pub b: String,
    /// `walk` | `stair` | `drop` | `barred` | `vision`.
    pub class: String,
    /// The declared level change, on the classes that carry one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rise: Option<i64>,
    /// The opening or transit volume, when the edge declares one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub via: Option<ContractVolume>,
    /// The bar, on a `barred` edge.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bar: Option<ContractBar>,
    /// **The contingency**, on a traversal edge that content opens: the region
    /// the edge is severed by as built, and which direction opening it goes.
    ///
    /// Absent on every edge that is what it claims to be as shipped, which is
    /// why a piece that declares none writes no key at all and its metadata is
    /// byte-for-byte what it was.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub way: Option<ContractWay>,
}

/// A contingent edge's way: the region that decides whether the edge is
/// crossable, and which direction opening it moves in.
///
/// The dual of [`ContractBar`], and the reason `bar` is not extended in place:
/// an existing piece's metadata says `bar` and keeps saying `bar`. The
/// **checker** normalises the two into one prover; the document keeps both
/// spellings, so nothing already written moves a byte.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractWay {
    /// `laid` — the region is empty as built and opening fills it with
    /// [`block`](ContractWay::block); or `cleared` — the region stands in
    /// `block` as built and opening voids it.
    pub opens: String,
    /// The region's name, which is what content addresses.
    pub region: String,
    /// The cells it covers.
    pub boxes: Vec<Region>,
    /// The palette role the way is made of, in the author's own vocabulary.
    ///
    /// Provenance for a reader, and never a second authority: what an opening
    /// writes is [`block`](ContractWay::block), because a role name means
    /// nothing outside the program that bound it. Recorded because a reviewer
    /// reading this document otherwise has no way back to the declaration —
    /// `minecraft:oak_planks` says what the cells become and `"tread"` says
    /// what the author called it.
    ///
    /// Optional for the reason [`License::generated_by`] is: a role is a
    /// *program's* vocabulary, so an expansion always has one and a hand-built
    /// or ingested piece — which names its blocks directly — has none at all.
    /// Writing an invented role there would be a fact about nothing.
    ///
    /// [`ContractBar`] carries no such field, and deliberately: adding one
    /// would move the exported bytes of every piece that already declares a
    /// bar, which spec-0042 §2.3 forbids. The checker reads neither.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    /// The block state the way is made of: what a `laid` way is filled with,
    /// and what a `cleared` way stands in.
    pub block: String,
}

/// An edge's own volume — an opening, a stair's treads, a fall column.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractVolume {
    /// The region's name, which is what content binds to.
    pub region: String,
    /// The cells it covers.
    pub boxes: Vec<Region>,
}

/// A `barred` edge's bar: the region that stands in the way, and its block.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractBar {
    /// The region's name.
    pub region: String,
    /// The cells it covers.
    pub boxes: Vec<Region>,
    /// The block state the bar is built from.
    pub block: String,
}

/// The `structure` block: which file, how big, for which MC version.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct StructureMeta {
    /// The `.nbt` filename, relative to this metadata file.
    pub file: String,
    /// The datapack structure id (a path segment).
    pub id: String,
    /// Structure extent `[x, y, z]`.
    pub size: [i32; 3],
    /// The MC data version the structure targets (ADR-0009).
    pub data_version: i32,
    /// Provenance breadcrumb: what wrote the `.nbt`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub generator: Option<String>,
}

/// One structure template a piece's blocks arrive in, and where in the piece it
/// sits.
///
/// **The unit every placer works in.** A single-template prefab has exactly one,
/// at `offset` `[0, 0, 0]`; a tiled zone has one per tile at its manifest
/// offset. Nothing that places, stamps or reads a piece's blocks needs to know
/// which of the two it was handed — that is the whole point of the type, and the
/// reason [`PrefabMeta::templates`] is the only way to reach a `.nbt` filename.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PieceTemplate<'a> {
    /// The datapack structure id (a path segment).
    pub id: &'a str,
    /// The `.nbt` filename, relative to the metadata file.
    pub file: &'a str,
    /// This template's origin in **piece-local** coordinates — add it to a
    /// template-local cell to get the piece cell. `[0, 0, 0]` for a
    /// single-template piece.
    pub offset: [i32; 3],
    /// The template's extent `[x, y, z]`.
    pub size: [i32; 3],
}

/// **What an anchor is FOR**, when the compiler has to find it without being
/// told its name (spec-0046).
///
/// A closed vocabulary the compiler owns, and deliberately small: a role is
/// added by the change that teaches the compiler to resolve it, never by a
/// producer that wants a label. Deserialising is therefore the whole
/// validation — a term this engine does not know is a `serde` error naming the
/// terms it does, which the prefab registry reports as `DW0346` against the
/// file that wrote it, rather than a string ridden through into a resolution
/// that silently never matches.
///
/// Distinct from [`ContractWay::role`], which is a *palette* role in a
/// program's own vocabulary and means nothing outside it. This one is the
/// engine's vocabulary, which is exactly why it is closed.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum AnchorRole {
    /// **The cell a body arrives at when it enters the area this piece is
    /// placed in.** One per area: `setworldspawn`, the class-apply teleport,
    /// first-join placement, inter-area transport, the POV planner's first
    /// frame and the trap-safety start set all resolve it.
    Entry,
}

impl AnchorRole {
    /// Every term in the vocabulary, in declaration order — what a refusal
    /// lists, so the message cannot drift from the type.
    pub const ALL: &'static [AnchorRole] = &[AnchorRole::Entry];

    /// The term as it is written in a document.
    pub fn as_str(self) -> &'static str {
        match self {
            AnchorRole::Entry => "entry",
        }
    }

    /// The terms a refusal lists, comma-separated — one rendering, so a message
    /// written at a command line and a message written by the prefab registry
    /// cannot name different vocabularies.
    pub fn vocabulary() -> String {
        AnchorRole::ALL
            .iter()
            .map(|r| format!("`{r}`"))
            .collect::<Vec<_>>()
            .join(", ")
    }
}

/// A role typed at a command line is the same closed vocabulary a role written
/// into a document is, read from the same table — so `delvec prefab anchor
/// --role` refuses exactly what deserialising the document refuses, by name,
/// and the two cannot come to know different terms.
impl std::str::FromStr for AnchorRole {
    type Err = String;

    fn from_str(s: &str) -> Result<AnchorRole, String> {
        AnchorRole::ALL
            .iter()
            .copied()
            .find(|r| r.as_str() == s)
            .ok_or_else(|| {
                format!(
                    "unknown anchor role `{s}` — the engine's vocabulary is {}",
                    AnchorRole::vocabulary()
                )
            })
    }
}

impl std::fmt::Display for AnchorRole {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// One entry of the `anchors` map.
///
/// A point anchor carries `pos` (+ optionally `facing`); a gate anchor carries a
/// `region` (+ optionally `block`); a trap anchor also carries the hardware the
/// prefab pre-wired for it. All of those are the same object class — a named
/// place in a piece — so they live in one type and each writes only the keys it
/// means.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Anchor {
    /// Local cell `[x, y, z]`, relative to the structure origin.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pos: Option<[i32; 3]>,
    /// Cardinal facing keyword.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub facing: Option<String>,
    /// **What this anchor is for**, when the compiler has to find it without
    /// being told its name (spec-0046).
    ///
    /// A campaign addresses an anchor by its name, and for everything a
    /// campaign addresses that is the whole story. The entry point is the one
    /// place a campaign does *not* name — the compiler has to find it — and
    /// finding it by matching a spelling is a fact about the producer that
    /// wrote the piece rather than about the piece. So the piece declares it,
    /// every producer can write it, and none has to agree with another about
    /// how it is spelled.
    ///
    /// Absent on every piece that predates the role, which is what keeps the
    /// shipped library building byte-for-byte what it built before: the
    /// compiler falls back to the name list when no anchor in an area declares
    /// a role.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role: Option<AnchorRole>,
    /// Local cell range, for a gate anchor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub region: Option<Region>,
    /// Block id filling a gate region (e.g. `minecraft:iron_bars`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub block: Option<String>,
    /// **Which element of the piece's spatial contract this anchor lands in** —
    /// `space:<name>`, `no_body:<name>`, `via:<name>` or `bar:<name>`.
    ///
    /// A campaign binds content to an anchor by name; what says whether that
    /// place is play space, a door or exterior dressing is the contract, and a
    /// reader who has only the anchor list cannot tell. Absent on a piece that
    /// declares no contract, and on an anchor that lands in nothing the contract
    /// accounts for — which is a finding the checker raises rather than a
    /// silence.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resolves_to: Option<String>,
    /// The pre-wired dispenser socket cell (local coords) for an `anchor/trap`
    /// marker. `pos` is the trap's trigger/hazard cell (the plate, tripwire or
    /// chest modelled as the hazard); `dispenser` is the separate cell holding
    /// the empty dispenser whose payload is filled at compile time. Absent for
    /// every non-trap anchor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dispenser: Option<[i32; 3]>,
    /// The block the prefab wired as this `anchor/trap`'s **trigger** — the
    /// plate or tripwire sitting on `pos` — with its full blockstate exactly as
    /// authored (`minecraft:oak_pressure_plate[powered=false]`), because
    /// flag-gating a trap physically removes and restores this block and must
    /// put back what was there. The gate-anchor `block` above is the same
    /// contract for a sealed gate. Absent for every non-trap anchor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trigger_block: Option<String>,
    /// **One line of prose about this place, for a person reading the piece.**
    ///
    /// The engine never reads it: nothing routes, places, lights or refuses
    /// anything because of what it says. It is modelled all the same, because
    /// a document whose only reader is a machine is a document a person cannot
    /// review, and a producer that writes the key unmodelled makes every build
    /// report `DW0543` — "this library is newer than this engine" — about a
    /// sentence. A tripwire that fires on the normal state of the tree is not a
    /// tripwire.
    ///
    /// It is prose, so it is deliberately unconstrained and deliberately not
    /// inventoried for translation: it is addressed to whoever opens the `.json`,
    /// never to a player.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
    /// Every anchor key this version does not model, kept verbatim. The anchor
    /// block is where this document has grown most often — `resolves_to`,
    /// `dispenser` and `trigger_block` were each a new key on a shipped
    /// document — so it captures for the same reason [`PrefabMeta::extra`]
    /// does.
    #[serde(flatten)]
    pub extra: BTreeMap<String, serde_json::Value>,
}

impl Anchor {
    /// The point-anchor shape: a cell and a facing.
    pub fn point(pos: [i32; 3], facing: impl Into<String>) -> Anchor {
        Anchor {
            pos: Some(pos),
            facing: Some(facing.into()),
            ..Anchor::default()
        }
    }

    /// Declare what this anchor is for ([`Anchor::role`]).
    pub fn with_role(mut self, role: AnchorRole) -> Anchor {
        self.role = Some(role);
        self
    }
}

/// A gate anchor's region and fill block, in **piece-local** coordinates — the
/// answer [`PrefabMeta::gate_anchor`] gives, and the only shape either reader
/// works from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GateAnchor {
    /// Low corner of the region, piece-local.
    pub from: [i32; 3],
    /// High corner of the region, piece-local, inclusive.
    pub to: [i32; 3],
    /// The block the region is filled with and cleared of.
    pub block: String,
}

impl GateAnchor {
    /// The region as a reader sees it in a diagnostic.
    fn extent(&self) -> String {
        format!(
            "[{},{},{}]..[{},{},{}]",
            self.from[0], self.from[1], self.from[2], self.to[0], self.to[1], self.to[2]
        )
    }
}

/// The contract-element name a `bar:<region>` [`Anchor::resolves_to`] carries,
/// and nothing else's. Every other element kind is a place a body stands, looks
/// through or walks over — not a thing content fills.
fn bar_name(resolves_to: &str) -> Option<&str> {
    resolves_to.strip_prefix("bar:")
}

/// The one box `boxes` exactly fills, or `None` when they do not fill one.
///
/// A contract region is a **list** of boxes, and a gate is a **single** box: the
/// compiler fills it and clears it as one region, and every consumer of a
/// resolved gate — the assembler that voids it, the seal that rebuilds it, the
/// nav model that walks through it — is written against two corners. So the
/// question is not "what box contains these" but "do these BE a box": a bounding
/// box that the members do not fill would hand every one of those consumers
/// cells the contract never called bar, and the assembler would delete them.
///
/// Exact, not approximate: the members must be pairwise disjoint and their
/// volumes must sum to the bounding box's. A doorway declared as a lintel row
/// plus the two jambs under it is one box and passes; a doorway plus a
/// threshold nub hanging off its corner is not, and is refused.
fn one_box(boxes: &[Region]) -> Option<Region> {
    let first = boxes.first()?;
    let mut from = first.from;
    let mut to = first.to;
    for b in &boxes[1..] {
        for a in 0..3 {
            from[a] = from[a].min(b.from[a]).min(b.to[a]);
            to[a] = to[a].max(b.from[a]).max(b.to[a]);
        }
    }
    let vol = |f: [i32; 3], t: [i32; 3]| -> i64 {
        (0..3)
            .map(|a| i64::from(t[a] - f[a]) + 1)
            .try_fold(1i64, |acc, n| if n > 0 { acc.checked_mul(n) } else { None })
            .unwrap_or(0)
    };
    let total: i64 = boxes.iter().map(|b| vol(b.from, b.to)).sum();
    if total != vol(from, to) {
        return None;
    }
    for (i, a) in boxes.iter().enumerate() {
        for b in &boxes[i + 1..] {
            if (0..3).all(|k| a.from[k] <= b.to[k] && b.from[k] <= a.to[k]) {
                return None;
            }
        }
    }
    Some(Region { from, to })
}

/// Where an anchor is and what it is for — the parts of an anchor an editing
/// tool declares.
///
/// Deliberately a different type from [`Anchor`]: the whole anchor is what a
/// caller must not be able to hand an editing step, because constructing one
/// means filling in — and therefore erasing — the hardware, provenance and
/// unknown keys the caller knows nothing about. See [`PrefabMeta::edit_anchor`].
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AnchorEdit {
    /// Local cell `[x, y, z]`, for a point anchor.
    pub pos: Option<[i32; 3]>,
    /// Cardinal facing keyword.
    pub facing: Option<String>,
    /// Local cell range, for a gate anchor.
    pub region: Option<Region>,
    /// Block id filling a gate region.
    pub block: Option<String>,
    /// **What the anchor is for** ([`Anchor::role`]), tri-state because the
    /// place and the purpose are two properties and an edit may speak about
    /// either without speaking about the other:
    ///
    /// * `None` — the edit says nothing about the role, so an existing one is
    ///   kept. Moving a cell is not a statement that the piece stopped being
    ///   the place a party arrives at, and silently answering it as one is the
    ///   deletion this type exists to prevent.
    /// * `Some(None)` — the edit says the anchor has **no** role, and an
    ///   existing one is removed. This is the remedy `DW0804` prescribes when
    ///   two anchors in one area both claim a role, and it is reachable here
    ///   rather than only by hand-editing the document.
    /// * `Some(Some(role))` — the anchor is declared to have that role.
    pub role: Option<Option<AnchorRole>>,
}

/// An inclusive local cell range.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Region {
    /// Low corner `[x, y, z]`.
    pub from: [i32; 3],
    /// High corner `[x, y, z]`.
    pub to: [i32; 3],
}

/// One jigsaw socket declared by a prefab.
///
/// `local_pos` is the socket's wall cell (bottom-centre of the opening) in the
/// prefab's local coordinates; `facing` is the cardinal direction the opening
/// faces outward. Two sockets mate by placing the child so its socket sits one
/// block beyond the parent's, facing the opposite way.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Connector {
    /// Jigsaw `name`.
    pub name: String,
    /// Jigsaw `target`.
    pub target: String,
    /// The socket's wall cell, local coords `[x, y, z]`.
    pub local_pos: [i32; 3],
    /// Cardinal direction the opening faces outward.
    pub facing: String,
    /// Opening extent `[width, height]`.
    pub opening: [i32; 2],
    /// Jigsaw joint.
    pub joint: String,
}

/// The profile of a prefab whose light nothing has measured.
pub const UNMEASURED: &str = "unmeasured";

/// The `license` block: the human half and the machine half of provenance.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct License {
    /// Where the asset came from (`original`, or a named upstream).
    pub source: String,
    /// SPDX id (ADR-0013).
    pub spdx: String,
    /// Human note.
    pub note: String,
    /// Human-readable provenance sentence.
    pub provenance: String,
    /// The machine-readable provenance row: what regenerates these exact bytes.
    ///
    /// Absent for a piece nothing can regenerate — an ingested community build,
    /// or a hand-edited one. Present, it is the ADR-0006 claim in a form a tool
    /// can act on rather than a sentence a human can read.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub generated_by: Option<GeneratedBy>,
}

/// Everything needed to reproduce the `.nbt` byte for byte (ADR-0006).
///
/// **Every input that reaches the bytes, and nothing that does not.** A record
/// missing one of them is worse than no record: it names a set of inputs, and a
/// re-expansion from that set produces a different artifact while the document
/// claims byte reproducibility. The set is the source program, the overrides
/// applied to it, the region and the seed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedBy {
    /// The back end that produced the bytes.
    pub generator: String,
    /// The source program's name.
    pub program: String,
    /// `sha256:<64 hex>` over the canonical JSON of the program **as expanded**
    /// — the source document with [`Self::params`] and [`Self::roles`] already
    /// applied. It is therefore the checksum of the reproduction rather than a
    /// second statement of it: apply the overrides to the named document and
    /// this is the hash you must get.
    pub program_hash: String,
    /// The expansion seed.
    pub seed: u64,
    /// The region the program was expanded over, `[x, y, z]`. An independent
    /// input: the same program at the same seed over a different box is a
    /// different building.
    pub region: [i32; 3],
    /// Integer parameters overridden on the way in (`delvec grammar expand
    /// --param`), by name. Empty — and absent from the document — where the
    /// program was expanded as written.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub params: BTreeMap<String, i64>,
    /// Palette roles rebound on the way in (`--role`), by name, each the block
    /// state as the caller wrote it. The axis frame is not recorded because it
    /// is not an input: a rebind inherits the frame of the binding it replaces,
    /// which the named source document already carries.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub roles: BTreeMap<String, String>,
}

impl PrefabMeta {
    /// Parse metadata from JSON text.
    ///
    /// **The one reader of both packagings.** "Which shape is this" has exactly
    /// two answers and no third, so a document declaring neither block, or both,
    /// is refused here rather than handed half-read to a step that will place
    /// some of its blocks. A tile set is validated as it is read
    /// ([`TileSet::validate`]) for the same reason: a manifest that does not
    /// tile its own volume reassembles into a building with a hole in it and
    /// reports success.
    pub fn from_json(text: &str) -> Result<PrefabMeta, String> {
        let meta: PrefabMeta =
            serde_json::from_str(text).map_err(|e| format!("invalid prefab metadata: {e}"))?;
        match (&meta.structure, &meta.structure_set) {
            (None, None) => {
                return Err("prefab metadata has neither a `structure` block nor a \
                            `structure_set` block — it does not say what blocks it describes"
                    .to_string());
            }
            (Some(_), Some(_)) => {
                return Err(
                    "prefab metadata has BOTH a `structure` block and a `structure_set` \
                            block — a piece's blocks arrive one way or the other, and a reader \
                            cannot be asked which one is the building"
                        .to_string(),
                );
            }
            (None, Some(set)) => set
                .validate()
                .map_err(|e| format!("`structure_set`: {e}"))?,
            (Some(_), None) => {}
        }
        Ok(meta)
    }

    /// Every structure template this piece's blocks arrive in, in a
    /// deterministic order (grid order for a tile set).
    ///
    /// Empty only for a value built in code that declares neither block, which
    /// [`Self::from_json`] refuses — nothing read from disk is in that state.
    pub fn templates(&self) -> Vec<PieceTemplate<'_>> {
        if let Some(s) = &self.structure {
            return vec![PieceTemplate {
                id: &s.id,
                file: &s.file,
                offset: [0, 0, 0],
                size: s.size,
            }];
        }
        self.structure_set
            .iter()
            .flat_map(|set| {
                set.parts.iter().map(|p| PieceTemplate {
                    id: &p.id,
                    file: &p.file,
                    offset: p.offset,
                    size: p.size,
                })
            })
            .collect()
    }

    /// The piece's extent `[x, y, z]` — the WHOLE building, whichever packaging
    /// its blocks arrived in. `[0, 0, 0]` only for the value
    /// [`Self::templates`] documents as unreachable from disk.
    pub fn size(&self) -> [i32; 3] {
        match (&self.structure, &self.structure_set) {
            (Some(s), _) => s.size,
            (None, Some(set)) => set.size,
            (None, None) => [0, 0, 0],
        }
    }

    /// The MC data version the piece's templates target (ADR-0009), when it
    /// declares one.
    pub fn data_version(&self) -> Option<i32> {
        match (&self.structure, &self.structure_set) {
            (Some(s), _) => Some(s.data_version),
            (None, Some(set)) => Some(set.data_version),
            (None, None) => None,
        }
    }

    /// True when the piece's blocks arrive as several templates — a fact about
    /// packaging that only a tool reporting on packaging may ask.
    pub fn is_tiled(&self) -> bool {
        self.structure_set.is_some()
    }

    /// The filename stem the piece's files are named from — the single
    /// template's `id`, or the tile set's `base`. They are the same concept
    /// under two keys, so a diagnostic that wants to name the piece's document
    /// asks here rather than reaching into one packaging.
    pub fn base(&self) -> &str {
        match (&self.structure, &self.structure_set) {
            (Some(s), _) => &s.id,
            (None, Some(set)) => &set.base,
            (None, None) => "",
        }
    }

    /// The tile grid `[x, y, z]`; `[1, 1, 1]` for a piece that fit one
    /// template. Packaging, like [`Self::is_tiled`].
    pub fn grid(&self) -> [i32; 3] {
        self.structure_set
            .as_ref()
            .map_or([1, 1, 1], |set| set.grid)
    }

    /// Read the document at `path`, or `Ok(None)` when there is no file there.
    pub fn read(path: &Path) -> Result<Option<PrefabMeta>, String> {
        if !path.exists() {
            return Ok(None);
        }
        let text =
            std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
        PrefabMeta::from_json(&text).map(Some)
    }

    /// Load `<nbt_path>.json` (the sibling metadata), or `Ok(None)` when absent.
    pub fn beside_nbt(nbt_path: &Path) -> Result<Option<PrefabMeta>, String> {
        let json_path = nbt_path.with_extension("json");
        if !json_path.exists() {
            return Ok(None);
        }
        let text = std::fs::read_to_string(&json_path)
            .map_err(|e| format!("read {}: {e}", json_path.display()))?;
        Ok(Some(PrefabMeta::from_json(&text)?))
    }

    /// Serialize as canonical pretty JSON with a trailing newline.
    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).expect("prefab metadata serializes") + "\n"
    }

    /// The JSON Schema of this document, for `delvec schema --stage
    /// prefab-metadata`.
    ///
    /// **Deliberately not part of `--stage all`.** `all` is the campaign DSL's
    /// staged documents, and the gallery's coverage gate enumerates its units
    /// from exactly that export: a library-asset document folded into it would
    /// demand a gallery *stage-document* binding for every field of a file no
    /// stage document contains. A prefab's declarations are proven where they
    /// are read — `shown_faces` by the exposure ledger, `walk_y` and
    /// `waterline_y` by the seating and waterline bindings — which is a binding
    /// a schema unit could not give them.
    ///
    /// It is exported anyway, and for the reason the walk record is: this is
    /// the command an author is told to run to see the shape of a document they
    /// must write, and a piece's metadata is one of those.
    pub fn schema() -> serde_json::Value {
        let mut v = serde_json::to_value(schemars::schema_for!(PrefabMeta))
            .expect("the prefab-metadata schema serializes to JSON");
        if let Some(obj) = v.as_object_mut() {
            obj.insert(
                "title".into(),
                serde_json::Value::String("<prefab-id>.json (prefab metadata)".into()),
            );
            obj.insert(
                "description".into(),
                serde_json::Value::String(SCHEMA_DESCRIPTION.into()),
            );
        }
        v
    }

    /// Every key of this document — top level and per anchor — that this version
    /// does not model, as `(where, key)` pairs in a stable order.
    ///
    /// `where` is `""` for a top-level key and the anchor's name for an anchor
    /// key. A reader that wants to say something about a key it kept asks here;
    /// nothing has to re-open the file to find out.
    pub fn unknown_keys(&self) -> Vec<(&str, &str)> {
        let mut out: Vec<(&str, &str)> = self
            .extra
            .keys()
            .map(|k| ("", k.as_str()))
            .collect::<Vec<_>>();
        for (name, anchor) in &self.anchors {
            for key in anchor.extra.keys() {
                out.push((name.as_str(), key.as_str()));
            }
        }
        out
    }

    /// A minimal skeleton for a freshly admitted external piece.
    pub fn skeleton(
        id: &str,
        size: [i32; 3],
        data_version: i32,
        generator: &str,
        license: License,
    ) -> PrefabMeta {
        PrefabMeta {
            prefab_id: format!("prefab/{id}"),
            structure: Some(StructureMeta {
                file: format!("{id}.nbt"),
                id: id.to_string(),
                size,
                data_version,
                generator: Some(generator.to_string()),
            }),
            structure_set: None,
            anchors: BTreeMap::new(),
            connectors: Vec::new(),
            lighting: Some(Lighting {
                method: Some("not yet probed".to_string()),
                ..Lighting::unmeasured()
            }),
            license: Some(license),
            // A freshly admitted piece states no walk plane, for the reason
            // `walk_y` has no default: the number is a measurement of the piece
            // its generator made, and the admission step that converts a
            // stranger's `.nbt` did not build the piece and has no walk plane
            // to report. A campaign that seats such a piece on a horizon whose
            // datum needs one is `DW0886`, which is where the author learns.
            walk_y: None,
            waterline_y: None,
            // A freshly admitted piece shows nothing, for the same reason it
            // claims no size class: which of its sides are finished surface is
            // the author's claim about what the piece is FOR, and reading it off
            // the bytes would be this document inferring intent from material.
            shown_faces: Vec::new(),
            spatial_contract: None,
            // A freshly admitted piece makes no claim about which size class of
            // box it fills, and inventing one from its bytes would be the
            // inference this document does not do: the claim is the author's.
            footprint_class: None,
            extra: BTreeMap::new(),
        }
    }

    /// Annotate a named anchor's **place and purpose**, creating the anchor
    /// when it is not there yet.
    ///
    /// An anchor is an object, not a value. A tool that names where the anchor
    /// is has said nothing about the hardware the prefab wired at it
    /// ([`Anchor::dispenser`], [`Anchor::trigger_block`]), about which contract
    /// element an exporter resolved it into ([`Anchor::resolves_to`]), or about
    /// any key this version has never heard of ([`Anchor::extra`]) — so none of
    /// those is touched. Replacing the whole anchor instead is the same silent
    /// deletion this type exists to prevent at the top level, one level down,
    /// and on the block of the document that has grown most often.
    ///
    /// The place itself is one property expressed two ways — a cell or a region
    /// — so an edit redeclares all four of its fields together and a `pos` does
    /// supersede a stale `region`.
    ///
    /// The **role** is a fifth field and not a fifth way of saying where: what
    /// an anchor is for is a property of the anchor, so it is written when the
    /// edit speaks about it, cleared when the edit says it has none, and left
    /// alone when the edit says nothing ([`AnchorEdit::role`]).
    pub fn edit_anchor(&mut self, name: &str, edit: AnchorEdit) {
        let anchor = self.anchors.entry(name.to_string()).or_default();
        anchor.pos = edit.pos;
        anchor.facing = edit.facing;
        anchor.region = edit.region;
        anchor.block = edit.block;
        if let Some(role) = edit.role {
            anchor.role = role;
        }
    }

    /// **The ONE authority on the region and block a gate anchor names**, in
    /// piece-local coordinates.
    ///
    /// A gate anchor is declared in one of two forms, and the compiler must read
    /// both identically:
    ///
    /// * **explicitly** — the anchor carries its own [`region`](Anchor::region)
    ///   and [`block`](Anchor::block), which is how a hand-authored piece has
    ///   always written one;
    /// * **through the piece's spatial contract** — the anchor carries a
    ///   [`resolves_to`](Anchor::resolves_to) of `bar:<region>`, which is what an
    ///   exporter writes. The cells and the block already live in that edge's
    ///   [`ContractBar`], so repeating them on the anchor would be a second
    ///   authority for one fact, and the exporter rightly does not.
    ///
    /// Nothing derived either form from the other, and the whole compiler read
    /// only the first. A piece declaring its gates the second way therefore had
    /// no gate at all: the anchor resolved to a bare point, every verb that fills
    /// or clears a gate was refused (`DW0343`), and the information the refusal
    /// asked for was sitting in the same document.
    ///
    /// Both readers ask this function and nothing else — the planner, which
    /// resolves the anchor to world cells, and
    /// [`AnchorRegistry`](crate::AnchorRegistry)'s prefab implementation, which
    /// answers whether the compiler can fill it — so the two cannot come to
    /// disagree about what a gate is or where it stands.
    ///
    /// Three answers, and the third is the one that earns the `Result`:
    ///
    /// * `Ok(None)` — not a gate anchor. A point anchor, a trap anchor, or an
    ///   anchor whose `resolves_to` names a space, a `no_body` region, a `via`
    ///   volume or a `way`. None of those is a thing content fills or clears.
    /// * `Ok(Some(gate))` — a gate, and here is the box and the block.
    /// * `Err(why)` — declared as a gate and **not resolvable to one fillable
    ///   box**. Refused rather than guessed, because every alternative writes
    ///   blocks somewhere the document did not ask for. `why` is a clause the
    ///   caller folds into its own diagnostic.
    ///
    /// A `way` is deliberately not a gate here. Its
    /// [`opens`](ContractWay::opens) is a direction — `laid` cells are absent as
    /// built and appear when opened, `cleared` cells stand and are voided — and
    /// the single-region gate model carries no direction to put it in. Reading
    /// one as a gate would silently pick a direction for the author.
    pub fn gate_anchor(&self, name: &str) -> Result<Option<GateAnchor>, String> {
        let Some(anchor) = self.anchors.get(name) else {
            return Ok(None);
        };
        let contract_bar = match anchor.resolves_to.as_deref().and_then(bar_name) {
            Some(region) => Some(self.contract_bar(name, region)?),
            None => None,
        };
        match (&anchor.region, contract_bar) {
            (None, None) => Ok(None),
            // The contract form. The block is the contract's, which is the block
            // the piece was actually built out of.
            (None, Some(bar)) => Ok(Some(bar)),
            // The explicit form, unchanged since it was the only one. A region
            // with no `block` is what `DW0343` has always refused: the compiler
            // is being asked to fill cells with a block nothing names.
            (Some(region), None) => match &anchor.block {
                Some(block) => Ok(Some(GateAnchor {
                    from: region.from,
                    to: region.to,
                    block: block.clone(),
                })),
                None => Err(format!(
                    "gate anchor `{name}` declares a `region` and no `block`, so nothing says \
                     what the region is filled with"
                )),
            },
            // Both forms. Agreement is fine and is what a piece that was
            // hand-authored and later exported looks like; a disagreement is
            // refused rather than resolved by precedence, because whichever one
            // this function preferred would be a rule no reader of the document
            // could see.
            (Some(region), Some(bar)) => {
                let explicit = GateAnchor {
                    from: region.from,
                    to: region.to,
                    block: anchor.block.clone().unwrap_or_default(),
                };
                if explicit == bar {
                    return Ok(Some(bar));
                }
                Err(format!(
                    "gate anchor `{name}` declares a `region` and also resolves into contract bar \
                     `{bar_region}`, and the two disagree — the anchor says {ea} filled with \
                     `{eb}`, the bar says {ba} filled with `{bb}`. One place stands in one way: \
                     delete the anchor's `region`/`block` and let the contract say it, or correct \
                     the contract",
                    bar_region = anchor
                        .resolves_to
                        .as_deref()
                        .and_then(bar_name)
                        .unwrap_or_default(),
                    ea = explicit.extent(),
                    eb = explicit.block,
                    ba = bar.extent(),
                    bb = bar.block,
                ))
            }
        }
    }

    /// The bar `region` of this piece's spatial contract, as one fillable box.
    fn contract_bar(&self, anchor: &str, region: &str) -> Result<GateAnchor, String> {
        let bar = self
            .spatial_contract
            .as_ref()
            .into_iter()
            .flat_map(|c| c.edges.iter())
            .filter_map(|e| e.bar.as_ref())
            .find(|b| b.region == region)
            .ok_or_else(|| {
                format!(
                    "gate anchor `{anchor}` resolves into contract bar `{region}`, and this \
                     piece's spatial contract declares no bar of that name — the anchor's \
                     `resolves_to` is written from the contract, so the two have come apart. \
                     Re-export the piece"
                )
            })?;
        let one = one_box(&bar.boxes).ok_or_else(|| {
            format!(
                "gate anchor `{anchor}` resolves into contract bar `{region}`, whose {n} boxes do \
                 not fill their own bounding box — so there is no single region the compiler can \
                 fill or clear without writing blocks into cells the contract does not call bar. \
                 Declare the bar as one box, or as boxes that tile one",
                n = bar.boxes.len()
            )
        })?;
        Ok(GateAnchor {
            from: one.from,
            to: one.to,
            block: bar.block.clone(),
        })
    }

    /// Append a socket connector (idempotent by `local_pos` + `facing`).
    pub fn add_connector(&mut self, c: Connector) {
        if !self
            .connectors
            .iter()
            .any(|x| x.local_pos == c.local_pos && x.facing == c.facing)
        {
            self.connectors.push(c);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The whole reason this type is not two types: an editing tool reads a
    /// document, changes one block of it, and writes it back. Anything it does
    /// not model is deleted, and nothing says so.
    #[test]
    fn a_read_modify_write_round_trip_keeps_every_field() {
        let text = r#"{
  "prefab_id": "prefab/chapel-ward",
  "structure": {
    "file": "chapel-ward.nbt",
    "id": "chapel-ward",
    "size": [16, 9, 26],
    "data_version": 4671,
    "generator": "crates/delvec/src/grammar"
  },
  "anchors": {
    "anchor/bell": { "pos": [3, 1, 4], "facing": "north" },
    "anchor/ward": { "region": { "from": [0, 0, 0], "to": [2, 2, 2] }, "block": "minecraft:stone" }
  },
  "connectors": [],
  "lighting": { "profile": "unmeasured" },
  "license": {
    "source": "original",
    "spdx": "GPL-3.0-or-later",
    "note": "n",
    "provenance": "p",
    "generated_by": {
      "generator": "grammar",
      "program": "bell_chapel_ward",
      "program_hash": "sha256:00",
      "seed": 1,
      "region": [11, 6, 13]
    }
  },
  "waterline_y": 2
}
"#;
        let mut meta = PrefabMeta::from_json(text).unwrap();
        meta.lighting = Some(Lighting {
            profile: crate::registry::LightingProfile::Dark,
            measured_min_light: Some(0),
            measured: Some("2026-08-11".to_string()),
            rationale: None,
            method: Some("static estimate".to_string()),
        });
        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
        let before: serde_json::Value = serde_json::from_str(text).unwrap();
        assert_eq!(
            after["license"]["generated_by"], before["license"]["generated_by"],
            "the provenance row must survive an edit to an unrelated block"
        );
        assert_eq!(after["anchors"], before["anchors"]);
        assert_eq!(after["structure"], before["structure"]);
        assert_eq!(
            after["waterline_y"], before["waterline_y"],
            "a declared waterline must survive an edit to an unrelated block"
        );
    }

    /// The same guarantee for a key no version of this type has ever heard of.
    /// This is the general form of the `waterline_y` and `generated_by` losses:
    /// the type cannot enumerate what has not been invented, so it keeps it.
    #[test]
    fn a_key_this_version_does_not_model_survives_the_round_trip() {
        let text = r#"{
  "prefab_id": "prefab/x",
  "structure": { "file": "x.nbt", "id": "x", "size": [3, 3, 3], "data_version": 4671 },
  "anchors": { "anchor/a": { "pos": [1, 1, 1], "acoustics": "reverberant" } },
  "connectors": [],
  "lighting": { "profile": "unmeasured" },
  "from_the_future": { "nested": [1, 2, 3] }
}
"#;
        let mut meta = PrefabMeta::from_json(text).unwrap();
        assert_eq!(
            meta.unknown_keys(),
            vec![("", "from_the_future"), ("anchor/a", "acoustics")],
            "both unknown keys must be reportable, top level and per anchor"
        );
        meta.connectors.push(Connector {
            name: "keep:socket".to_string(),
            target: "keep:socket".to_string(),
            local_pos: [0, 0, 0],
            facing: "north".to_string(),
            opening: [3, 3],
            joint: "aligned".to_string(),
        });
        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
        let before: serde_json::Value = serde_json::from_str(text).unwrap();
        assert_eq!(after["from_the_future"], before["from_the_future"]);
        assert_eq!(
            after["anchors"]["anchor/a"]["acoustics"],
            before["anchors"]["anchor/a"]["acoustics"]
        );
    }

    /// The same guarantee **inside** an anchor, which is where the document's
    /// round trip is finest-grained and where the top-level guarantee above says
    /// nothing at all.
    ///
    /// An editing step that re-annotates an anchor already on the piece names
    /// only where it is. The dispenser cell the prefab wired, the trigger block
    /// it must put back, the contract element the exporter resolved, and a key
    /// no version has heard of are all properties of the anchor and not of the
    /// edit — so all four survive, and only the place changes.
    #[test]
    fn re_annotating_an_anchor_keeps_the_hardware_the_piece_carries() {
        let text = r#"{
  "prefab_id": "prefab/trap-room",
  "structure": { "file": "trap-room.nbt", "id": "trap-room", "size": [7, 5, 7], "data_version": 4671 },
  "anchors": {
    "anchor/trap": {
      "pos": [3, 1, 3],
      "facing": "north",
      "resolves_to": "space:hall",
      "dispenser": [3, 2, 4],
      "trigger_block": "minecraft:oak_pressure_plate[powered=false]",
      "acoustics": "reverberant"
    }
  },
  "connectors": [],
  "lighting": { "profile": "unmeasured" }
}
"#;
        let mut meta = PrefabMeta::from_json(text).unwrap();
        meta.edit_anchor(
            "anchor/trap",
            AnchorEdit {
                pos: Some([4, 1, 3]),
                facing: Some("south".to_string()),
                ..AnchorEdit::default()
            },
        );
        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
        let a = &after["anchors"]["anchor/trap"];
        assert_eq!(
            a["pos"],
            serde_json::json!([4, 1, 3]),
            "the place is edited"
        );
        assert_eq!(a["facing"], serde_json::json!("south"));
        assert_eq!(
            a["dispenser"],
            serde_json::json!([3, 2, 4]),
            "the pre-wired dispenser cell is the piece's hardware, not the edit's"
        );
        assert_eq!(
            a["trigger_block"],
            serde_json::json!("minecraft:oak_pressure_plate[powered=false]"),
            "flag-gating a trap has to put this exact block back"
        );
        assert_eq!(a["resolves_to"], serde_json::json!("space:hall"));
        assert_eq!(
            a["acoustics"],
            serde_json::json!("reverberant"),
            "a key this version does not model is the anchor's too"
        );

        // A gate anchor's region and a point anchor's cell are one property, so
        // naming the cell supersedes the region rather than leaving both.
        meta.edit_anchor(
            "anchor/gate",
            AnchorEdit {
                region: Some(Region {
                    from: [0, 0, 0],
                    to: [1, 2, 0],
                }),
                block: Some("minecraft:iron_bars".to_string()),
                ..AnchorEdit::default()
            },
        );
        meta.edit_anchor(
            "anchor/gate",
            AnchorEdit {
                pos: Some([0, 1, 0]),
                ..AnchorEdit::default()
            },
        );
        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
        let g = &after["anchors"]["anchor/gate"];
        assert_eq!(g["pos"], serde_json::json!([0, 1, 0]));
        assert!(g.get("region").is_none(), "{g}");
        assert!(g.get("block").is_none(), "{g}");
    }

    /// A tiled zone's manifest is a prefab document like any other: it is read,
    /// one block of it is edited, and it is written back whole — and the keys
    /// this version does not model survive.
    ///
    /// It used to be a *second type* (`TileSetMeta`), field-for-field this one.
    /// The copy is what this test is really about: the same round trip, on the
    /// same struct, is what makes a block added here reach both packagings.
    #[test]
    fn a_tile_set_manifest_round_trips_through_an_edit() {
        let text = r#"{
  "prefab_id": "prefab/notre-dame",
  "structure_set": {
    "base": "notre-dame",
    "size": [31, 48, 93],
    "part_max": 48,
    "grid": [1, 1, 2],
    "data_version": 4671,
    "generator": "crates/delvec/src/grammar",
    "parts": [
      { "file": "notre-dame.x0y0z0.nbt", "id": "a", "grid_index": [0,0,0], "offset": [0,0,0], "size": [31,48,48] },
      { "file": "notre-dame.x0y0z1.nbt", "id": "b", "grid_index": [0,0,1], "offset": [0,0,48], "size": [31,48,45] }
    ]
  },
  "anchors": { "anchor/crossing": { "pos": [15, 1, 56], "facing": "south" } },
  "connectors": [],
  "lighting": { "profile": "unmeasured" },
  "waterline_y": 12,
  "license": {
    "source": "original",
    "spdx": "GPL-3.0-or-later",
    "note": "n",
    "provenance": "p",
    "generated_by": {
      "generator": "grammar",
      "program": "nd",
      "program_hash": "sha256:00",
      "seed": 1,
      "region": [3, 3, 3]
    }
  },
  "a_key_no_engine_models": { "kept": true }
}
"#;
        let mut meta = PrefabMeta::from_json(text).unwrap();
        assert!(meta.is_tiled());
        assert_eq!(meta.size(), [31, 48, 93]);
        assert_eq!(meta.data_version(), Some(4671));
        assert_eq!(meta.license.as_ref().unwrap().spdx, "GPL-3.0-or-later");
        // The whole point of one document: a block the copy had lost is here.
        assert_eq!(meta.waterline_y, Some(12));

        // The templates, in grid order, with their piece-local offsets.
        let templates = meta.templates();
        assert_eq!(templates.len(), 2);
        assert_eq!(templates[0].file, "notre-dame.x0y0z0.nbt");
        assert_eq!(templates[0].offset, [0, 0, 0]);
        assert_eq!(templates[1].id, "b");
        assert_eq!(templates[1].offset, [0, 0, 48]);
        assert_eq!(templates[1].size, [31, 48, 45]);

        meta.lighting = Some(Lighting {
            profile: crate::registry::LightingProfile::Lit,
            measured_min_light: Some(6),
            measured: Some(String::new()),
            rationale: None,
            method: Some("static estimate".to_string()),
        });
        let after: serde_json::Value = serde_json::from_str(&meta.to_json()).unwrap();
        let before: serde_json::Value = serde_json::from_str(text).unwrap();
        assert_eq!(after["license"], before["license"]);
        assert_eq!(after["structure_set"], before["structure_set"]);
        assert_eq!(after["anchors"], before["anchors"]);
        assert_eq!(after["waterline_y"], before["waterline_y"]);
        assert_eq!(after["lighting"]["profile"], "lit");
        assert!(
            after.get("structure").is_none(),
            "a tiled document must not grow an empty `structure` key: {after}"
        );
        // Reading is total here too: a key this version has never heard of
        // survives an edit rather than being deleted by the tool that made it.
        assert_eq!(
            after["a_key_no_engine_models"], before["a_key_no_engine_models"],
            "an unmodelled key must survive a read-modify-write"
        );
    }

    /// A single-template piece is one template at the origin, so nothing that
    /// places blocks has to ask which packaging it was handed.
    #[test]
    fn a_single_template_piece_is_one_template_at_the_origin() {
        let text = r#"{
  "prefab_id": "prefab/x",
  "structure": { "file": "x.nbt", "id": "x", "size": [3, 4, 5], "data_version": 4671 }
}
"#;
        let meta = PrefabMeta::from_json(text).unwrap();
        assert!(!meta.is_tiled());
        assert_eq!(meta.size(), [3, 4, 5]);
        assert_eq!(
            meta.templates(),
            vec![PieceTemplate {
                id: "x",
                file: "x.nbt",
                offset: [0, 0, 0],
                size: [3, 4, 5],
            }]
        );
    }

    /// "Which shape is this" has exactly two answers and no third: a document
    /// with neither block, and one with both, are refusals rather than a
    /// half-read document handed to a step that places some of its blocks.
    #[test]
    fn a_document_that_does_not_say_what_blocks_it_describes_is_refused() {
        let err = PrefabMeta::from_json(r#"{"prefab_id":"prefab/x"}"#).unwrap_err();
        assert!(err.contains("structure_set"), "{err}");
        assert!(err.contains("structure"), "{err}");

        let both = r#"{
  "prefab_id": "prefab/x",
  "structure": { "file": "x.nbt", "id": "x", "size": [3, 3, 3], "data_version": 4671 },
  "structure_set": {
    "base": "x", "size": [3, 3, 3], "part_max": 48, "grid": [1, 1, 1],
    "data_version": 4671, "generator": "g",
    "parts": [ { "file": "x.x0y0z0.nbt", "id": "x0", "grid_index": [0,0,0], "offset": [0,0,0], "size": [3,3,3] } ]
  }
}
"#;
        let err = PrefabMeta::from_json(both).unwrap_err();
        assert!(err.contains("BOTH"), "{err}");
    }

    /// A manifest that does not tile its own zone is refused **by the reader**,
    /// so every consumer of the document meets it at the same place — and none
    /// of them reassembles a building with a hole in it and reports success.
    #[test]
    fn a_manifest_that_does_not_tile_its_zone_is_refused_by_the_reader() {
        let text = r#"{
  "prefab_id": "prefab/holed",
  "structure_set": {
    "base": "holed", "size": [4, 4, 100], "part_max": 48, "grid": [1, 1, 1],
    "data_version": 4671, "generator": "g",
    "parts": [ { "file": "holed.x0y0z0.nbt", "id": "h0", "grid_index": [0,0,0], "offset": [0,0,0], "size": [4,4,48] } ]
  }
}
"#;
        let err = PrefabMeta::from_json(text).unwrap_err();
        assert!(err.contains("cover"), "{err}");
        assert!(err.contains("hole"), "{err}");
    }

    /// A piece nothing has regenerated has no row, and the key is absent rather
    /// than `null` — `null` reads as "measured, and the answer is nothing".
    #[test]
    fn absent_optional_fields_are_omitted_not_nulled() {
        let meta = PrefabMeta::skeleton(
            "ingested",
            [3, 3, 3],
            4671,
            "delvec prefab (external admission)",
            License {
                source: "unknown".to_string(),
                spdx: "UNKNOWN".to_string(),
                note: String::new(),
                provenance: String::new(),
                generated_by: None,
            },
        );
        let json = meta.to_json();
        assert!(!json.contains("generated_by"), "{json}");
        assert!(!json.contains("null"), "{json}");
        assert!(json.contains("\"connectors\": []"), "{json}");
        assert_eq!(PrefabMeta::from_json(&json).unwrap(), meta);
    }

    /// The role vocabulary is closed **by the type**, so a term this engine does
    /// not know is a parse failure naming the terms it does — not a string
    /// carried into a resolution that then silently never matches.
    ///
    /// The mechanism is worth pinning rather than assuming: [`Anchor`] carries
    /// a `#[serde(flatten)]` catch-all, which buffers the whole object, and a
    /// buffered unknown enum variant is easy to believe would land in `extra`
    /// instead of erroring. It does not.
    #[test]
    fn a_role_outside_the_vocabulary_is_refused_by_name() {
        let anchor: Anchor =
            serde_json::from_str(r#"{"pos": [1, 2, 3], "role": "entry"}"#).unwrap();
        assert_eq!(anchor.role, Some(AnchorRole::Entry));
        assert!(anchor.extra.is_empty(), "a modelled key is not `extra`");

        let err = serde_json::from_str::<Anchor>(r#"{"pos": [1, 2, 3], "role": "spawn"}"#)
            .expect_err("`spawn` is a NAME, and was never a role");
        let msg = err.to_string();
        assert!(msg.contains("unknown variant"), "{msg}");
        for role in AnchorRole::ALL {
            assert!(
                msg.contains(role.as_str()),
                "the refusal lists `{role}`: {msg}"
            );
        }
    }

    /// An anchor's `note` is a modelled key, so a piece that explains its own
    /// places is not a piece reporting `DW0543` six times a build.
    ///
    /// Both halves matter and only one is obvious. It must PARSE into the field
    /// (or `unknown_keys` reports it, which is the defect), and it must survive
    /// a round trip unchanged — a modelled key that serialises back differently
    /// would move every prefab document the first time anything rewrote one.
    #[test]
    fn an_anchor_note_is_modelled_and_is_not_an_unknown_key() {
        let text = r#"{
  "prefab_id": "prefab/x",
  "structure": { "file": "x.nbt", "id": "x", "size": [3, 3, 3], "data_version": 4671 },
  "anchors": { "anchor/a": { "pos": [1, 1, 1], "note": "where a wave forms up" } },
  "connectors": [],
  "lighting": { "profile": "unmeasured" }
}
"#;
        let meta = PrefabMeta::from_json(text).unwrap();
        assert_eq!(
            meta.anchors["anchor/a"].note.as_deref(),
            Some("where a wave forms up")
        );
        assert_eq!(
            meta.unknown_keys(),
            Vec::<(&str, &str)>::new(),
            "`note` is modelled, so nothing is left for `DW0543` to report"
        );
        assert_eq!(
            serde_json::from_str::<serde_json::Value>(&meta.to_json()).unwrap(),
            serde_json::from_str::<serde_json::Value>(text).unwrap()
        );
        // An anchor that says nothing writes no key: every piece admitted before
        // the field stays byte-for-byte what it was.
        let plain = serde_json::to_string(&Anchor::point([1, 2, 3], "north")).unwrap();
        assert!(!plain.contains("note"), "{plain}");
    }

    /// An anchor that declares no role writes no key, which is what keeps every
    /// piece in the shipped library byte-for-byte what it was (spec-0046 §4.5).
    #[test]
    fn an_anchor_without_a_role_writes_no_role_key() {
        let plain = serde_json::to_string(&Anchor::point([1, 2, 3], "north")).unwrap();
        assert!(!plain.contains("role"), "{plain}");
        let declared =
            serde_json::to_string(&Anchor::point([1, 2, 3], "north").with_role(AnchorRole::Entry))
                .unwrap();
        assert!(declared.contains(r#""role":"entry""#), "{declared}");
    }

    /// A role typed at a command line goes through the **same** closed table a
    /// role written into a document does, so the two cannot come to know
    /// different terms — and a term the engine does not know is refused with
    /// both the term and the vocabulary in the message.
    #[test]
    fn a_role_parsed_from_a_word_is_the_same_vocabulary_as_a_role_read_from_a_document() {
        for role in AnchorRole::ALL {
            assert_eq!(role.as_str().parse::<AnchorRole>(), Ok(*role));
        }
        let err = "dispenser".parse::<AnchorRole>().expect_err("not a term");
        assert!(err.contains("dispenser"), "{err}");
        for role in AnchorRole::ALL {
            assert!(err.contains(role.as_str()), "{err}");
        }
        // The name the compiler once matched is a name and has never been a
        // role, so it is refused here exactly as it is refused by `serde`.
        assert!("spawn".parse::<AnchorRole>().is_err());
    }

    /// `edit_anchor`'s role is TRI-state, and the middle state is the one that
    /// matters: an edit that says nothing about the role keeps it. A tool that
    /// moved an anchor's cell would otherwise delete the piece's entry point,
    /// which is the silent deletion [`AnchorEdit`] exists to prevent.
    #[test]
    fn an_edit_that_says_nothing_about_the_role_keeps_it() {
        let mut meta = PrefabMeta::skeleton(
            "x",
            [3, 3, 3],
            4671,
            "test",
            License {
                source: "original".to_string(),
                spdx: "GPL-3.0-or-later".to_string(),
                note: String::new(),
                provenance: String::new(),
                generated_by: None,
            },
        );
        let place = |role| AnchorEdit {
            pos: Some([1, 1, 1]),
            role,
            ..AnchorEdit::default()
        };

        meta.edit_anchor("anchor/a", place(Some(Some(AnchorRole::Entry))));
        assert_eq!(meta.anchors["anchor/a"].role, Some(AnchorRole::Entry));

        // Silent: kept.
        meta.edit_anchor(
            "anchor/a",
            AnchorEdit {
                pos: Some([2, 1, 1]),
                ..AnchorEdit::default()
            },
        );
        assert_eq!(meta.anchors["anchor/a"].pos, Some([2, 1, 1]));
        assert_eq!(meta.anchors["anchor/a"].role, Some(AnchorRole::Entry));

        // Said to have none: removed — the remedy `DW0804` prescribes.
        meta.edit_anchor("anchor/a", place(Some(None)));
        assert_eq!(meta.anchors["anchor/a"].role, None);
    }
}