//! Layer 1 orchestrator — empty-world topography (#953). //! //! Runs the full Layer-1 pipeline for one body, in order: //! 1. D8 priority-flood drainage (D-208) → river network + basins //! 2. shared terrain analysis (ocean/lake masks, water distance, slope, //! elevation percentile) — D-209/D-210 inputs //! 3. 7-tag geographic feature extraction (D-209) //! 4. sub-biome + terrain_modification_cost classification (D-210) //! //! Output is the in-memory `Layer1Output`, which maps directly onto //! `BodyWorldState` (D-203). Name attachment (D-223) is a separate, cheap step //! (`attach_feature_names`) so the compute can be benchmarked in isolation and //! names sourced from the DB pool independently. //! //! **Determinism (D-010 #4):** every stage is deterministic; the same heightmap //! yields bit-identical attractors and river networks. use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork}; use crate::atlas::drainage::{self, DrainageResult}; use crate::atlas::features::{self, TerrainAnalysis}; use crate::atlas::heightmap::BodyHeightmap; use crate::atlas::subbiome; use crate::simulation::generator::{AttractorType, GeographicAttractor}; use serde::{Deserialize, Serialize}; /// Full Layer-1 result for one body. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Layer1Output { pub body_id: String, pub river_network: RiverNetwork, pub drainage_basins: Vec, /// Geographic attractors (D-209) with sub-biome + cost (D-210), sorted by /// `(attractor_type, row, col)`. pub attractors: Vec, /// Working-grid dimensions every position in this output (river cells, basin /// boundaries, attractor positions) is expressed in — equals the downsampled /// heightmap size. The client maps these onto the displayed heightmap (#960), /// so the overlay scale stays correct for any source resolution (mod-safe). pub grid_w: u32, pub grid_h: u32, } /// Run the Layer-1 topography pipeline for a single body. pub fn run_layer1(hm: &BodyHeightmap) -> Layer1Output { let drainage: DrainageResult = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); let ta: TerrainAnalysis = TerrainAnalysis::analyze(hm, &drainage); let raw = features::extract_attractors(hm, &drainage, &ta); let attractors: Vec = raw .iter() .map(|r| { let (sub_biome, terrain_modification_cost) = subbiome::classify(&ta, r.row as usize, r.col as usize); GeographicAttractor { position: (r.row, r.col), attractor_type: r.attractor_type, strength: r.strength, sub_biome, terrain_modification_cost, // Layer-1 water-direction extraction (#957, D-234) — feeds D-213 // founding orientation + the D-234 waterfront rule. water_bearing: ta.water_bearing(r.row as usize, r.col as usize), } }) .collect(); Layer1Output { body_id: hm.body_id.clone(), river_network: drainage.river_network, drainage_basins: drainage.drainage_basins, attractors, grid_w: hm.width, grid_h: hm.height, } } /// Attach pool names (D-223) to the largest computed rivers and mountains. /// /// Rivers are ranked by mouth strength (a proxy for catchment size) descending; /// `RiverMouth` attractors take names from `river_names` in that order. Mountain /// names attach to the highest-elevation `Alpine`/`PassEntrance` attractors. /// Returns `(river_assignments, mountain_assignments)` as `(position, name)` /// pairs; positions that outrun the pool get no name (the pool is finite). pub fn attach_feature_names( output: &Layer1Output, river_names: &[String], mountain_names: &[String], ) -> (Vec<((u16, u16), String)>, Vec<((u16, u16), String)>) { // Rivers: RiverMouth attractors, strongest first (ties by row, col). let mut mouths: Vec<&GeographicAttractor> = output .attractors .iter() .filter(|a| a.attractor_type == AttractorType::RiverMouth) .collect(); mouths.sort_by(|a, b| { // strength is integer now — rank directly (descending). b.strength .cmp(&a.strength) .then(a.position.0.cmp(&b.position.0)) .then(a.position.1.cmp(&b.position.1)) }); let rivers = mouths .iter() .zip(river_names.iter()) .map(|(a, n)| (a.position, n.clone())) .collect(); // Mountains: Alpine attractors, strongest first. let mut peaks: Vec<&GeographicAttractor> = output .attractors .iter() .filter(|a| { matches!( a.sub_biome, crate::simulation::generator::SubBiomeVariant::Alpine ) }) .collect(); peaks.sort_by(|a, b| { // strength is integer now — rank directly (descending). b.strength .cmp(&a.strength) .then(a.position.0.cmp(&b.position.0)) .then(a.position.1.cmp(&b.position.1)) }); let mountains = peaks .iter() .zip(mountain_names.iter()) .map(|(a, n)| (a.position, n.clone())) .collect(); (rivers, mountains) } #[cfg(test)] mod tests { use super::*; fn slope_grid(w: u32, h: u32) -> Vec { let n = (w * h) as usize; (0..n) .map(|i| { let r = i / w as usize; let c = i % w as usize; 1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5) }) .collect() } fn hm(w: u32, h: u32) -> BodyHeightmap { BodyHeightmap { body_id: "TestBody".into(), width: w, height: h, data: slope_grid(w, h), sea_level: 0.3, } } #[test] fn run_layer1_is_deterministic() { let h = hm(128, 64); let o1 = run_layer1(&h); let o2 = run_layer1(&h); assert_eq!(o1.attractors.len(), o2.attractors.len()); for (a, b) in o1.attractors.iter().zip(o2.attractors.iter()) { assert_eq!(a.position, b.position); assert_eq!(a.attractor_type, b.attractor_type); assert_eq!(a.strength, b.strength); assert_eq!(a.sub_biome, b.sub_biome); assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost); } assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells); } #[test] fn produces_attractors_and_costs() { let o = run_layer1(&hm(256, 128)); assert!(!o.attractors.is_empty(), "expected some attractors"); assert!(o .attractors .iter() .all(|a| a.terrain_modification_cost >= 100)); assert!(o.attractors.iter().all(|a| (0..=100).contains(&a.strength))); } #[test] fn name_attachment_respects_pool_size() { let o = run_layer1(&hm(256, 128)); let names = vec!["Aldren".to_string(), "Brook".to_string()]; let (rivers, _mtn) = attach_feature_names(&o, &names, &[]); assert!(rivers.len() <= names.len()); } }