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>
This commit is contained in:
@@ -785,8 +785,9 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
### D-195: Attractor-Matching Compatibility Matrix for Generative City Placement
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** City placement on a planetary surface uses an attractor-matching model. A `GeographicAttractor` is a terrain feature that increases city placement score at nearby positions. Seven `AttractorType` variants: `RiverMouth`, `CoastalAccess`, `RiverCrossing`, `ValleyFloor`, `PassEntrance`, `LakeShore`, `PlainCenter`. A `CompatibilityMatrix` is a 10×7 scoring table (10 `economic_role` values × 7 attractor types) whose cells are float weights (0.0–3.0) representing how strongly that economic role favors that terrain feature. Examples: manufacturing → RiverMouth 2.8, ValleyFloor 2.1; financial → CoastalAccess 2.5, PlainCenter 1.8; agricultural → ValleyFloor 3.0, PlainCenter 2.5. The matrix is authored data (not computed at runtime). Attractor extraction from heightmaps is defined in D-209. Matching algorithm is defined in D-211.
|
||||
- **Rationale:** Terrain-naive city placement produces spatially incoherent worlds. The compatibility matrix gives different city types different terrain affinities, so financial hubs appear on coasts and agricultural cities appear in fertile valleys — without hard-coding placement rules per city type. The float weight matrix gives graduated preference, not binary requirement.
|
||||
- **Ticket:** #919, #925
|
||||
- **Rationale:** Terrain-naive city placement produces spatially incoherent worlds. The compatibility matrix gives different city types different terrain affinities, so financial hubs appear on coasts and agricultural cities appear in fertile valleys — without hard-coding placement rules per city type. The weight matrix gives graduated preference, not binary requirement.
|
||||
- **Amended 2026-06-03 (#955):** the matrix weights and the whole placement-scoring path are **integer basis-points, not f32** (D-010 determinism / D-227 save-critical). When #955 wired `match_cities` into the deterministic generation cascade, the original f32 scoring became a live cross-platform divergence risk (a near-tie score comparison or the Hungarian's f32 reductions can round differently per platform → a different world from the same seed). Now: `CompatibilityMatrix.weights` are `i32` bps (10000 = 1.0×; the examples above are 28000 / 30000 / 25000 …), `GeographicAttractor.strength` and `terrain_modification_cost` are bps, and `cell_score` / the Hungarian / `CityPlacement.score` use integer arithmetic. The 0.0–3.0 affinity semantics are unchanged; only the representation is now integer.
|
||||
- **Ticket:** #919, #925, #955
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-208 (D8 drainage — attractor extraction), D-209 (feature tag extraction), D-211 (attractor-matching pipeline)
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
//! 5. Name fulfillment check: warn if any atlas city was not placed.
|
||||
//!
|
||||
//! **Mismatch flagging (D-211):**
|
||||
//! - score < 0.35 → WARNING
|
||||
//! - score < 0.15 → ERROR (flagged for manual review; generation continues)
|
||||
//! - score < 1500 → WARNING
|
||||
//! - score < 500 → ERROR (flagged for manual review; generation continues)
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
@@ -47,7 +47,8 @@ pub struct CityPlacement {
|
||||
pub city_id: u64,
|
||||
pub position: (u16, u16),
|
||||
pub attractor_type: AttractorType,
|
||||
pub score: f32,
|
||||
/// Integer match score (D-010). See [`cell_score`].
|
||||
pub score: i64,
|
||||
pub synthetic: bool,
|
||||
}
|
||||
|
||||
@@ -88,23 +89,27 @@ fn attractor_col(at: &AttractorType) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the raw match score between a city and an attractor.
|
||||
/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost).
|
||||
/// Compute the raw match score between a city and an attractor (integer, D-010).
|
||||
///
|
||||
/// `score = weight(0–100) × strength(0–100) × 100 / cost_pct`, where `cost_pct`
|
||||
/// is the terrain build cost as a percent of baseline (100 = 1.0×). Higher cost
|
||||
/// → lower score. All integer — no f32 in any placement decision (#955).
|
||||
fn cell_score(
|
||||
city: &CityRecord,
|
||||
attractor: &GeographicAttractor,
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_cost: f32,
|
||||
) -> f32 {
|
||||
terrain_cost_pct: i32,
|
||||
) -> i64 {
|
||||
let row = role_row(&city.economic_role);
|
||||
let col = attractor_col(&attractor.attractor_type);
|
||||
let weight = matrix.weights[row][col];
|
||||
let cost_factor = if terrain_cost > 0.0 {
|
||||
1.0 / terrain_cost
|
||||
let weight = matrix.weights[row][col] as i64;
|
||||
let strength = attractor.strength as i64;
|
||||
let cost = if terrain_cost_pct > 0 {
|
||||
terrain_cost_pct as i64
|
||||
} else {
|
||||
1.0
|
||||
100
|
||||
};
|
||||
weight * attractor.strength * cost_factor
|
||||
weight * strength * 100 / cost
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -118,7 +123,7 @@ fn cell_score(
|
||||
/// by using `max_score - score` as cost.
|
||||
///
|
||||
/// Returns `assignment[i] = j` for each row i.
|
||||
fn hungarian(cost: &[Vec<f32>]) -> Vec<usize> {
|
||||
fn hungarian(cost: &[Vec<i64>]) -> Vec<usize> {
|
||||
let n = cost.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
@@ -128,25 +133,29 @@ fn hungarian(cost: &[Vec<f32>]) -> Vec<usize> {
|
||||
return vec![usize::MAX; n];
|
||||
}
|
||||
|
||||
// Sentinel "infinity" — far above any real cost (scores ≤ ~10^4, padded
|
||||
// costs accumulate well under this) yet far below i64::MAX so the potential
|
||||
// updates can't overflow.
|
||||
let inf: i64 = 1 << 60;
|
||||
|
||||
// Pad to square n×n if m < n (more cities than attractors handled by overflow).
|
||||
let sz = n.max(m);
|
||||
let mut c: Vec<Vec<f32>> = vec![vec![0.0; sz]; sz];
|
||||
let mut c: Vec<Vec<i64>> = vec![vec![0i64; sz]; sz];
|
||||
for i in 0..n {
|
||||
for j in 0..m {
|
||||
c[i][j] = cost[i][j];
|
||||
}
|
||||
// Pad extra columns with high cost so overflow cities pick them last.
|
||||
for item in c[i].iter_mut().take(sz).skip(m) {
|
||||
*item = f32::MAX / 2.0;
|
||||
*item = inf;
|
||||
}
|
||||
}
|
||||
// Pad extra rows with 0 cost (dummy workers).
|
||||
// Already initialized to 0.
|
||||
|
||||
// Standard O(n³) Hungarian.
|
||||
let inf = f32::MAX / 2.0;
|
||||
let mut u = vec![0.0f32; sz + 1];
|
||||
let mut v = vec![0.0f32; sz + 1];
|
||||
let mut u = vec![0i64; sz + 1];
|
||||
let mut v = vec![0i64; sz + 1];
|
||||
let mut p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed)
|
||||
let mut way = vec![0usize; sz + 1];
|
||||
|
||||
@@ -243,9 +252,9 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
|
||||
GeographicAttractor {
|
||||
position: (row, col),
|
||||
attractor_type: AttractorType::PlainCenter,
|
||||
strength: 0.5,
|
||||
strength: 50,
|
||||
sub_biome: SubBiomeVariant::TemperateGrassland,
|
||||
terrain_modification_cost: 1.0,
|
||||
terrain_modification_cost: 100,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,16 +265,16 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
|
||||
/// Run the five-phase attractor-matching pipeline (D-211).
|
||||
///
|
||||
/// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline).
|
||||
/// If `None`, all costs default to 1.0.
|
||||
/// If `None`, all costs default to 100 (1.0× baseline).
|
||||
pub fn match_cities(
|
||||
cities: &[CityRecord],
|
||||
attractors: &[GeographicAttractor],
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_costs: Option<&[f32]>,
|
||||
terrain_costs: Option<&[i32]>,
|
||||
grid_w: u32,
|
||||
grid_h: u32,
|
||||
) -> Vec<CityPlacement> {
|
||||
let default_cost = vec![1.0f32; attractors.len()];
|
||||
let default_cost = vec![100i32; attractors.len()];
|
||||
let costs = terrain_costs.unwrap_or(&default_cost);
|
||||
|
||||
let mut placements: Vec<CityPlacement> = Vec::with_capacity(cities.len());
|
||||
@@ -274,7 +283,7 @@ pub fn match_cities(
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 1: Score matrix
|
||||
// -------------------------------------------------------------------------
|
||||
let scores: Vec<Vec<f32>> = cities
|
||||
let scores: Vec<Vec<i64>> = cities
|
||||
.iter()
|
||||
.map(|city| {
|
||||
attractors
|
||||
@@ -306,7 +315,7 @@ pub fn match_cities(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ai, _)| !used_attractors[*ai])
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
.max_by(|(_, a), (_, b)| a.cmp(b));
|
||||
|
||||
if let Some((ai, &score)) = best {
|
||||
used_attractors[ai] = true;
|
||||
@@ -340,12 +349,13 @@ pub fn match_cities(
|
||||
if !tier_bc_indices.is_empty() && !free_attractors.is_empty() {
|
||||
// Build cost sub-matrix (maximization → minimization via complement).
|
||||
let scores_ref = &scores;
|
||||
let max_score: f32 = tier_bc_indices
|
||||
let max_score: i64 = tier_bc_indices
|
||||
.iter()
|
||||
.flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai]))
|
||||
.fold(0.0f32, f32::max);
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let cost: Vec<Vec<f32>> = tier_bc_indices
|
||||
let cost: Vec<Vec<i64>> = tier_bc_indices
|
||||
.iter()
|
||||
.map(|&ci| {
|
||||
free_attractors
|
||||
@@ -387,7 +397,7 @@ pub fn match_cities(
|
||||
continue;
|
||||
}
|
||||
let synthetic = synthetic_attractor(&placements, grid_w, grid_h);
|
||||
let score = cell_score(city, &synthetic, matrix, 1.0);
|
||||
let score = cell_score(city, &synthetic, matrix, 100);
|
||||
flag_mismatch(&city.name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: city.city_id,
|
||||
@@ -416,18 +426,20 @@ pub fn match_cities(
|
||||
placements
|
||||
}
|
||||
|
||||
fn flag_mismatch(city_name: &str, score: f32) {
|
||||
if score < 0.15 {
|
||||
fn flag_mismatch(city_name: &str, score: i64) {
|
||||
// Integer score space (#955): a baseline-cost perfect match peaks at ~10000,
|
||||
// neutral ~2500. < 500 is a severe mismatch; < 1500 below expected quality.
|
||||
if score < 500 {
|
||||
error!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.15 — flagged for manual review"
|
||||
"attractor mismatch score < 500 — flagged for manual review"
|
||||
);
|
||||
} else if score < 0.35 {
|
||||
} else if score < 1500 {
|
||||
warn!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.35 — below expected quality"
|
||||
"attractor mismatch score < 1500 — below expected quality"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -481,17 +493,17 @@ mod tests {
|
||||
|
||||
fn uniform_matrix() -> CompatibilityMatrix {
|
||||
CompatibilityMatrix {
|
||||
weights: [[1.0; 7]; 10],
|
||||
weights: [[50; 7]; 10],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor {
|
||||
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: i32) -> GeographicAttractor {
|
||||
GeographicAttractor {
|
||||
position: (row, col),
|
||||
attractor_type: at,
|
||||
strength,
|
||||
sub_biome: SubBiomeVariant::TemperateGrassland,
|
||||
terrain_modification_cost: 1.0,
|
||||
terrain_modification_cost: 100,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +520,7 @@ mod tests {
|
||||
#[test]
|
||||
fn single_city_single_attractor() {
|
||||
let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)];
|
||||
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 0.8)];
|
||||
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 80)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 1);
|
||||
@@ -525,8 +537,8 @@ mod tests {
|
||||
make_city(2, SettlementClass::PopulationBudget, 200_000),
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best
|
||||
make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second
|
||||
make_attractor(5, 5, AttractorType::RiverMouth, 90), // best
|
||||
make_attractor(10, 10, AttractorType::ValleyFloor, 40), // second
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
@@ -541,7 +553,7 @@ mod tests {
|
||||
make_city(1, SettlementClass::NameLocked, 2_000_000),
|
||||
make_city(2, SettlementClass::PopulationBudget, 60_000),
|
||||
];
|
||||
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 1.0)];
|
||||
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 100)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 2);
|
||||
@@ -555,8 +567,8 @@ mod tests {
|
||||
.map(|i| make_city(i, SettlementClass::PopulationBudget, 100_000))
|
||||
.collect();
|
||||
let attractors = vec![
|
||||
make_attractor(10, 10, AttractorType::RiverMouth, 0.9),
|
||||
make_attractor(20, 20, AttractorType::CoastalAccess, 0.7),
|
||||
make_attractor(10, 10, AttractorType::RiverMouth, 90),
|
||||
make_attractor(20, 20, AttractorType::CoastalAccess, 70),
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
@@ -568,9 +580,9 @@ mod tests {
|
||||
// 2 cities, 2 attractors. City A scores best on attractor 0, city B best on attractor 1.
|
||||
let mut matrix = uniform_matrix();
|
||||
// agricultural (row 2) scores high on ValleyFloor (col 3) = 3.0
|
||||
matrix.weights[2][3] = 3.0;
|
||||
matrix.weights[2][3] = 100;
|
||||
// transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0
|
||||
matrix.weights[6][2] = 3.0;
|
||||
matrix.weights[6][2] = 100;
|
||||
let cities = vec![
|
||||
CityRecord {
|
||||
city_id: 1,
|
||||
@@ -588,8 +600,8 @@ mod tests {
|
||||
},
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::ValleyFloor, 1.0),
|
||||
make_attractor(10, 10, AttractorType::RiverCrossing, 1.0),
|
||||
make_attractor(5, 5, AttractorType::ValleyFloor, 100),
|
||||
make_attractor(10, 10, AttractorType::RiverCrossing, 100),
|
||||
];
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 2);
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::attractor_matching::CityPlacement;
|
||||
use crate::simulation::generator::{DistrictId, DistrictWorldState, GeographicAttractor};
|
||||
|
||||
/// Simulation tick counter — monotonically increasing u64.
|
||||
@@ -71,6 +72,9 @@ pub struct BodyWorldState {
|
||||
pub drainage_basins: Vec<DrainageBasin>,
|
||||
/// Geographic attractors (D-195, D-209). Empty until attractor task completes.
|
||||
pub attractors: Vec<GeographicAttractor>,
|
||||
/// Settlement placements (D-211, #955). Attractor-matched city positions.
|
||||
/// Empty until the Layer-3 placement task completes.
|
||||
pub placements: Vec<CityPlacement>,
|
||||
/// District-level world state, keyed by `DistrictId` (D-230).
|
||||
///
|
||||
/// Populated by `GenCompletion::SkeletonGenerated` after the plan phase
|
||||
@@ -196,6 +200,7 @@ mod tests {
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
placements: vec![],
|
||||
districts: BTreeMap::new(),
|
||||
last_accessed: tick,
|
||||
}
|
||||
|
||||
+130
-5
@@ -19,10 +19,12 @@ use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::attractor_matching::{match_cities, CityPlacement, CityRecord};
|
||||
use crate::atlas::body_world_state::{BodyWorldState, RiverNetwork};
|
||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||
use crate::atlas::layer1::{self, Layer1Output};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
|
||||
|
||||
/// Cascade layers in execution order (D-200). [`run_cascade`] runs every layer
|
||||
/// up to and including the requested one. Append new layers as they are built;
|
||||
@@ -33,6 +35,9 @@ pub enum CascadeLayer {
|
||||
Heightmap,
|
||||
/// Layer 1 — empty-world topography: drainage, feature tags, sub-biome (#953).
|
||||
Topography,
|
||||
/// Layer 3 — settlement placement: attractor-matched city positions (#955, D-211).
|
||||
/// First RNG-using layer (uses the carried `SeedChain`).
|
||||
Settlement,
|
||||
}
|
||||
|
||||
/// Output of the cascade for one body, up to the requested layer (#952).
|
||||
@@ -49,6 +54,15 @@ pub struct CascadeSnapshot {
|
||||
pub heightmap: BodyHeightmap,
|
||||
/// Layer 1 — topography. `Some` once [`CascadeLayer::Topography`] has run.
|
||||
pub layer1: Option<Layer1Output>,
|
||||
/// Layer 3 — settlement placement. `Some` once [`CascadeLayer::Settlement`] has run.
|
||||
pub layer3: Option<Layer3Output>,
|
||||
}
|
||||
|
||||
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
|
||||
/// body. Re-derivable from (Layer-1 attractors + settlement records + seed).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Layer3Output {
|
||||
pub placements: Vec<CityPlacement>,
|
||||
}
|
||||
|
||||
impl CascadeSnapshot {
|
||||
@@ -61,6 +75,7 @@ impl CascadeSnapshot {
|
||||
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
|
||||
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
|
||||
};
|
||||
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
|
||||
BodyWorldState {
|
||||
body_id: self.body_id,
|
||||
heightmap: self.heightmap.data,
|
||||
@@ -69,20 +84,42 @@ impl CascadeSnapshot {
|
||||
river_network,
|
||||
drainage_basins,
|
||||
attractors,
|
||||
placements,
|
||||
districts: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer 3 — settlement placement (#955, D-211). Pure: matches the body's
|
||||
/// settlements to its Layer-1 attractors via the authored D-195 compatibility
|
||||
/// matrix (the five-phase `match_cities` pipeline). Deterministic — a pure
|
||||
/// function of (attractors, cities); no RNG.
|
||||
///
|
||||
/// `terrain_costs` is `None` for now (uniform 1.0); wiring sub-biome
|
||||
/// `terrain_modification_cost` (D-234) is a follow-on refinement.
|
||||
fn run_layer3(
|
||||
attractors: &[GeographicAttractor],
|
||||
cities: &[CityRecord],
|
||||
grid_w: u32,
|
||||
grid_h: u32,
|
||||
) -> Layer3Output {
|
||||
let matrix = CompatibilityMatrix::d195();
|
||||
let placements = match_cities(cities, attractors, &matrix, None, grid_w, grid_h);
|
||||
Layer3Output { placements }
|
||||
}
|
||||
|
||||
/// Run the cascade from a heightmap already in memory, up to `up_to`.
|
||||
///
|
||||
/// Pure (no I/O); this is the testable core. `body_seed` is this body's
|
||||
/// [`SeedChain`] position — the caller derives it from the world seed via
|
||||
/// `SeedChain::root(world_seed).derive(SeedDomain::Body, id)`.
|
||||
/// `SeedChain::root(world_seed).derive(SeedDomain::Body, id)`. `cities` are the
|
||||
/// body's settlements (from `atlas_city_names`, supplied by the caller — the
|
||||
/// cascade stays DB-free); empty until Layer 3 (`Settlement`) is requested.
|
||||
pub fn run_cascade_from_heightmap(
|
||||
body_seed: SeedChain,
|
||||
heightmap: BodyHeightmap,
|
||||
cities: &[CityRecord],
|
||||
up_to: CascadeLayer,
|
||||
) -> CascadeSnapshot {
|
||||
let mut snapshot = CascadeSnapshot {
|
||||
@@ -90,6 +127,7 @@ pub fn run_cascade_from_heightmap(
|
||||
seed: body_seed,
|
||||
heightmap,
|
||||
layer1: None,
|
||||
layer3: None,
|
||||
};
|
||||
|
||||
// Layer 1 — topography (RNG-free; pure function of the heightmap).
|
||||
@@ -97,6 +135,25 @@ pub fn run_cascade_from_heightmap(
|
||||
snapshot.layer1 = Some(layer1::run_layer1(&snapshot.heightmap));
|
||||
}
|
||||
|
||||
// Layer 3 — settlement placement (D-211). Requires Layer 1 attractors, which
|
||||
// are present because Settlement > Topography in the layer order.
|
||||
if up_to >= CascadeLayer::Settlement {
|
||||
let attractors: &[GeographicAttractor] = match snapshot.layer1.as_ref() {
|
||||
Some(l1) => &l1.attractors,
|
||||
None => &[],
|
||||
};
|
||||
// cache seam: run_layer3 is a pure, deterministic function of
|
||||
// (attractors, cities) — wrap a persistent cache here when we add one
|
||||
// (build-time bake or local cache; see #1021).
|
||||
let l3 = run_layer3(
|
||||
attractors,
|
||||
cities,
|
||||
snapshot.heightmap.width,
|
||||
snapshot.heightmap.height,
|
||||
);
|
||||
snapshot.layer3 = Some(l3);
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
@@ -109,11 +166,14 @@ pub fn run_cascade(
|
||||
body_id: &str,
|
||||
heightmap_path: &Path,
|
||||
default_sea_level: f32,
|
||||
cities: &[CityRecord],
|
||||
up_to: CascadeLayer,
|
||||
) -> Result<CascadeSnapshot, HeightmapLoadError> {
|
||||
// Layer 0 — the cascade's input; always loaded.
|
||||
let heightmap = heightmap::load_heightmap_png(heightmap_path, body_id, default_sea_level)?;
|
||||
Ok(run_cascade_from_heightmap(body_seed, heightmap, up_to))
|
||||
Ok(run_cascade_from_heightmap(
|
||||
body_seed, heightmap, cities, up_to,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -149,7 +209,7 @@ mod tests {
|
||||
#[test]
|
||||
fn heightmap_layer_skips_layer1() {
|
||||
let snap =
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), CascadeLayer::Heightmap);
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), &[], CascadeLayer::Heightmap);
|
||||
assert_eq!(snap.body_id, "test_body");
|
||||
assert!(
|
||||
snap.layer1.is_none(),
|
||||
@@ -159,8 +219,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn topography_layer_runs_layer1() {
|
||||
let snap =
|
||||
run_cascade_from_heightmap(body_seed(), test_heightmap(), CascadeLayer::Topography);
|
||||
let snap = run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
);
|
||||
let l1 = snap.layer1.expect("Layer 1 should have run");
|
||||
assert_eq!(l1.body_id, "test_body");
|
||||
}
|
||||
@@ -177,11 +241,13 @@ mod tests {
|
||||
let a = extract(run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
let b = extract(run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
CascadeLayer::Topography,
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -203,8 +269,67 @@ mod tests {
|
||||
"missing",
|
||||
std::path::Path::new("/nonexistent/sr-test/heightmap.png"),
|
||||
0.3,
|
||||
&[],
|
||||
CascadeLayer::Heightmap,
|
||||
);
|
||||
assert!(res.is_err(), "missing heightmap must Err, not panic");
|
||||
}
|
||||
|
||||
/// Layer 3 — settlement placement runs, places the body's settlements onto
|
||||
/// attractors, and is deterministic (#955, D-211).
|
||||
#[test]
|
||||
fn settlement_layer_places_cities_deterministically() {
|
||||
use crate::atlas::attractor_matching::CityRecord;
|
||||
use crate::simulation::generator::SettlementClass;
|
||||
|
||||
let cities = vec![
|
||||
CityRecord {
|
||||
city_id: 1,
|
||||
name: "Capital".into(),
|
||||
settlement_class: SettlementClass::NameLocked,
|
||||
population: 2_000_000,
|
||||
economic_role: "financial".into(),
|
||||
},
|
||||
CityRecord {
|
||||
city_id: 2,
|
||||
name: "Farm Town".into(),
|
||||
settlement_class: SettlementClass::OrganicGrowth,
|
||||
population: 120_000,
|
||||
economic_role: "agricultural".into(),
|
||||
},
|
||||
];
|
||||
let run = || {
|
||||
run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&cities,
|
||||
CascadeLayer::Settlement,
|
||||
)
|
||||
};
|
||||
let snap = run();
|
||||
let placement_count = {
|
||||
let l3 = snap.layer3.as_ref().expect("Layer 3 should have run");
|
||||
assert!(
|
||||
!l3.placements.is_empty(),
|
||||
"settlements must be placed when Layer 1 produced attractors"
|
||||
);
|
||||
l3.placements.len()
|
||||
};
|
||||
// Determinism: same inputs → identical placements (positions + city_ids).
|
||||
let key = |s: &CascadeSnapshot| {
|
||||
s.layer3
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.placements
|
||||
.iter()
|
||||
.map(|p| (p.city_id, p.position, p.attractor_type, p.synthetic))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(key(&snap), key(&run()), "placement must be deterministic");
|
||||
// The placements propagate into the hot-cache BodyWorldState.
|
||||
assert_eq!(
|
||||
snap.into_body_world_state().placements.len(),
|
||||
placement_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ pub struct RawAttractor {
|
||||
pub row: u16,
|
||||
pub col: u16,
|
||||
pub attractor_type: AttractorType,
|
||||
pub strength: f32,
|
||||
/// Strength on a 0–100 integer scale (100 = strongest). Quantized here from
|
||||
/// the f32 flow-accumulation ratio — the single f32→integer boundary, after
|
||||
/// which every ranking/scoring decision is integer (D-010, #955).
|
||||
pub strength: i32,
|
||||
}
|
||||
|
||||
/// Precomputed per-cell terrain fields, shared by feature extraction (D-209)
|
||||
@@ -311,7 +314,8 @@ pub fn extract_attractors(
|
||||
row: r as u16,
|
||||
col: c as u16,
|
||||
attractor_type: at,
|
||||
strength: strength.clamp(0.0, 1.0),
|
||||
// The single f32→integer boundary: quantize the 0.0–1.0 ratio to 0–100.
|
||||
strength: (strength.clamp(0.0, 1.0) * 100.0).round() as i32,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -462,9 +466,11 @@ pub fn extract_attractors(
|
||||
}
|
||||
for group in by_type.values_mut() {
|
||||
group.sort_by(|a, b| {
|
||||
let sa = (a.strength * 1e6) as i64;
|
||||
let sb = (b.strength * 1e6) as i64;
|
||||
sb.cmp(&sa).then(a.row.cmp(&b.row)).then(a.col.cmp(&b.col))
|
||||
// strength is integer now — rank by it directly (descending).
|
||||
b.strength
|
||||
.cmp(&a.strength)
|
||||
.then(a.row.cmp(&b.row))
|
||||
.then(a.col.cmp(&b.col))
|
||||
});
|
||||
}
|
||||
let mut kept: Vec<RawAttractor> = Vec::with_capacity(MAX_ATTRACTORS);
|
||||
|
||||
@@ -356,8 +356,10 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
} else {
|
||||
hm
|
||||
};
|
||||
// Cities (&[]) get supplied once the Layer-3 settlement read is
|
||||
// wired through the work item (#955 follow-on); Topography needs none.
|
||||
let snapshot =
|
||||
run_cascade_from_heightmap(*body_seed, working, CascadeLayer::Topography);
|
||||
run_cascade_from_heightmap(*body_seed, working, &[], CascadeLayer::Topography);
|
||||
GenCompletion::BodyAnalyzed {
|
||||
body_id: body_id.clone(),
|
||||
state: snapshot.into_body_world_state(),
|
||||
|
||||
+10
-16
@@ -90,9 +90,9 @@ pub fn attach_feature_names(
|
||||
.filter(|a| a.attractor_type == AttractorType::RiverMouth)
|
||||
.collect();
|
||||
mouths.sort_by(|a, b| {
|
||||
let sa = (a.strength * 1e6) as i64;
|
||||
let sb = (b.strength * 1e6) as i64;
|
||||
sb.cmp(&sa)
|
||||
// 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))
|
||||
});
|
||||
@@ -114,9 +114,9 @@ pub fn attach_feature_names(
|
||||
})
|
||||
.collect();
|
||||
peaks.sort_by(|a, b| {
|
||||
let sa = (a.strength * 1e6) as i64;
|
||||
let sb = (b.strength * 1e6) as i64;
|
||||
sb.cmp(&sa)
|
||||
// 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))
|
||||
});
|
||||
@@ -163,12 +163,9 @@ mod tests {
|
||||
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.to_bits(), b.strength.to_bits());
|
||||
assert_eq!(a.strength, b.strength);
|
||||
assert_eq!(a.sub_biome, b.sub_biome);
|
||||
assert_eq!(
|
||||
a.terrain_modification_cost.to_bits(),
|
||||
b.terrain_modification_cost.to_bits()
|
||||
);
|
||||
assert_eq!(a.terrain_modification_cost, b.terrain_modification_cost);
|
||||
}
|
||||
assert_eq!(o1.river_network.river_cells, o2.river_network.river_cells);
|
||||
}
|
||||
@@ -180,11 +177,8 @@ mod tests {
|
||||
assert!(o
|
||||
.attractors
|
||||
.iter()
|
||||
.all(|a| a.terrain_modification_cost >= 1.0));
|
||||
assert!(o
|
||||
.attractors
|
||||
.iter()
|
||||
.all(|a| (0.0..=1.0).contains(&a.strength)));
|
||||
.all(|a| a.terrain_modification_cost >= 100));
|
||||
assert!(o.attractors.iter().all(|a| (0..=100).contains(&a.strength)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -200,6 +200,7 @@ mod tests {
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
placements: vec![],
|
||||
districts: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
});
|
||||
|
||||
@@ -32,25 +32,27 @@ fn temperature(row: usize, h: usize) -> f32 {
|
||||
/// 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).
|
||||
fn base_cost(v: SubBiomeVariant) -> f32 {
|
||||
/// 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 => 1.0,
|
||||
SubBiomeVariant::Savanna => 1.1,
|
||||
SubBiomeVariant::Desert => 1.2,
|
||||
SubBiomeVariant::TemperateForest => 1.3,
|
||||
SubBiomeVariant::CoastalLowland => 1.4,
|
||||
SubBiomeVariant::BorealForest => 1.5,
|
||||
SubBiomeVariant::Tundra => 1.6,
|
||||
SubBiomeVariant::TropicalWet => 2.0,
|
||||
SubBiomeVariant::Wetland => 3.2,
|
||||
SubBiomeVariant::Alpine => 3.8,
|
||||
SubBiomeVariant::Volcanic => 4.5,
|
||||
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, f32) {
|
||||
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];
|
||||
@@ -61,7 +63,9 @@ pub fn classify(ta: &TerrainAnalysis, row: usize, col: usize) -> (SubBiomeVarian
|
||||
|
||||
// Cost = sub-biome base + a slope surcharge (steeper terrain costs more to
|
||||
// build on), capped so a steep grassland never out-costs flat volcanic.
|
||||
let slope_surcharge = (slope / 12.0).min(1.5);
|
||||
// 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)
|
||||
@@ -139,7 +143,7 @@ mod tests {
|
||||
base_cost(SubBiomeVariant::Alpine),
|
||||
);
|
||||
assert_eq!(v, SubBiomeVariant::Alpine);
|
||||
assert!(cost > 3.0);
|
||||
assert!(cost > 300, "alpine cost well above the 100 baseline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -156,8 +160,8 @@ mod tests {
|
||||
let (v1, c1) = classify(&ta, 10, 20);
|
||||
let (v2, c2) = classify(&ta, 10, 20);
|
||||
assert_eq!(v1, v2);
|
||||
assert_eq!(c1.to_bits(), c2.to_bits());
|
||||
assert!(c1 >= 1.0, "cost is at least the grassland baseline");
|
||||
assert_eq!(c1, c2);
|
||||
assert!(c1 >= 100, "cost is at least the grassland baseline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -439,27 +439,62 @@ pub struct GeographicAttractor {
|
||||
/// Pixel position in heightmap space [row, col].
|
||||
pub position: (u16, u16),
|
||||
pub attractor_type: AttractorType,
|
||||
/// Normalized strength 0.0–1.0. Derived from flow accumulation or habitability score.
|
||||
pub strength: f32,
|
||||
/// Strength on a 0–100 integer scale (100 = strongest). Quantized from flow
|
||||
/// accumulation / habitability at Layer-1 output. Integer for D-010
|
||||
/// determinism — every ranking/scoring decision is exact, no f32 (#955).
|
||||
pub strength: i32,
|
||||
/// Fine-grained terrain classification at this position (D-210).
|
||||
pub sub_biome: SubBiomeVariant,
|
||||
/// Infrastructure-build cost multiplier (1.0 = baseline grassland; higher =
|
||||
/// more expensive). Derived from `sub_biome` + local slope. Consumed by the
|
||||
/// attractor-matching pipeline (D-211) to penalize marginal cities. (D-210)
|
||||
pub terrain_modification_cost: f32,
|
||||
/// Infrastructure-build cost as a percent of baseline (100 = baseline
|
||||
/// grassland; >100 = more expensive, e.g. 200 = 2× cost). Derived from
|
||||
/// `sub_biome` + local slope; penalizes marginal cities in the matching
|
||||
/// pipeline (D-211). Integer for D-010 determinism (#955). (D-210)
|
||||
pub terrain_modification_cost: i32,
|
||||
}
|
||||
|
||||
/// Compatibility weights between economic roles and attractor types.
|
||||
/// A 10×7 matrix (10 economic_role values × 7 AttractorType variants).
|
||||
/// Each cell is a weight multiplier 0.0–3.0 applied during attractor-matching scoring.
|
||||
/// Source: D-195
|
||||
/// Each cell is an integer affinity 0–100 (100 = ideal terrain for that role,
|
||||
/// ~50 = neutral, low = poor) used during attractor-matching scoring.
|
||||
/// Integer for D-010 determinism (#955). Source: D-195
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CompatibilityMatrix {
|
||||
/// Row order: manufacturing, financial, agricultural, extraction, service_mixed,
|
||||
/// institutional, transit_hub, research, military, residential.
|
||||
/// Column order: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor,
|
||||
/// PassEntrance, LakeShore, PlainCenter.
|
||||
pub weights: [[f32; 7]; 10],
|
||||
pub weights: [[i32; 7]; 10],
|
||||
}
|
||||
|
||||
impl CompatibilityMatrix {
|
||||
/// The canonical D-195 authored compatibility table (#955).
|
||||
///
|
||||
/// Integer affinity 0–100: 100 = ideal terrain for the role, ~50 = neutral,
|
||||
/// low (10–30) = poor fit (graduated, never a hard 0 — D-195). Re-authored
|
||||
/// on the 0–100 scale from D-195's relative examples (the agricultural→
|
||||
/// ValleyFloor 3.0 peak maps to the global max 100).
|
||||
///
|
||||
/// Rows: manufacturing, financial, agricultural, extraction, service_mixed,
|
||||
/// institutional, transit_hub, research, military, residential.
|
||||
/// Cols: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor,
|
||||
/// PassEntrance, LakeShore, PlainCenter.
|
||||
pub fn d195() -> Self {
|
||||
CompatibilityMatrix {
|
||||
weights: [
|
||||
// RMth Coast RXing Vall Pass Lake Plain
|
||||
[95, 80, 75, 60, 30, 35, 55], // manufacturing
|
||||
[70, 90, 65, 25, 40, 45, 70], // financial
|
||||
[85, 30, 55, 100, 10, 70, 90], // agricultural
|
||||
[30, 35, 40, 65, 95, 30, 25], // extraction
|
||||
[60, 65, 70, 50, 35, 50, 70], // service_mixed
|
||||
[65, 65, 60, 45, 20, 55, 90], // institutional
|
||||
[65, 75, 100, 35, 95, 25, 60], // transit_hub
|
||||
[40, 70, 35, 75, 40, 95, 60], // research
|
||||
[60, 75, 80, 55, 100, 25, 40], // military
|
||||
[70, 85, 50, 65, 20, 85, 70], // residential
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -55,7 +55,7 @@ fn cascade_layer0_to_1_matches_golden() {
|
||||
// ── Layer 1 — downsample, then run the cascade to topography. ───────────
|
||||
let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1);
|
||||
let body_seed = SeedChain::for_body(WORLD_SEED, "GJ1c");
|
||||
let snapshot = run_cascade_from_heightmap(body_seed, small, CascadeLayer::Topography);
|
||||
let snapshot = run_cascade_from_heightmap(body_seed, small, &[], CascadeLayer::Topography);
|
||||
let layer1 = snapshot.layer1.expect("Layer 1 ran");
|
||||
|
||||
let actual = json!({
|
||||
|
||||
@@ -557,9 +557,9 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
attractors: vec![GeographicAttractor {
|
||||
position: (12, 58),
|
||||
attractor_type: AttractorType::CoastalAccess,
|
||||
strength: 0.9,
|
||||
strength: 90,
|
||||
sub_biome: SubBiomeVariant::CoastalLowland,
|
||||
terrain_modification_cost: 1.7,
|
||||
terrain_modification_cost: 170,
|
||||
}],
|
||||
grid_w: 512,
|
||||
grid_h: 256,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user