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>
195 lines
7.3 KiB
Rust
195 lines
7.3 KiB
Rust
//! 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)"
|
||
);
|
||
}
|
||
}
|
||
}
|