feat(simulation): Layer-3 settlement spatial-character enrichment (#956)

Derive and store each placed settlement's spatial character on top of the
#955 placement (D-212/213/214/215). Derivation is pure + integer-deterministic
(D-010); the cascade stays DB-free (D-225) — the enqueuer pre-resolves the
body's system faction onto the work item, like #955's settlements.

- TerritorialStatus (D-212): mapped from the authored dominant_faction
  (territorial_status_from_faction). Adds an AutonomistHeld variant for the
  Compact of Westphalia (self-governing bloc that rejects Assembly authority —
  neither Commission, Corp, Contested, nor truly Frontier). Grounded in
  wiki/factions/. Stored per-province on DrainageBasin.territorial_status
  (uniform per body for now; forward-compatible for per-province faction data).
- PoliticalArchetype (D-214): political_archetype(status, role), status takes
  precedence over economic_role.
- ArrangementPattern (D-215): new 5-variant enum + arrangement_pattern(); the
  block-adjacency *enforcement* stays deferred to the Quarter-skeleton gen (#957)
  — this only derives + stores which pattern applies.
- FoundingOrientation (D-213): existing fn extended with a seed-derived Free
  bearing so pioneer/open-terrain grids vary per seed.

Wiring: dominant_faction threaded through CityContextReader
(read_body_dominant_faction) → atlas proxy (cache-miss read) → AnalyzeBody work
item → run_cascade → run_layer3/match_cities. Enrichment stored on CityPlacement
(political_archetype, arrangement_pattern, founding_orientation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:57:09 +02:00
co-authored by Claude Opus 4.8
parent 8b1005221d
commit af60d85d94
9 changed files with 514 additions and 45 deletions
+317 -15
View File
@@ -17,6 +17,7 @@
use tracing::{error, warn};
use crate::seed::{splitmix64, SeedChain};
use crate::simulation::generator::{
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, SubBiomeVariant,
};
@@ -41,7 +42,8 @@ pub struct CityRecord {
// Output
// ---------------------------------------------------------------------------
/// Result of matching one city to one attractor (real or synthetic).
/// Result of matching one city to one attractor (real or synthetic), enriched
/// with its spatial character (#956, D-213/214/215).
#[derive(Debug, Clone)]
pub struct CityPlacement {
pub city_id: u64,
@@ -50,6 +52,14 @@ pub struct CityPlacement {
/// Integer match score (D-010). See [`cell_score`].
pub score: i64,
pub synthetic: bool,
/// Power structure expressed in layout (D-214). Derived from the body's
/// `TerritorialStatus` + the city's economic role.
pub political_archetype: PoliticalArchetype,
/// Spatial arrangement governing district adjacency (D-215).
pub arrangement_pattern: ArrangementPattern,
/// Primary street-grid axis (D-213). Derived from the anchoring attractor
/// type; pioneer/open-terrain bearings are seed-varied.
pub founding_orientation: FoundingOrientation,
}
// ---------------------------------------------------------------------------
@@ -266,6 +276,26 @@ fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> Ge
///
/// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline).
/// If `None`, all costs default to 100 (1.0× baseline).
/// Build the #956 spatial-character enrichment (D-213/214/215) for one placed
/// city: its `PoliticalArchetype`, `ArrangementPattern`, and `FoundingOrientation`.
/// The pioneer/open-terrain `Free` bearing is seed-derived per settlement so
/// grids vary per seed (D-213) while staying deterministic (D-010).
fn city_character(
attractor_type: AttractorType,
economic_role: &str,
territorial_status: &TerritorialStatus,
city_id: u64,
seed: SeedChain,
) -> (PoliticalArchetype, ArrangementPattern, FoundingOrientation) {
let archetype = political_archetype(territorial_status, economic_role);
let pattern = arrangement_pattern(&archetype, economic_role);
let free_bearing = (splitmix64(seed.seed() ^ city_id) % 360) as u16;
// river_bearing / coastal_facing are 0 until Layer 1 exposes terrain bearings
// (D-209 follow-on); free_bearing seeds the pioneer/open-terrain case.
let orientation = founding_orientation(&attractor_type, territorial_status, 0, 0, free_bearing);
(archetype, pattern, orientation)
}
pub fn match_cities(
cities: &[CityRecord],
attractors: &[GeographicAttractor],
@@ -273,6 +303,8 @@ pub fn match_cities(
terrain_costs: Option<&[i32]>,
grid_w: u32,
grid_h: u32,
territorial_status: &TerritorialStatus,
seed: SeedChain,
) -> Vec<CityPlacement> {
let default_cost = vec![100i32; attractors.len()];
let costs = terrain_costs.unwrap_or(&default_cost);
@@ -320,12 +352,22 @@ pub fn match_cities(
if let Some((ai, &score)) = best {
used_attractors[ai] = true;
flag_mismatch(&cities[ci].name, score);
let (archetype, pattern, orientation) = city_character(
attractors[ai].attractor_type,
&cities[ci].economic_role,
territorial_status,
cities[ci].city_id,
seed,
);
placements.push(CityPlacement {
city_id: cities[ci].city_id,
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type,
score,
synthetic: false,
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
});
}
}
@@ -376,12 +418,22 @@ pub fn match_cities(
let score = scores[ci][ai];
used_attractors[ai] = true;
flag_mismatch(&cities[ci].name, score);
let (archetype, pattern, orientation) = city_character(
attractors[ai].attractor_type,
&cities[ci].economic_role,
territorial_status,
cities[ci].city_id,
seed,
);
placements.push(CityPlacement {
city_id: cities[ci].city_id,
position: attractors[ai].position,
attractor_type: attractors[ai].attractor_type,
score,
synthetic: false,
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
});
}
}
@@ -399,12 +451,22 @@ pub fn match_cities(
let synthetic = synthetic_attractor(&placements, grid_w, grid_h);
let score = cell_score(city, &synthetic, matrix, 100);
flag_mismatch(&city.name, score);
let (archetype, pattern, orientation) = city_character(
AttractorType::PlainCenter,
&city.economic_role,
territorial_status,
city.city_id,
seed,
);
placements.push(CityPlacement {
city_id: city.city_id,
position: synthetic.position,
attractor_type: AttractorType::PlainCenter,
score,
synthetic: true,
political_archetype: archetype,
arrangement_pattern: pattern,
founding_orientation: orientation,
});
}
@@ -448,18 +510,22 @@ fn flag_mismatch(city_name: &str, score: i64) {
// FoundingOrientation derivation from matched attractor (D-211, D-213)
// ---------------------------------------------------------------------------
use crate::simulation::generator::FoundingOrientation;
use crate::simulation::generator::TerritorialStatus;
use crate::simulation::generator::{
ArrangementPattern, FoundingOrientation, PoliticalArchetype, TerritorialStatus,
};
/// Derive `FoundingOrientation` from the attractor type that anchored the city (D-211, D-213).
///
/// `river_bearing` and `coastal_facing` are compass degrees 0359.
/// Pass 0 as default when the terrain doesn't dictate a specific bearing.
/// `river_bearing` and `coastal_facing` are compass degrees 0359 (terrain
/// bearings; not yet extracted by Layer 1 — pass 0 until D-209 exposes them).
/// `free_bearing` is a seed-derived 0359 bearing used only for the `Free`
/// (pioneer / open-terrain) case so pioneer grids vary per seed (D-213).
pub fn founding_orientation(
attractor_type: &AttractorType,
territorial_status: &TerritorialStatus,
river_bearing: u16,
coastal_facing: u16,
free_bearing: u16,
) -> FoundingOrientation {
match attractor_type {
AttractorType::RiverMouth | AttractorType::CoastalAccess => FoundingOrientation::Coastal {
@@ -473,7 +539,9 @@ pub fn founding_orientation(
if matches!(territorial_status, TerritorialStatus::CommissionControlled) {
FoundingOrientation::Cardinal
} else {
FoundingOrientation::Free { bearing_degrees: 0 }
FoundingOrientation::Free {
bearing_degrees: free_bearing,
}
}
}
AttractorType::PassEntrance | AttractorType::LakeShore => {
@@ -482,6 +550,89 @@ pub fn founding_orientation(
}
}
/// Map a system's authored `dominant_faction` (8-value vocabulary, D-237) to a
/// province `TerritorialStatus` (D-212, amended 2026-06-05).
///
/// D-212's original derivation reads numeric per-faction influence thresholds,
/// but only a single authored `dominant_faction` exists in the data — so this
/// collapses the thresholds to a faction→status lookup, grounded in the faction
/// canon (`wiki/factions/`):
/// - `concord_assembly`, `veil_institute` → `CommissionControlled` (the Assembly
/// is the Reach's central government; the Institute is Assembly-aligned)
/// - `syndic_dominant` → `CorpTerritory` (Syndics are the commercial network)
/// - `compact`, `compact_sympathetic` → `AutonomistHeld` (the Compact of
/// Westphalia governs itself and rejects Assembly authority)
/// - `disputed`, `mixed` → `ContestedZone`
/// - `independent`, NULL, or any unknown value → `FrontierUnclaimed`
///
/// `IndigenousHeld` and `Derelict` are not reachable from `dominant_faction`
/// alone (they need the cultural-corridor autonomy flag / population density);
/// those derivations are deferred.
pub fn territorial_status_from_faction(dominant_faction: Option<&str>) -> TerritorialStatus {
match dominant_faction {
Some("concord_assembly") | Some("veil_institute") => {
TerritorialStatus::CommissionControlled
}
Some("syndic_dominant") => TerritorialStatus::CorpTerritory,
Some("compact") | Some("compact_sympathetic") => TerritorialStatus::AutonomistHeld,
Some("disputed") | Some("mixed") => TerritorialStatus::ContestedZone,
_ => TerritorialStatus::FrontierUnclaimed,
}
}
/// Derive `PoliticalArchetype` from `TerritorialStatus` + `economic_role` (D-214).
///
/// `TerritorialStatus` takes precedence over `economic_role` (D-214): a
/// Commission-controlled manufacturing hub is `Commission`, not `Industrial`.
/// Statuses that don't dictate an archetype (`ContestedZone`, `IndigenousHeld`,
/// `Derelict`) fall through to the economic role. `AutonomistHeld` and
/// `FrontierUnclaimed` → `Pioneer` (self-organized, no central planner).
pub fn political_archetype(
territorial_status: &TerritorialStatus,
economic_role: &str,
) -> PoliticalArchetype {
match territorial_status {
TerritorialStatus::CommissionControlled => return PoliticalArchetype::Commission,
TerritorialStatus::CorpTerritory => return PoliticalArchetype::Corporate,
TerritorialStatus::FrontierUnclaimed | TerritorialStatus::AutonomistHeld => {
return PoliticalArchetype::Pioneer
}
TerritorialStatus::ContestedZone
| TerritorialStatus::IndigenousHeld
| TerritorialStatus::Derelict => {}
}
match economic_role {
"military" => PoliticalArchetype::Military,
"research" => PoliticalArchetype::Academic,
"manufacturing" | "extraction" => PoliticalArchetype::Industrial,
_ => PoliticalArchetype::Pioneer,
}
}
/// Derive the spatial `ArrangementPattern` from archetype + economic_role (D-215).
///
/// `transit_hub` is a cross-archetype override → `HubAndSpoke`. Otherwise:
/// Commission/Academic → `RadialCore`, Corporate → `CampusGrid`,
/// Pioneer/Industrial → `RibbonDevelopment`, Military → `FortifiedPerimeter`.
pub fn arrangement_pattern(
archetype: &PoliticalArchetype,
economic_role: &str,
) -> ArrangementPattern {
if economic_role == "transit_hub" {
return ArrangementPattern::HubAndSpoke;
}
match archetype {
PoliticalArchetype::Commission | PoliticalArchetype::Academic => {
ArrangementPattern::RadialCore
}
PoliticalArchetype::Corporate => ArrangementPattern::CampusGrid,
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
ArrangementPattern::RibbonDevelopment
}
PoliticalArchetype::Military => ArrangementPattern::FortifiedPerimeter,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -489,7 +640,11 @@ pub fn founding_orientation(
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
use crate::seed::SeedChain;
use crate::simulation::generator::{
ArrangementPattern, CompatibilityMatrix, FoundingOrientation, GeographicAttractor,
PoliticalArchetype, TerritorialStatus,
};
fn uniform_matrix() -> CompatibilityMatrix {
CompatibilityMatrix {
@@ -522,7 +677,16 @@ mod tests {
let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)];
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 80)];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
512,
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
);
assert_eq!(placements.len(), 1);
assert_eq!(placements[0].city_id, 1);
assert_eq!(placements[0].position, (10, 20));
@@ -541,7 +705,16 @@ mod tests {
make_attractor(10, 10, AttractorType::ValleyFloor, 40), // second
];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
512,
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
);
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
}
@@ -555,7 +728,16 @@ mod tests {
];
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 100)];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
512,
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
);
assert_eq!(placements.len(), 2);
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
assert!(p2.synthetic);
@@ -571,7 +753,16 @@ mod tests {
make_attractor(20, 20, AttractorType::CoastalAccess, 70),
];
let matrix = uniform_matrix();
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
512,
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
);
assert_eq!(placements.len(), 5, "all cities must be placed");
}
@@ -603,7 +794,16 @@ mod tests {
make_attractor(5, 5, AttractorType::ValleyFloor, 100),
make_attractor(10, 10, AttractorType::RiverCrossing, 100),
];
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
let placements = match_cities(
&cities,
&attractors,
&matrix,
None,
512,
256,
&TerritorialStatus::FrontierUnclaimed,
SeedChain::root(42),
);
assert_eq!(placements.len(), 2);
let farm = placements.iter().find(|p| p.city_id == 1).unwrap();
let hub = placements.iter().find(|p| p.city_id == 2).unwrap();
@@ -614,9 +814,8 @@ mod tests {
#[test]
fn founding_orientation_from_attractor() {
use crate::simulation::generator::TerritorialStatus;
let status = TerritorialStatus::FrontierUnclaimed;
let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270);
let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270, 0);
assert!(matches!(
o,
FoundingOrientation::Coastal {
@@ -629,10 +828,113 @@ mod tests {
&TerritorialStatus::CommissionControlled,
0,
0,
0,
);
assert!(matches!(o2, FoundingOrientation::Cardinal));
let o3 = founding_orientation(&AttractorType::ValleyFloor, &status, 0, 0);
let o3 = founding_orientation(&AttractorType::ValleyFloor, &status, 0, 0, 0);
assert!(matches!(o3, FoundingOrientation::TerrainFollowing));
// Free (pioneer/open terrain) uses the seed-derived free_bearing.
let o4 = founding_orientation(
&AttractorType::PlainCenter,
&TerritorialStatus::FrontierUnclaimed,
0,
0,
217,
);
assert!(matches!(
o4,
FoundingOrientation::Free {
bearing_degrees: 217
}
));
}
#[test]
fn territorial_status_faction_mapping() {
assert_eq!(
territorial_status_from_faction(Some("concord_assembly")),
TerritorialStatus::CommissionControlled
);
assert_eq!(
territorial_status_from_faction(Some("veil_institute")),
TerritorialStatus::CommissionControlled
);
assert_eq!(
territorial_status_from_faction(Some("syndic_dominant")),
TerritorialStatus::CorpTerritory
);
assert_eq!(
territorial_status_from_faction(Some("compact")),
TerritorialStatus::AutonomistHeld
);
assert_eq!(
territorial_status_from_faction(Some("compact_sympathetic")),
TerritorialStatus::AutonomistHeld
);
assert_eq!(
territorial_status_from_faction(Some("disputed")),
TerritorialStatus::ContestedZone
);
assert_eq!(
territorial_status_from_faction(None),
TerritorialStatus::FrontierUnclaimed
);
assert_eq!(
territorial_status_from_faction(Some("independent")),
TerritorialStatus::FrontierUnclaimed
);
}
#[test]
fn political_archetype_status_precedence() {
// Status wins over economic_role (D-214): Commission manufacturing → Commission.
assert_eq!(
political_archetype(&TerritorialStatus::CommissionControlled, "manufacturing"),
PoliticalArchetype::Commission
);
assert_eq!(
political_archetype(&TerritorialStatus::CorpTerritory, "research"),
PoliticalArchetype::Corporate
);
assert_eq!(
political_archetype(&TerritorialStatus::AutonomistHeld, "financial"),
PoliticalArchetype::Pioneer
);
// Non-dictating status → economic_role drives.
assert_eq!(
political_archetype(&TerritorialStatus::ContestedZone, "research"),
PoliticalArchetype::Academic
);
assert_eq!(
political_archetype(&TerritorialStatus::ContestedZone, "extraction"),
PoliticalArchetype::Industrial
);
}
#[test]
fn arrangement_pattern_mapping() {
assert_eq!(
arrangement_pattern(&PoliticalArchetype::Commission, "financial"),
ArrangementPattern::RadialCore
);
assert_eq!(
arrangement_pattern(&PoliticalArchetype::Corporate, "financial"),
ArrangementPattern::CampusGrid
);
assert_eq!(
arrangement_pattern(&PoliticalArchetype::Industrial, "manufacturing"),
ArrangementPattern::RibbonDevelopment
);
assert_eq!(
arrangement_pattern(&PoliticalArchetype::Military, "military"),
ArrangementPattern::FortifiedPerimeter
);
// transit_hub role overrides archetype.
assert_eq!(
arrangement_pattern(&PoliticalArchetype::Commission, "transit_hub"),
ArrangementPattern::HubAndSpoke
);
}
}
+9 -1
View File
@@ -14,7 +14,9 @@ use bevy_ecs::prelude::Resource;
use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::CityPlacement;
use crate::simulation::generator::{GeographicAttractor, QuarterId, QuarterWorldState};
use crate::simulation::generator::{
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
};
/// Simulation tick counter — monotonically increasing u64.
pub type SimTick = u64;
@@ -47,6 +49,12 @@ pub struct DrainageBasin {
pub boundary: Vec<(u16, u16)>,
/// Fraction of the body's surface area in this basin.
pub area_pct: f32,
/// Territory control status (D-212, #956). Set by the cascade after Layer 1
/// from the body's `dominant_faction`. Uniform across a body's basins for now
/// (a single system-level faction); the per-basin field is forward-compatible
/// for when per-province faction data exists. Defaults to `FrontierUnclaimed`
/// at drainage construction; the cascade overwrites it.
pub territorial_status: TerritorialStatus,
}
// ---------------------------------------------------------------------------
+78 -9
View File
@@ -20,12 +20,14 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::{match_cities, CityPlacement, CityRecord};
use crate::atlas::attractor_matching::{
match_cities, territorial_status_from_faction, 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};
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
/// 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;
@@ -101,14 +103,28 @@ impl CascadeSnapshot {
///
/// `terrain_costs` is `None` for now (uniform 1.0); wiring sub-biome
/// `terrain_modification_cost` (D-234) is a follow-on refinement.
///
/// `territorial_status` (D-212, from the body's `dominant_faction`) and `seed`
/// drive the per-settlement spatial-character enrichment (#956, D-213/214/215).
fn run_layer3(
attractors: &[GeographicAttractor],
cities: &[CityRecord],
territorial_status: &TerritorialStatus,
seed: SeedChain,
grid_w: u32,
grid_h: u32,
) -> Layer3Output {
let matrix = CompatibilityMatrix::d195();
let placements = match_cities(cities, attractors, &matrix, None, grid_w, grid_h);
let placements = match_cities(
cities,
attractors,
&matrix,
None,
grid_w,
grid_h,
territorial_status,
seed,
);
Layer3Output { placements }
}
@@ -119,10 +135,14 @@ fn run_layer3(
/// `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.
/// `dominant_faction` is the body's authored system faction (D-237); it drives
/// the `TerritorialStatus` on each province and the per-settlement spatial
/// character (#956). `None` → `FrontierUnclaimed`.
pub fn run_cascade_from_heightmap(
body_seed: SeedChain,
heightmap: BodyHeightmap,
cities: &[CityRecord],
dominant_faction: Option<&str>,
up_to: CascadeLayer,
) -> CascadeSnapshot {
let mut snapshot = CascadeSnapshot {
@@ -133,9 +153,18 @@ pub fn run_cascade_from_heightmap(
layer3: None,
};
// TerritorialStatus is derived once per body from the system's dominant
// faction (D-212, #956). Uniform across the body's provinces for now.
let territorial_status = territorial_status_from_faction(dominant_faction);
// Layer 1 — topography (RNG-free; pure function of the heightmap).
if up_to >= CascadeLayer::Topography {
snapshot.layer1 = Some(layer1::run_layer1(&snapshot.heightmap));
let mut l1 = layer1::run_layer1(&snapshot.heightmap);
// Stamp the province TerritorialStatus (D-212) onto each basin.
for basin in &mut l1.drainage_basins {
basin.territorial_status = territorial_status.clone();
}
snapshot.layer1 = Some(l1);
}
// Layer 3 — settlement placement (D-211). Requires Layer 1 attractors, which
@@ -146,11 +175,13 @@ pub fn run_cascade_from_heightmap(
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).
// (attractors, cities, territorial_status, seed) — wrap a persistent
// cache here when we add one (build-time bake or local cache; see #1021).
let l3 = run_layer3(
attractors,
cities,
&territorial_status,
body_seed,
snapshot.heightmap.width,
snapshot.heightmap.height,
);
@@ -170,12 +201,17 @@ pub fn run_cascade(
heightmap_path: &Path,
default_sea_level: f32,
cities: &[CityRecord],
dominant_faction: Option<&str>,
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, cities, up_to,
body_seed,
heightmap,
cities,
dominant_faction,
up_to,
))
}
@@ -211,8 +247,13 @@ mod tests {
#[test]
fn heightmap_layer_skips_layer1() {
let snap =
run_cascade_from_heightmap(body_seed(), test_heightmap(), &[], CascadeLayer::Heightmap);
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
CascadeLayer::Heightmap,
);
assert_eq!(snap.body_id, "test_body");
assert!(
snap.layer1.is_none(),
@@ -226,6 +267,7 @@ mod tests {
body_seed(),
test_heightmap(),
&[],
None,
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
@@ -245,12 +287,14 @@ mod tests {
body_seed(),
test_heightmap(),
&[],
None,
CascadeLayer::Topography,
));
let b = extract(run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
CascadeLayer::Topography,
));
assert_eq!(
@@ -278,6 +322,7 @@ mod tests {
std::path::Path::new("/nonexistent/sr-test/heightmap.png"),
0.3,
&[],
None,
CascadeLayer::Heightmap,
);
assert!(res.is_err(), "missing heightmap must Err, not panic");
@@ -311,6 +356,7 @@ mod tests {
body_seed(),
test_heightmap(),
&cities,
Some("concord_assembly"),
CascadeLayer::Settlement,
)
};
@@ -334,6 +380,29 @@ mod tests {
.collect::<Vec<_>>()
};
assert_eq!(key(&snap), key(&run()), "placement must be deterministic");
// #956 enrichment propagates: a concord_assembly body is
// CommissionControlled, so every placement derives the Commission
// archetype + RadialCore arrangement (D-212/214/215).
{
use crate::simulation::generator::{
ArrangementPattern, PoliticalArchetype, TerritorialStatus,
};
let l3 = snap.layer3.as_ref().unwrap();
for p in &l3.placements {
assert_eq!(p.political_archetype, PoliticalArchetype::Commission);
assert_eq!(p.arrangement_pattern, ArrangementPattern::RadialCore);
}
// TerritorialStatus is stamped on every province (D-212).
let l1 = snap.layer1.as_ref().unwrap();
assert!(
l1.drainage_basins
.iter()
.all(|b| b.territorial_status == TerritorialStatus::CommissionControlled),
"every basin inherits the body's TerritorialStatus"
);
}
// The placements propagate into the hot-cache BodyWorldState.
assert_eq!(
snap.into_body_world_state().placements.len(),
+30
View File
@@ -297,6 +297,36 @@ impl CityContextReader {
}
Ok(out)
}
/// Read the body's system `dominant_faction` (D-237) for Layer-3
/// TerritorialStatus + spatial-character derivation (#956). Joins
/// `bodies` → `system_factions` via `system_id`. Returns `None` when the
/// body is unknown or its system has no recorded faction (both → the
/// `FrontierUnclaimed` default downstream). Best-effort: only a DB/mutex
/// error fails.
pub fn read_body_dominant_faction(
&self,
body_id: &str,
) -> Result<Option<String>, CityContextReadError> {
let conn = self
.conn
.lock()
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
let result = conn.query_row(
"SELECT sf.dominant_faction
FROM bodies AS b
LEFT JOIN system_factions AS sf ON sf.system_id = b.system_id
WHERE b.body_id = ?1",
[body_id],
|row| row.get::<_, Option<String>>(0),
);
match result {
Ok(faction) => Ok(faction),
// Body not present → no faction (treated as frontier downstream).
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(CityContextReadError::Db(e.to_string())),
}
}
}
// ---------------------------------------------------------------------------
+4
View File
@@ -20,6 +20,7 @@
use std::collections::VecDeque;
use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
use crate::simulation::generator::TerritorialStatus;
/// A cell is a river cell when its flow accumulation exceeds this threshold (D-208).
pub const RIVER_THRESHOLD: i32 = 200;
@@ -559,6 +560,9 @@ fn build_basins(labels: &[i32], w: usize, h: usize) -> Vec<DrainageBasin> {
basin_id: basin_id as u32,
boundary,
area_pct,
// Default; the cascade sets the real status from dominant_faction
// after Layer 1 (D-212, #956).
territorial_status: TerritorialStatus::FrontierUnclaimed,
});
}
+10 -2
View File
@@ -72,6 +72,10 @@ pub enum GenWorkItem {
/// dispatch time so the cascade stays DB-free. Fed to Layer-3 placement
/// (#955); empty if the body has no settlements (cascade stops at Layer 1).
cities: Vec<CityRecord>,
/// The body's system `dominant_faction` (D-237), pre-resolved at dispatch
/// time. Drives Layer-3 TerritorialStatus + spatial character (#956,
/// D-212/214/215). `None` → `FrontierUnclaimed`.
dominant_faction: Option<String>,
},
/// Generate a Phase 1 QuarterSkeleton for this city.
///
@@ -353,6 +357,7 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
sea_level,
body_seed,
cities,
dominant_faction,
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
Ok(hm) => {
// Layer 1 runs at the GRID_W×GRID_H working resolution (D-202):
@@ -363,12 +368,14 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
hm
};
// Run through Layer 3 (settlement placement, #955): the enqueuer
// pre-resolved this body's settlements onto `cities`. A body with
// no settlements yields empty placements at negligible cost.
// pre-resolved this body's settlements onto `cities` and its
// system faction onto `dominant_faction` (#956). A body with no
// settlements yields empty placements at negligible cost.
let snapshot = run_cascade_from_heightmap(
*body_seed,
working,
cities,
dominant_faction.as_deref(),
CascadeLayer::Settlement,
);
GenCompletion::BodyAnalyzed {
@@ -463,6 +470,7 @@ mod tests {
sea_level: 0.3,
body_seed: SeedChain::for_body(42, body_id),
cities: vec![],
dominant_faction: None,
}
}
+29 -15
View File
@@ -95,21 +95,34 @@ pub fn handle_atlas_request(
// Miss — resolve the source heightmap and enqueue background analysis.
match resolver.resolve(&req.body_id) {
Ok(heightmap_path) => {
// Pre-resolve this body's settlements so the Rayon work item stays
// DB-free (#955, D-225). A read failure is non-fatal: log and place
// no cities (Layer 1 still runs).
let cities = match city_reader {
Some(reader) => reader
.read_body_settlements(&req.body_id)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"settlement read failed; placing no cities"
);
Vec::new()
}),
None => Vec::new(),
// Pre-resolve this body's settlements + system faction so the Rayon
// work item stays DB-free (#955/#956, D-225). Read failures are
// non-fatal: log and fall back (no cities / no faction → frontier).
let (cities, dominant_faction) = match city_reader {
Some(reader) => {
let cities = reader
.read_body_settlements(&req.body_id)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"settlement read failed; placing no cities"
);
Vec::new()
});
let faction = reader
.read_body_dominant_faction(&req.body_id)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %req.body_id,
error = %e,
"dominant_faction read failed; defaulting to frontier"
);
None
});
(cities, faction)
}
None => (Vec::new(), None),
};
queue.submit(
GenWorkItem::AnalyzeBody {
@@ -118,6 +131,7 @@ pub fn handle_atlas_request(
sea_level: DEFAULT_SEA_LEVEL,
body_seed: SeedChain::for_body(world_seed, &req.body_id),
cities,
dominant_faction,
},
GenPriority::Immediate,
);
+1
View File
@@ -161,6 +161,7 @@ mod tests {
sea_level: 0.3,
body_seed: SeedChain::for_body(42, "PlanetX"),
cities: vec![],
dominant_faction: None,
},
GenPriority::Immediate,
);
+36 -3
View File
@@ -365,23 +365,56 @@ pub enum FoundingOrientation {
}
/// Territory control status for a province (drainage basin). Priority-ordered derivation.
/// Source: D-212
/// Source: D-212 (amended 2026-06-05 — see `AutonomistHeld` + the dominant_faction
/// mapping note on the variant docs and in `attractor_matching::territorial_status_from_faction`).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum TerritorialStatus {
/// Commission faction_influence ≥ 0.6 in this province.
/// Established central governance — the Concord Assembly or its aligned
/// institutions (e.g. the Veil Institute). (Original D-212: Commission
/// faction_influence ≥ 0.6.)
CommissionControlled,
/// Single corporation faction_influence ≥ 0.5.
/// Commercial control — Syndic combines. (Original D-212: single corporation
/// faction_influence ≥ 0.5.)
CorpTerritory,
/// Two or more factions each ≥ 0.3; no dominant faction.
ContestedZone,
/// No faction with influence ≥ 0.2.
FrontierUnclaimed,
/// Self-governing autonomist bloc that rejects central (Assembly) authority —
/// the Compact of Westphalia and compact-sympathetic systems. Locally
/// governed, but outside the Assembly's reach (D-212 amendment 2026-06-05).
AutonomistHeld,
/// Cultural corridor has indigenous autonomy flag.
IndigenousHeld,
/// Population density < 0.01 AND no faction ≥ 0.1.
Derelict,
}
/// Spatial arrangement pattern governing district adjacency and landmark
/// placement within a settlement (D-215). Derived from `PoliticalArchetype`
/// (+ transit_hub economic role → `HubAndSpoke`). #956 derives and stores which
/// pattern applies; the block-adjacency *enforcement* is the Quarter-skeleton
/// generator's job (#957).
/// Source: D-215
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArrangementPattern {
/// Central landmark surrounded by mixed-use rings, transit spokes radiating
/// outward (Commission, Academic).
RadialCore,
/// Restricted campus block in the interior, commercial ring, peripheral
/// worker residential (Corporate).
CampusGrid,
/// Districts string along a linear feature; no dominant center (Pioneer,
/// Industrial).
RibbonDevelopment,
/// Restricted/secured districts at the footprint edge, open interior core,
/// single controlled access per edge (Military).
FortifiedPerimeter,
/// Transit district at center, all others reachable via direct corridors
/// (transit_hub economic role, any archetype).
HubAndSpoke,
}
// ---------------------------------------------------------------------------
// Attractor types for settlement placement (D-195, D-209, D-211)
// ---------------------------------------------------------------------------