From af60d85d94b811aa0d2b56d3d5943b9c16c675a9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 5 Jun 2026 14:57:09 +0200 Subject: [PATCH] feat(simulation): Layer-3 settlement spatial-character enrichment (#956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- server/src/atlas/attractor_matching.rs | 332 ++++++++++++++++++++++-- server/src/atlas/body_world_state.rs | 10 +- server/src/atlas/cascade.rs | 87 ++++++- server/src/atlas/city_context_reader.rs | 30 +++ server/src/atlas/drainage.rs | 4 + server/src/atlas/gen_queue.rs | 12 +- server/src/atlas/layer_proxy.rs | 44 ++-- server/src/atlas/plugin.rs | 1 + server/src/simulation/generator.rs | 39 ++- 9 files changed, 514 insertions(+), 45 deletions(-) diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs index c283ecdc8..c8d62f78a 100644 --- a/server/src/atlas/attractor_matching.rs +++ b/server/src/atlas/attractor_matching.rs @@ -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 { 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 0–359. -/// Pass 0 as default when the terrain doesn't dictate a specific bearing. +/// `river_bearing` and `coastal_facing` are compass degrees 0–359 (terrain +/// bearings; not yet extracted by Layer 1 — pass 0 until D-209 exposes them). +/// `free_bearing` is a seed-derived 0–359 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 + ); } } diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index 30c0f2dbb..0badc23ac 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -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, } // --------------------------------------------------------------------------- diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index aff125657..2b0ccbee5 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -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 { // 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::>() }; 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(), diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index eddb02243..be7358e6d 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -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, 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>(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())), + } + } } // --------------------------------------------------------------------------- diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs index 2f7d47c20..2e07584ca 100644 --- a/server/src/atlas/drainage.rs +++ b/server/src/atlas/drainage.rs @@ -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 { 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, }); } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 81d113691..e09df392d 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -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, + /// 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, }, /// 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, } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 95529b3f9..3e808e618 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -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, ); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 2911b9478..08a44174c 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -161,6 +161,7 @@ mod tests { sea_level: 0.3, body_seed: SeedChain::for_body(42, "PlanetX"), cities: vec![], + dominant_faction: None, }, GenPriority::Immediate, ); diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 76f9b5b94..5a4481784 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -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) // ---------------------------------------------------------------------------