Files
settled-reach/server/src/atlas/layer1.rs
T
jpmschweitzerandClaude Opus 4.8 b73b4e6d92 feat(simulation): Layer-1 water bearing + D-234 waterfront rule (#957)
Close the last D-234 piece — terrain water-direction extraction wired through
to founding orientation and the quarter waterfront rule:

- Layer 1: TerrainAnalysis::water_bearing — 8-octant integer bearing toward the
  nearest water from the water_dist gradient (D-010, no atan2). Stored on
  GeographicAttractor.water_bearing (360 = none).
- #956 founding orientation: coastal/river settlements now get a real
  water-facing bearing (the anchoring attractor's), replacing the 0 stub.
- #957 waterfront rule (D-234b): the water-facing quarter edge (from the
  settlement's Coastal founding orientation) drops its block setback to 0 so
  buildings present flush to the quay (dock-orthogonal). Typed Edge + coastal_edge
  + per-block gating.

Golden + atlas_response fixture rebaked (additive water_bearing field only).
8 new tests. All integer-deterministic (D-010).

Pending: the waterfront rule reads context.founding_orientation, which
city_context_reader still stubs to Cardinal — real per-settlement orientation
reaches quarter generation once the Layer-3 placement -> Layer-4 GenerateSkeleton
dispatch is wired (the remaining cross-layer integration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:22:14 +02:00

195 lines
7.1 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,
// 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<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());
}
}