use std::collections::BTreeMap;
use std::path::Path;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::diagnostic::{DwCode, ExitTier};
use crate::registry::Lighting;
use crate::split::TileSet;
pub const POOLS_FILE: &str = "pools.json";
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.
";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PrefabMeta {
pub prefab_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structure: Option<StructureMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structure_set: Option<TileSet>,
#[serde(default)]
pub anchors: BTreeMap<String, Anchor>,
#[serde(default)]
pub connectors: Vec<Connector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lighting: Option<Lighting>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<License>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub walk_y: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub waterline_y: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shown_faces: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spatial_contract: Option<SpatialContract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub footprint_class: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SpatialContract {
pub entry: String,
#[serde(default)]
pub spaces: BTreeMap<String, ContractSpace>,
#[serde(default)]
pub no_body: BTreeMap<String, ContractNoBody>,
#[serde(default)]
pub edges: Vec<ContractEdge>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub faces: Vec<ContractFace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_body_majority_ack: Option<String>,
}
crate::dw_code! {
pub const DW_FOOTPRINT_CLASS: DwCode = DwCode::new("DW0848", ExitTier::Build);
}
#[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; };
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("; "),
),
))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractFace {
pub space: String,
pub class: String,
pub dir: String,
pub opening: Region,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractSpace {
pub envelope: String,
pub boxes: Vec<Region>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractNoBody {
pub reason: String,
pub boxes: Vec<Region>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractEdge {
pub a: String,
pub b: String,
pub class: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rise: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<ContractVolume>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bar: Option<ContractBar>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub way: Option<ContractWay>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractWay {
pub opens: String,
pub region: String,
pub boxes: Vec<Region>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub block: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractVolume {
pub region: String,
pub boxes: Vec<Region>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContractBar {
pub region: String,
pub boxes: Vec<Region>,
pub block: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct StructureMeta {
pub file: String,
pub id: String,
pub size: [i32; 3],
pub data_version: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generator: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PieceTemplate<'a> {
pub id: &'a str,
pub file: &'a str,
pub offset: [i32; 3],
pub size: [i32; 3],
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum AnchorRole {
Entry,
Furniture,
}
impl AnchorRole {
pub const ALL: &'static [AnchorRole] = &[AnchorRole::Entry, AnchorRole::Furniture];
pub fn as_str(self) -> &'static str {
match self {
AnchorRole::Entry => "entry",
AnchorRole::Furniture => "furniture",
}
}
pub fn one_per_area(self) -> bool {
match self {
AnchorRole::Entry => true,
AnchorRole::Furniture => false,
}
}
pub fn vocabulary() -> String {
AnchorRole::ALL
.iter()
.map(|r| format!("`{r}`"))
.collect::<Vec<_>>()
.join(", ")
}
}
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())
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Anchor {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pos: Option<[i32; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub facing: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<AnchorRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<Region>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolves_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispenser: Option<[i32; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_block: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl Anchor {
pub fn point(pos: [i32; 3], facing: impl Into<String>) -> Anchor {
Anchor {
pos: Some(pos),
facing: Some(facing.into()),
..Anchor::default()
}
}
pub fn with_role(mut self, role: AnchorRole) -> Anchor {
self.role = Some(role);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GateAnchor {
pub from: [i32; 3],
pub to: [i32; 3],
pub block: String,
}
impl GateAnchor {
fn extent(&self) -> String {
format!(
"[{},{},{}]..[{},{},{}]",
self.from[0], self.from[1], self.from[2], self.to[0], self.to[1], self.to[2]
)
}
}
fn bar_name(resolves_to: &str) -> Option<&str> {
resolves_to.strip_prefix("bar:")
}
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 })
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AnchorEdit {
pub pos: Option<[i32; 3]>,
pub facing: Option<String>,
pub region: Option<Region>,
pub block: Option<String>,
pub role: Option<Option<AnchorRole>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Region {
pub from: [i32; 3],
pub to: [i32; 3],
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Connector {
pub name: String,
pub target: String,
pub local_pos: [i32; 3],
pub facing: String,
pub opening: [i32; 2],
pub joint: String,
}
pub const UNMEASURED: &str = "unmeasured";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct License {
pub source: String,
pub spdx: String,
pub note: String,
pub provenance: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generated_by: Option<GeneratedBy>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedBy {
pub generator: String,
pub program: String,
pub program_hash: String,
pub seed: u64,
pub region: [i32; 3],
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, i64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub roles: BTreeMap<String, String>,
}
impl PrefabMeta {
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)
}
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()
}
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],
}
}
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,
}
}
pub fn is_tiled(&self) -> bool {
self.structure_set.is_some()
}
pub fn base(&self) -> &str {
match (&self.structure, &self.structure_set) {
(Some(s), _) => &s.id,
(None, Some(set)) => &set.base,
(None, None) => "",
}
}
pub fn grid(&self) -> [i32; 3] {
self.structure_set
.as_ref()
.map_or([1, 1, 1], |set| set.grid)
}
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)
}
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)?))
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("prefab metadata serializes") + "\n"
}
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
}
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
}
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),
walk_y: None,
waterline_y: None,
shown_faces: Vec::new(),
spatial_contract: None,
footprint_class: None,
extra: BTreeMap::new(),
}
}
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;
}
}
pub fn gate_anchor(&self, name: &str) -> Result<Option<GateAnchor>, String> {
let Some(anchor) = self.anchors.get(name) else {
return Ok(None);
};
if anchor.role == Some(AnchorRole::Furniture) {
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),
(None, Some(bar)) => Ok(Some(bar)),
(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"
)),
},
(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,
))
}
}
}
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(),
})
}
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::*;
#[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"
);
}
#[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"]
);
}
#[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"
);
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}");
}
#[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");
assert_eq!(meta.waterline_y, Some(12));
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}"
);
assert_eq!(
after["a_key_no_engine_models"], before["a_key_no_engine_models"],
"an unmodelled key must survive a read-modify-write"
);
}
#[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],
}]
);
}
#[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}");
}
#[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}");
}
#[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);
}
#[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}"
);
}
}
#[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()
);
let plain = serde_json::to_string(&Anchor::point([1, 2, 3], "north")).unwrap();
assert!(!plain.contains("note"), "{plain}");
}
#[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}");
}
#[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}");
}
assert!("spawn".parse::<AnchorRole>().is_err());
}
#[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));
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));
meta.edit_anchor("anchor/a", place(Some(None)));
assert_eq!(meta.anchors["anchor/a"].role, None);
}
}