Files
settled-reach/server/src/atlas/subbiome.rs
T
jpmschweitzerandClaude Opus 4.8 ab084269f3 feat(simulation): integer-deterministic Layer-3 settlement placement (#955)
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>
2026-06-03 16:30:57 +02:00

195 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sub-biome variant classification and terrain_modification_cost (D-210).
//!
//! Each `GeographicAttractor` (D-209) carries a `SubBiomeVariant` and a
//! `terrain_modification_cost`. Classification uses four heightmap-derivable
//! signals (D-210):
//! - elevation percentile (of body total) — from `TerrainAnalysis::elev_pct`
//! - local slope — `TerrainAnalysis::slope_deg`
//! - moisture proxy — distance to nearest river mouth / coast (`water_dist`)
//! - temperature proxy — latitude of the equirectangular pixel (`row`)
//!
//! `Volcanic` is never emitted here: the Layer-1 inputs carry no volcanic
//! signal (D-210). It remains in the enum for a future volcanic data source.
//!
//! **Determinism:** pure function of integer/float inputs with fixed
//! thresholds; no RNG, no map iteration. `terrain_modification_cost` is f32
//! but is never used as a sort key.
use crate::atlas::features::TerrainAnalysis;
use crate::simulation::generator::SubBiomeVariant;
/// Temperature proxy [0,1] from latitude: 1.0 at the equator (`row == h/2`),
/// 0.0 at the poles (`row == 0` or `row == h-1`).
#[inline]
fn temperature(row: usize, h: usize) -> f32 {
if h <= 1 {
return 1.0;
}
let lat = row as f32 / (h - 1) as f32; // 0 = north pole, 1 = south pole
1.0 - (lat - 0.5).abs() * 2.0
}
/// Base infrastructure-build cost per sub-biome (D-210 anchors: grassland 1.0,
/// coastal lowland 1.4, wetland 3.2, alpine 3.8, volcanic 4.5; the rest
/// interpolated by buildability).
/// Base build cost as a percent of baseline (100 = 1.0× grassland). Integer for
/// D-010 determinism (#955).
fn base_cost(v: SubBiomeVariant) -> i32 {
match v {
SubBiomeVariant::TemperateGrassland => 100,
SubBiomeVariant::Savanna => 110,
SubBiomeVariant::Desert => 120,
SubBiomeVariant::TemperateForest => 130,
SubBiomeVariant::CoastalLowland => 140,
SubBiomeVariant::BorealForest => 150,
SubBiomeVariant::Tundra => 160,
SubBiomeVariant::TropicalWet => 200,
SubBiomeVariant::Wetland => 320,
SubBiomeVariant::Alpine => 380,
SubBiomeVariant::Volcanic => 450,
}
}
/// Classify the sub-biome and compute `terrain_modification_cost` for the cell
/// at `(row, col)`. Returns `(variant, cost)`.
pub fn classify(ta: &TerrainAnalysis, row: usize, col: usize) -> (SubBiomeVariant, i32) {
let i = row * ta.w + col;
let elev_pct = ta.elev_pct[i];
let slope = ta.slope_deg[i];
let water_dist = ta.water_dist[i];
let temp = temperature(row, ta.h);
let variant = classify_variant(elev_pct, slope, water_dist, temp);
// Cost = sub-biome base + a slope surcharge (steeper terrain costs more to
// build on), capped so a steep grassland never out-costs flat volcanic.
// Surcharge in percent points (0–150): the f32 slope is quantized here, the
// single f32→integer boundary; the cost itself is integer (D-010, #955).
let slope_surcharge = ((slope / 12.0).min(1.5) * 100.0).round() as i32;
let cost = base_cost(variant) + slope_surcharge;
(variant, cost)
}
fn classify_variant(elev_pct: f32, _slope: f32, water_dist: u16, temp: f32) -> SubBiomeVariant {
// High elevation dominates → Alpine (mountains, regardless of latitude).
if elev_pct > 0.80 {
return SubBiomeVariant::Alpine;
}
// Saturated low ground next to water → Wetland.
if water_dist <= 2 && elev_pct < 0.30 {
return SubBiomeVariant::Wetland;
}
// Low ground near a coast → Coastal lowland.
if water_dist <= 5 && elev_pct < 0.40 {
return SubBiomeVariant::CoastalLowland;
}
// Cold poleward zones.
if temp < 0.20 {
return SubBiomeVariant::Tundra;
}
if temp < 0.40 {
return SubBiomeVariant::BorealForest;
}
// Hot equatorial zones split by moisture.
if temp > 0.75 {
return if water_dist < 20 {
SubBiomeVariant::TropicalWet
} else if water_dist < 45 {
SubBiomeVariant::Savanna
} else {
SubBiomeVariant::Desert
};
}
// Temperate mid-latitudes split by moisture.
if water_dist > 60 {
SubBiomeVariant::Desert
} else if water_dist < 25 {
SubBiomeVariant::TemperateForest
} else {
SubBiomeVariant::TemperateGrassland
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::drainage;
use crate::atlas::heightmap::BodyHeightmap;
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()
}
#[test]
fn temperature_peaks_at_equator() {
assert!((temperature(0, 256) - 0.0).abs() < 0.01);
assert!((temperature(255, 256) - 0.0).abs() < 0.01);
assert!(temperature(128, 256) > 0.98);
}
#[test]
fn alpine_for_high_elevation() {
// elev_pct > 0.8 → Alpine regardless of other signals.
let (v, cost) = (
classify_variant(0.95, 30.0, 100, 0.5),
base_cost(SubBiomeVariant::Alpine),
);
assert_eq!(v, SubBiomeVariant::Alpine);
assert!(cost > 300, "alpine cost well above the 100 baseline");
}
#[test]
fn classify_is_deterministic_and_bounded() {
let h = BodyHeightmap {
body_id: "T".into(),
width: 64,
height: 32,
data: slope_grid(64, 32),
sea_level: 0.3,
};
let dr = drainage::analyze(&h.data, 64, 32, 0.3);
let ta = TerrainAnalysis::analyze(&h, &dr);
let (v1, c1) = classify(&ta, 10, 20);
let (v2, c2) = classify(&ta, 10, 20);
assert_eq!(v1, v2);
assert_eq!(c1, c2);
assert!(c1 >= 100, "cost is at least the grassland baseline");
}
#[test]
fn each_variant_reachable_and_volcanic_never_emitted() {
use SubBiomeVariant::*;
// (elev_pct, slope, water_dist, temp) → expected variant, per the
// classify_variant branch order. Covers all 10 derivable variants.
let cases: &[(f32, f32, u16, f32, SubBiomeVariant)] = &[
(0.95, 0.0, 100, 0.5, Alpine), // high elevation dominates
(0.10, 0.0, 1, 0.5, Wetland), // saturated low ground
(0.35, 0.0, 4, 0.5, CoastalLowland), // near coast, low
(0.50, 0.0, 100, 0.10, Tundra), // cold pole
(0.50, 0.0, 100, 0.30, BorealForest), // cool
(0.50, 0.0, 10, 0.90, TropicalWet), // hot + moist
(0.50, 0.0, 30, 0.90, Savanna), // hot + mid-dry
(0.50, 0.0, 50, 0.90, Desert), // hot + dry
(0.50, 0.0, 10, 0.50, TemperateForest), // temperate + moist
(0.50, 0.0, 40, 0.50, TemperateGrassland), // temperate + mid
(0.50, 0.0, 70, 0.50, Desert), // temperate + arid
];
for &(e, s, w, t, expected) in cases {
let got = classify_variant(e, s, w, t);
assert_eq!(got, expected, "classify_variant({e},{s},{w},{t})");
assert_ne!(
got, Volcanic,
"Volcanic must never be emitted (no L1 signal)"
);
}
}
}