use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;
use crate::store::{InsertPosition, NodeKind, Store, SYSTEM_TAG_WORLD};
use crate::world::types::{
AstronomyOutput, ClimateOutput, DemographicsOutput, GeologyOutput, HydrologyOutput,
};
#[derive(Debug, Default, Clone)]
pub struct MaterializeReport {
pub chapter: String,
pub created: Vec<String>,
pub updated: Vec<String>,
}
pub fn materialize_astronomy(
store: &Store,
cfg: &Config,
out: &AstronomyOutput,
) -> Result<MaterializeReport> {
let world = world_book(store)?;
let chapter = ensure_chapter(store, cfg, &world, "Astronomy")?;
let overview = serde_json::json!({
"stellar_mass_solar": out.stellar_mass_solar,
"orbital_period_days_earth": out.orbital_period_days_earth,
"year_length_planet_days": out.year_length_planet_days,
"declared_year_length_days": out.declared_year_length_days,
"year_length_divergence_pct": out.year_length_divergence_pct,
"axial_tilt_deg": out.axial_tilt_deg,
"insolation_bands": out.insolation_bands,
});
let calendar = serde_json::json!({
"seasons": out.seasons,
"calendar_check": out.calendar_check,
});
let celestial = serde_json::json!({
"moons": out.moons,
"eclipses": out.eclipses,
"tide": out.tide,
});
let mut report = MaterializeReport { chapter: "Astronomy".into(), ..Default::default() };
for (title, payload) in [
("System overview", overview),
("Calendar", calendar),
("Celestial events", celestial),
] {
let body = serde_json::to_string_pretty(&payload)
.map_err(|e| Error::Store(format!("serializing {title}: {e}")))?;
match ensure_paragraph(store, cfg, &chapter, title, &body)? {
Outcome::Created => report.created.push(title.to_string()),
Outcome::Updated => report.updated.push(title.to_string()),
}
}
Ok(report)
}
pub fn materialize_geology(
store: &Store,
cfg: &Config,
out: &GeologyOutput,
) -> Result<MaterializeReport> {
let world = world_book(store)?;
let chapter = ensure_chapter(store, cfg, &world, "Geology")?;
let png = write_heightmap_png(store.project_root(), out)?;
let png_rel = png
.strip_prefix(store.project_root())
.unwrap_or(&png)
.display()
.to_string();
let continents = serde_json::json!({
"source": out.source,
"width": out.width,
"height": out.height,
"sea_level": out.sea_level,
"plates": out.plates,
"boundaries": out.boundaries,
"continents": out.continents,
"sea_coverage_pct": out.sea_coverage_pct,
"elevation": out.elevation,
"heightmap_asset": png_rel,
});
let mountains = serde_json::json!({ "mountain_ranges": out.mountain_ranges });
let minerals = serde_json::json!({ "minerals": out.minerals });
let mut report = MaterializeReport { chapter: "Geology".into(), ..Default::default() };
for (title, payload) in [
("Continents and plates", continents),
("Mountains and ranges", mountains),
("Mineral distribution", minerals),
] {
let body = serde_json::to_string_pretty(&payload)
.map_err(|e| Error::Store(format!("serializing {title}: {e}")))?;
match ensure_paragraph(store, cfg, &chapter, title, &body)? {
Outcome::Created => report.created.push(title.to_string()),
Outcome::Updated => report.updated.push(title.to_string()),
}
}
Ok(report)
}
pub fn materialize_climate(
store: &Store,
cfg: &Config,
out: &ClimateOutput,
) -> Result<MaterializeReport> {
let world = world_book(store)?;
let chapter = ensure_chapter(store, cfg, &world, "Climate")?;
let zones = serde_json::json!({
"width": out.width,
"height": out.height,
"mean_land_temp_c": out.mean_land_temp_c,
"mean_land_precip_mm": out.mean_land_precip_mm,
"zones": out.zones,
});
let winds = serde_json::json!({ "winds": out.winds });
let mut report = MaterializeReport { chapter: "Climate".into(), ..Default::default() };
for (title, payload) in [("Climate zones", zones), ("Prevailing winds", winds)] {
let body = serde_json::to_string_pretty(&payload)
.map_err(|e| Error::Store(format!("serializing {title}: {e}")))?;
match ensure_paragraph(store, cfg, &chapter, title, &body)? {
Outcome::Created => report.created.push(title.to_string()),
Outcome::Updated => report.updated.push(title.to_string()),
}
}
Ok(report)
}
pub fn materialize_hydrology(
store: &Store,
cfg: &Config,
out: &HydrologyOutput,
) -> Result<MaterializeReport> {
let world = world_book(store)?;
let chapter = ensure_chapter(store, cfg, &world, "Hydrology")?;
let rivers = serde_json::json!({
"width": out.width,
"height": out.height,
"river_count": out.river_count,
"major_rivers": out.major_rivers,
});
let basins = serde_json::json!({
"watershed_count": out.watershed_count,
"lake_count": out.lake_count,
"settlement_priors": out.settlement_priors,
});
let mut report = MaterializeReport { chapter: "Hydrology".into(), ..Default::default() };
for (title, payload) in
[("River systems", rivers), ("Watersheds and settlement priors", basins)]
{
let body = serde_json::to_string_pretty(&payload)
.map_err(|e| Error::Store(format!("serializing {title}: {e}")))?;
match ensure_paragraph(store, cfg, &chapter, title, &body)? {
Outcome::Created => report.created.push(title.to_string()),
Outcome::Updated => report.updated.push(title.to_string()),
}
}
Ok(report)
}
pub fn materialize_demographics(
store: &Store,
cfg: &Config,
out: &DemographicsOutput,
) -> Result<MaterializeReport> {
let world = world_book(store)?;
let chapter = ensure_chapter(store, cfg, &world, "Demographics")?;
let overview = serde_json::json!({
"total_population": out.total_population,
"habitable_fraction": out.habitable_fraction,
"size_classes": out.size_classes,
"role_archetypes": out.role_archetypes,
});
let distribution = serde_json::json!({ "settlements": out.settlements });
let mut report = MaterializeReport { chapter: "Demographics".into(), ..Default::default() };
for (title, payload) in
[("Settlement overview", overview), ("Population distribution", distribution)]
{
let body = serde_json::to_string_pretty(&payload)
.map_err(|e| Error::Store(format!("serializing {title}: {e}")))?;
match ensure_paragraph(store, cfg, &chapter, title, &body)? {
Outcome::Created => report.created.push(title.to_string()),
Outcome::Updated => report.updated.push(title.to_string()),
}
}
Ok(report)
}
fn write_heightmap_png(root: &Path, out: &GeologyOutput) -> Result<PathBuf> {
let dir = root.join("assets").join("world");
std::fs::create_dir_all(&dir)
.map_err(|e| Error::Store(format!("creating {}: {e}", dir.display())))?;
let path = dir.join("heightmap.png");
let mut img = image::GrayImage::new(out.width as u32, out.height as u32);
for y in 0..out.height {
for x in 0..out.width {
let v = (out.heightmap[y * out.width + x].clamp(0.0, 1.0) * 255.0).round() as u8;
img.put_pixel(x as u32, y as u32, image::Luma([v]));
}
}
img.save(&path).map_err(|e| Error::Store(format!("writing {}: {e}", path.display())))?;
Ok(path)
}
fn world_book(store: &Store) -> Result<Node> {
Hierarchy::load(store)?
.iter()
.find(|n| n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(SYSTEM_TAG_WORLD))
.cloned()
.ok_or_else(|| {
Error::Store("World system book missing — re-open the project to seed it".into())
})
}
fn ensure_chapter(store: &Store, cfg: &Config, book: &Node, title: &str) -> Result<Node> {
let h = Hierarchy::load(store)?;
if let Some(c) = h
.children_of(Some(book.id))
.into_iter()
.find(|n| n.kind == NodeKind::Chapter && n.title.eq_ignore_ascii_case(title))
.cloned()
{
return Ok(c);
}
let h = Hierarchy::load(store)?;
store.create_node(cfg, &h, NodeKind::Chapter, title, Some(book), None, InsertPosition::End)
}
enum Outcome {
Created,
Updated,
}
fn ensure_paragraph(
store: &Store,
cfg: &Config,
chapter: &Node,
title: &str,
body: &str,
) -> Result<Outcome> {
let h = Hierarchy::load(store)?;
let existing = h
.children_of(Some(chapter.id))
.into_iter()
.find(|n| n.kind == NodeKind::Paragraph && n.title.eq_ignore_ascii_case(title))
.cloned();
let (mut node, outcome) = match existing {
Some(p) => (p, Outcome::Updated),
None => {
let h = Hierarchy::load(store)?;
let p = store.create_node(
cfg,
&h,
NodeKind::Paragraph,
title,
Some(chapter),
None,
InsertPosition::End,
)?;
(p, Outcome::Created)
}
};
node.content_type = Some("hjson".to_string());
if let Some(rel) = &node.file {
let abs = store.project_root().join(rel);
std::fs::write(&abs, body.as_bytes())
.map_err(|e| Error::Store(format!("writing {title}: {e}")))?;
}
store.update_paragraph_content(&mut node, body.as_bytes())?;
Ok(outcome)
}