use std::path::Path;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
pub struct Part {
pub grid_index: [i32; 3],
pub offset: [i32; 3],
pub size: [i32; 3],
}
#[derive(Debug, Clone)]
pub struct SplitPlan {
pub grid: [i32; 3],
pub parts: Vec<Part>,
}
impl SplitPlan {
pub fn is_single(&self) -> bool {
self.grid == [1, 1, 1]
}
}
fn ceil_div(a: i32, b: i32) -> i32 {
(a + b - 1) / b
}
pub fn plan_split(size: [i32; 3], part_max: i32) -> SplitPlan {
let max = part_max.max(1);
let grid = [
ceil_div(size[0], max).max(1),
ceil_div(size[1], max).max(1),
ceil_div(size[2], max).max(1),
];
let mut parts = Vec::with_capacity((grid[0] * grid[1] * grid[2]) as usize);
for i in 0..grid[0] {
for j in 0..grid[1] {
for k in 0..grid[2] {
let offset = [i * max, j * max, k * max];
let part_size = [
(size[0] - offset[0]).min(max),
(size[1] - offset[1]).min(max),
(size[2] - offset[2]).min(max),
];
parts.push(Part {
grid_index: [i, j, k],
offset,
size: part_size,
});
}
}
}
SplitPlan { grid, parts }
}
pub fn part_filename(base: &str, grid_index: [i32; 3]) -> String {
format!(
"{base}.x{}y{}z{}.nbt",
grid_index[0], grid_index[1], grid_index[2]
)
}
pub fn manifest_filename(base: &str) -> String {
format!("{base}.split.json")
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TileSet {
pub base: String,
pub size: [i32; 3],
pub part_max: i32,
pub grid: [i32; 3],
pub data_version: i32,
#[serde(default)]
pub generator: String,
pub parts: Vec<TilePart>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct TilePart {
pub file: String,
pub id: String,
pub grid_index: [i32; 3],
pub offset: [i32; 3],
pub size: [i32; 3],
}
impl TileSet {
pub fn validate(&self) -> Result<(), String> {
if self.parts.is_empty() {
return Err("the manifest lists no tiles".to_string());
}
let mut covered: i64 = 0;
for part in &self.parts {
for axis in 0..3 {
if part.size[axis] <= 0 {
return Err(format!("tile {:?} has a zero or negative axis", part.file));
}
if part.size[axis] > self.part_max {
return Err(format!(
"tile {:?} is {} on axis {axis}, past the declared cap of {}",
part.file, part.size[axis], self.part_max
));
}
if part.offset[axis] < 0 || part.offset[axis] + part.size[axis] > self.size[axis] {
return Err(format!(
"tile {:?} runs outside the {}x{}x{} volume on axis {axis}",
part.file, self.size[0], self.size[1], self.size[2]
));
}
}
covered += part.size[0] as i64 * part.size[1] as i64 * part.size[2] as i64;
}
let whole = self.size[0] as i64 * self.size[1] as i64 * self.size[2] as i64;
if covered != whole {
return Err(format!(
"the {} tile(s) cover {covered} cell(s) of a {whole}-cell volume — a tile set that \
does not tile its volume exactly would reassemble with a hole or an overlap",
self.parts.len()
));
}
Ok(())
}
}
pub fn read_tile_set(path: &Path) -> Result<Option<TileSet>, String> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let meta = crate::prefab::PrefabMeta::from_json(&text)
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(meta.structure_set)
}
#[derive(Serialize)]
struct PartManifest {
file: String,
grid_index: [i32; 3],
offset: [i32; 3],
size: [i32; 3],
}
#[derive(Serialize)]
struct SplitManifest {
base: String,
data_version: i32,
source_size: [i32; 3],
source_offset: [i32; 3],
part_max: i32,
grid: [i32; 3],
parts: Vec<PartManifest>,
}
pub fn tile_filename(name: &str) -> Option<(&str, [i32; 3])> {
let stem = name.strip_suffix(".nbt")?;
let (base, suffix) = stem.rsplit_once('.')?;
let rest = suffix.strip_prefix('x')?;
let (i, rest) = rest.split_once('y')?;
let (j, k) = rest.split_once('z')?;
Some((base, [i.parse().ok()?, j.parse().ok()?, k.parse().ok()?]))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TileEvidence {
Whole,
Tile {
base: String,
manifest: Option<std::path::PathBuf>,
},
}
pub fn tile_evidence(nbt_path: &Path) -> Result<TileEvidence, String> {
let Some(name) = nbt_path.file_name().and_then(|s| s.to_str()) else {
return Ok(TileEvidence::Whole);
};
let Some((base, _)) = tile_filename(name) else {
return Ok(TileEvidence::Whole);
};
let manifest = nbt_path.with_file_name(format!("{base}.json"));
let claimed = manifest.exists()
&& read_tile_set(&manifest)?.is_some_and(|set| set.parts.iter().any(|p| p.file == name));
Ok(TileEvidence::Tile {
base: base.to_string(),
manifest: claimed.then_some(manifest),
})
}
pub fn fragment_refusal(
nbt_path: &Path,
evidence: &TileEvidence,
verb: &str,
consequence: &str,
) -> Option<String> {
let TileEvidence::Tile { base, manifest } = evidence else {
return None;
};
Some(match manifest {
Some(m) => format!(
"{} is one tile of the zone described by {} — to {verb} it would {consequence}. \
Use the whole zone: pass {}",
nbt_path.display(),
m.display(),
m.display()
),
None => format!(
"{} is one tile of a tiled zone (`{base}`) that has been separated from its set — to \
{verb} it would {consequence}, and its manifest is not beside it, so there is \
nothing here to reassemble the zone from. Put the tile back with its `{base}.json` \
manifest and the rest of its tiles, and pass the manifest",
nbt_path.display()
),
})
}
pub fn manifest_claiming(nbt_path: &Path) -> Result<Option<std::path::PathBuf>, String> {
match tile_evidence(nbt_path)? {
TileEvidence::Tile { manifest, .. } => Ok(manifest),
TileEvidence::Whole => Ok(None),
}
}
pub fn manifest_json(
base: &str,
data_version: i32,
source_size: [i32; 3],
source_offset: [i32; 3],
part_max: i32,
plan: &SplitPlan,
) -> String {
let manifest = SplitManifest {
base: base.to_string(),
data_version,
source_size,
source_offset,
part_max,
grid: plan.grid,
parts: plan
.parts
.iter()
.map(|p| PartManifest {
file: part_filename(base, p.grid_index),
grid_index: p.grid_index,
offset: p.offset,
size: p.size,
})
.collect(),
};
serde_json::to_string_pretty(&manifest).expect("manifest serializes")
}
#[cfg(test)]
mod tests {
use super::*;
fn tile_set(size: [i32; 3], parts: Vec<([i32; 3], [i32; 3])>) -> TileSet {
TileSet {
base: "zone".to_string(),
size,
part_max: 48,
grid: [1, 1, parts.len() as i32],
data_version: 4671,
generator: "crates/delvec/src/grammar".to_string(),
parts: parts
.into_iter()
.enumerate()
.map(|(i, (offset, size))| TilePart {
file: format!("zone.x0y0z{i}.nbt"),
id: format!("zone.x0y0z{i}"),
grid_index: [0, 0, i as i32],
offset,
size,
})
.collect(),
}
}
#[test]
fn every_plan_split_tiling_validates() {
for size in [
[1, 1, 1],
[48, 48, 48],
[49, 1, 1],
[20, 10, 84],
[90, 14, 130],
[200, 100, 200],
] {
let plan = plan_split(size, 48);
let set = TileSet {
base: "zone".to_string(),
size,
part_max: 48,
grid: plan.grid,
data_version: 4671,
generator: String::new(),
parts: plan
.parts
.iter()
.map(|p| TilePart {
file: part_filename("zone", p.grid_index),
id: format!("zone{:?}", p.grid_index),
grid_index: p.grid_index,
offset: p.offset,
size: p.size,
})
.collect(),
};
assert_eq!(set.validate(), Ok(()), "{size:?}");
}
}
#[test]
fn a_tiling_with_a_gap_is_refused() {
let short = tile_set([4, 4, 100], vec![([0, 0, 0], [4, 4, 48])]);
let err = short.validate().unwrap_err();
assert!(err.contains("cover"), "{err}");
let overlap = tile_set(
[4, 4, 48],
vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 0], [4, 4, 24])],
);
assert_eq!(overlap.validate(), Ok(()), "volume alone cannot see this");
let outside = tile_set(
[4, 4, 48],
vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 40], [4, 4, 24])],
);
assert!(
outside.validate().unwrap_err().contains("outside"),
"a part running past the volume is caught"
);
}
#[test]
fn a_part_past_the_declared_cap_is_refused() {
let big = tile_set([4, 4, 49], vec![([0, 0, 0], [4, 4, 49])]);
assert!(
big.validate()
.unwrap_err()
.contains("past the declared cap"),
"{:?}",
big.validate()
);
}
#[test]
fn a_manifest_with_no_tiles_is_refused() {
let empty = tile_set([4, 4, 4], vec![]);
assert!(empty.validate().unwrap_err().contains("no tiles"));
}
#[test]
fn a_tile_is_recognised_by_its_own_name_wherever_it_is_put() {
let dir = std::env::temp_dir().join(format!("dw-split-evid-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("elsewhere")).unwrap();
let set = tile_set(
[4, 4, 60],
vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
);
std::fs::write(
dir.join("zone.json"),
serde_json::to_string(
&serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
)
.unwrap(),
)
.unwrap();
for part in &set.parts {
std::fs::write(dir.join(&part.file), b"not really nbt").unwrap();
}
assert_eq!(
tile_evidence(&dir.join("zone.x0y0z1.nbt")).unwrap(),
TileEvidence::Tile {
base: "zone".to_string(),
manifest: Some(dir.join("zone.json")),
}
);
std::fs::copy(
dir.join("zone.x0y0z1.nbt"),
dir.join("elsewhere/zone.x0y0z1.nbt"),
)
.unwrap();
assert_eq!(
tile_evidence(&dir.join("elsewhere/zone.x0y0z1.nbt")).unwrap(),
TileEvidence::Tile {
base: "zone".to_string(),
manifest: None,
},
"a guard a `cp` defeats is not a property of the artifact"
);
assert_eq!(
tile_evidence(&dir.join("keep-gate-room.nbt")).unwrap(),
TileEvidence::Whole
);
assert_eq!(tile_filename("keep-gate-room.nbt"), None);
assert_eq!(
tile_filename("zone.x0y10z2.nbt"),
Some(("zone", [0, 10, 2]))
);
assert_eq!(
tile_filename("cave.mouth.nbt"),
None,
"a dotted name is not a grid suffix"
);
let beside = tile_evidence(&dir.join("zone.x0y0z0.nbt")).unwrap();
let msg = fragment_refusal(&dir.join("zone.x0y0z0.nbt"), &beside, "audit", "lie").unwrap();
assert!(msg.contains("zone.json"), "{msg}");
let orphan = tile_evidence(&dir.join("elsewhere/zone.x0y0z0.nbt")).unwrap();
let msg = fragment_refusal(
&dir.join("elsewhere/zone.x0y0z0.nbt"),
&orphan,
"audit",
"lie",
)
.unwrap();
assert!(msg.contains("separated from its set"), "{msg}");
assert_eq!(
fragment_refusal(&dir, &TileEvidence::Whole, "audit", "lie"),
None
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_two_metadata_shapes_are_told_apart() {
let dir = std::env::temp_dir().join(format!("dw-split-shape-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let single = dir.join("single.json");
std::fs::write(
&single,
r#"{"prefab_id":"prefab/x","structure":{"file":"x.nbt","id":"x","size":[2,2,2],"data_version":4671}}"#,
)
.unwrap();
assert_eq!(read_tile_set(&single).unwrap(), None);
let set = tile_set(
[4, 4, 60],
vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
);
let tiled = dir.join("tiled.json");
std::fs::write(
&tiled,
serde_json::to_string(
&serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
)
.unwrap(),
)
.unwrap();
assert_eq!(read_tile_set(&tiled).unwrap().unwrap(), set);
let broken = dir.join("broken.json");
let bad = tile_set([4, 4, 60], vec![([0, 0, 0], [4, 4, 48])]);
std::fs::write(
&broken,
serde_json::to_string(
&serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": bad }),
)
.unwrap(),
)
.unwrap();
assert!(read_tile_set(&broken).unwrap_err().contains("cover"));
std::fs::remove_dir_all(&dir).unwrap();
}
}