use std::path::Path;
use serde::{Deserialize, Serialize};
use super::model::WorldModel;
pub const FORMAT: &str = "imaginu-world/1";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Manifest {
pub format: String,
pub name: String,
pub seed: u64,
pub palette: String,
pub size: [f32; 2],
pub chunk_size: f32,
pub chunk_resolution: u32,
pub grid: [u32; 2],
pub sea_level: f32,
pub chunks: Vec<ChunkEntry>,
pub pois: Vec<Poi>,
pub roads: Vec<Polyline>,
pub rivers: Vec<Polyline>,
pub zones: Vec<ZoneSummary>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChunkEntry {
pub x: u32,
pub z: u32,
pub file: String,
pub position: [f32; 3],
pub min: [f32; 3],
pub max: [f32; 3],
pub resolution: u32,
pub lods: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Poi {
pub name: String,
pub kind: String,
pub position: [f32; 3],
pub radius: f32,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub spawn_points: Vec<[f32; 3]>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Polyline {
pub kind: String,
pub width: f32,
pub points: Vec<[f32; 3]>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ZoneSummary {
pub kind: String,
pub center: [f32; 2],
}
pub fn chunk_file(cx: u32, cz: u32) -> String {
format!("chunk_{cx}_{cz}.glb")
}
pub fn create(m: &WorldModel) -> Manifest {
let cs = m.p.chunk_size;
let mut chunks = Vec::with_capacity((m.nx * m.nz) as usize);
for cz in 0..m.nz {
for cx in 0..m.nx {
let (ox, oz) = m.chunk_origin(cx, cz);
let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
for iz in 0..=16 {
for ix in 0..=16 {
let h = m.height(
ox + (ix as f32 / 16.0 - 0.5) * cs,
oz + (iz as f32 / 16.0 - 0.5) * cs,
);
lo = lo.min(h);
hi = hi.max(h);
}
}
lo = lo.min(m.p.sea_level) - 8.0;
hi += 16.0; chunks.push(ChunkEntry {
x: cx,
z: cz,
file: chunk_file(cx, cz),
position: [ox, 0.0, oz],
min: [ox - cs / 2.0, lo, oz - cs / 2.0],
max: [ox + cs / 2.0, hi, oz + cs / 2.0],
resolution: m.chunk_res(cx, cz),
lods: m.p.lods,
});
}
}
Manifest {
format: FORMAT.into(),
name: m.p.name.clone(),
seed: m.p.seed,
palette: m.p.palette.clone(),
size: [m.size_x, m.size_z],
chunk_size: cs,
chunk_resolution: m.p.chunk_resolution,
grid: [m.nx, m.nz],
sea_level: m.p.sea_level,
chunks,
pois: {
let mut pois: Vec<Poi> = m
.pois
.iter()
.enumerate()
.map(|(i, s)| Poi {
name: s.name.clone(),
kind: s.kind.name().into(),
position: [s.x, s.ground, s.z],
radius: s.radius,
file: Some(super::poi::poi_file(s, i)),
spawn_points: super::poi::spawn_points(s),
})
.collect();
for (i, b) in m.network.bridges.iter().enumerate() {
pois.push(Poi {
name: format!("Bridge {}", i + 1),
kind: "bridge".into(),
position: [b.pos.x, b.deck, b.pos.y],
radius: b.len * 0.5,
file: Some(format!("poi_bridge_{i}.glb")),
spawn_points: vec![[b.pos.x, b.deck, b.pos.y]],
});
}
pois
},
roads: m
.network
.roads
.iter()
.map(|p| Polyline {
kind: "road".into(),
width: p.width,
points: p.points.iter().map(|v| [v.x, v.y, v.z]).collect(),
})
.collect(),
rivers: m
.network
.rivers
.iter()
.map(|p| Polyline {
kind: "river".into(),
width: p.width,
points: p.points.iter().map(|v| [v.x, v.y, v.z]).collect(),
})
.collect(),
zones: m
.zones
.cells_in(
[-m.size_x / 2.0, -m.size_z / 2.0],
[m.size_x / 2.0, m.size_z / 2.0],
)
.into_iter()
.map(|(kind, center)| ZoneSummary {
kind: kind.name().into(),
center,
})
.collect(),
}
}
pub fn validate_dir(dir: &Path) -> Result<String, String> {
let man_path = dir.join("manifest.json");
let text = std::fs::read_to_string(&man_path)
.map_err(|e| format!("cannot read {}: {e}", man_path.display()))?;
let man: Manifest =
serde_json::from_str(&text).map_err(|e| format!("bad manifest.json: {e}"))?;
if man.format != FORMAT {
return Err(format!("unknown manifest format '{}'", man.format));
}
let expect = (man.grid[0] as usize) * (man.grid[1] as usize);
if man.chunks.len() != expect {
return Err(format!(
"manifest lists {} chunks, grid {}×{} needs {expect}",
man.chunks.len(),
man.grid[0],
man.grid[1]
));
}
let mut seen = std::collections::BTreeSet::new();
for c in &man.chunks {
if c.x >= man.grid[0] || c.z >= man.grid[1] {
return Err(format!("chunk ({},{}) outside grid", c.x, c.z));
}
if !seen.insert((c.x, c.z)) {
return Err(format!("duplicate chunk entry ({},{})", c.x, c.z));
}
for i in 0..3 {
if c.min[i] > c.max[i] {
return Err(format!("chunk ({},{}) has inverted bounds", c.x, c.z));
}
}
}
for p in &man.pois {
if let Some(f) = &p.file {
if !dir.join(f).exists() {
return Err(format!("POI '{}' references missing file {f}", p.name));
}
crate::validate::validate_glb(&dir.join(f)).map_err(|e| format!("POI GLB {f}: {e}"))?;
if p.kind == "boss" {
let bytes = std::fs::read(dir.join(f))
.map_err(|e| format!("cannot read POI GLB {f}: {e}"))?;
crate::validate::validate_boss_bytes(&bytes)
.map_err(|e| format!("POI GLB {f}: {e}"))?;
}
}
}
let mut missing = 0usize;
let mut tris_total = 0u64;
for c in &man.chunks {
let path = dir.join(&c.file);
if !path.exists() {
missing += 1;
continue;
}
let summary =
crate::validate::validate_glb(&path).map_err(|e| format!("{}: {e}", c.file))?;
tris_total += summary
.split_whitespace()
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
}
if missing > 0 {
return Err(format!(
"{missing}/{} chunk GLB(s) missing (lazy build? run the full build)",
man.chunks.len()
));
}
Ok(format!(
"{} chunks ({}×{}), {} POIs, {} roads, {} rivers, {tris_total} tris total",
man.chunks.len(),
man.grid[0],
man.grid[1],
man.pois.len(),
man.roads.len(),
man.rivers.len()
))
}