Wire the existing attractor-matching engine (#919/#925) into the generation cascade as Layer 3, and make the whole placement-scoring path integer-deterministic. Layer 3 (D-211): - CascadeLayer::Settlement + Layer3Output (placements) on the snapshot; BodyWorldState gains a `placements` field (the D-203 hot cache). - run_layer3 runs the five-phase match_cities against Layer-1 attractors via the authored D-195 compatibility matrix; pure function of (attractors, cities) — no RNG. cities are passed in by the caller so the cascade stays DB-free and testable. A `// cache seam` marks where a persistent cache wraps it later (#1021). - gen_queue passes &[] for now (Topography needs no cities); the runtime settlement read (gen_queue/layer_proxy) is the #955 follow-on. Integer determinism (D-010 / D-227 — D-195 amended): - Wiring match_cities into the deterministic cascade made its f32 scoring a live cross-platform divergence risk (a near-tie comparison or the Hungarian's f32 reductions can round differently per platform → a different world from the same seed). Converted the entire path to integers: CompatibilityMatrix is a 0-100 affinity table; attractor strength is 0-100 and terrain cost is a percent (100 = baseline), quantized once at the Layer-1 feature boundary; cell_score, the Hungarian, and CityPlacement.score are i64. No f32 in any placement or ranking decision. - Layer-1 golden fixture rebaked: confirmed selection/positions are unchanged (same 256 attractors, 93 river cells) — only the strength/cost representation changed. Tests: lib green (1292); new settlement_layer_places_cities_deterministically covers placement + determinism + propagation into BodyWorldState. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
6.9 KiB
Rust
192 lines
6.9 KiB
Rust
//! 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<DrainageBasin>,
|
|
/// Geographic attractors (D-209) with sub-biome + cost (D-210), sorted by
|
|
/// `(attractor_type, row, col)`.
|
|
pub attractors: Vec<GeographicAttractor>,
|
|
/// 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<GeographicAttractor> = 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,
|
|
}
|
|
})
|
|
.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<f32> {
|
|
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());
|
|
}
|
|
}
|