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:
@@ -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 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -161,6 +161,7 @@ mod tests {
|
||||
sea_level: 0.3,
|
||||
body_seed: SeedChain::for_body(42, "PlanetX"),
|
||||
cities: vec![],
|
||||
dominant_faction: None,
|
||||
},
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user