Files
settled-reach/server/src/atlas/skeleton_gen.rs
T
jpmschweitzerandClaude Fable 5 204359927a feat(simulation): BuildingExteriorTag grammar + Layer-5 material fill + ops-surface (T-988/T-959/T-1097)
T-988 — BuildingExteriorTag {wall_material, roof_form, facade_rhythm,
setback_tier, color:HsvColor, street_surface}. New trait_exterior.rs:
3-step era-free derivation (visual_bundle filter -> zone-bias weighted
pick -> density->setback), color seed-sampled within the register band.
Resolved at GenerateSkeleton plan time (T-994 precedent), frozen on
BuildingPropertyTag; FillChunk only reads it. Four append-only
integer-discriminant enums (WallMaterial/RoofForm/FacadeRhythm/
StreetSurface), unknown token -> axis Generic + warn. trait_catalog_reader
now parses visual_bundle + the two new tables (OnceLock-cached).
New SeedDomain::TraitExterior (per-axis sub-chains — no cross-field
correlation).

T-959 — FillChunk reads the frozen exterior tag: wall_material/roof_form
onto Wall/Roof shell voxels (FilledChunk.surface_material). Additive,
ShellVoxel untouched, all shell tests pass. (Interstitial-fill-from-setback
split to T-1098 — needs a FillChunk block-metadata + geometry design pass;
nothing consumes FillChunk until Phase 5.)

T-1097 — BlockSkeleton.interstitial_character (OpenSpace|OperationsSurface),
D-233 bulk-driven: bulk-industry blocks' non-roofed remainder tags as built
economic infrastructure, not generic open space.

cargo check --all-targets clean; 1666 lib tests pass; no golden impact
(harnesses top out at CascadeLayer::RoadGraph, unreachable by Layer 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:42:20 +02:00

2383 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Phase 1 quarter skeleton generator (D-194, D-196, D-211, D-213, D-214).
//!
//! Entry point: [`generate_quarter_skeleton`]. Consumes a [`CityGenerationContext`]
//! together with the city's raw population and economic role, and produces a
//! fully classified [`QuarterSkeleton`] with:
//!
//! - [`SettingType`] derived from the surrounding biome context.
//! - [`ComplexityTier`] derived from population tier × [`WorldTier`].
//! - [`DistrictLayoutMode`] derived from [`PoliticalArchetype`].
//! - 4×4 block grid with [`ZoningType`] assignments from the district-mix
//! algorithm (D-194).
//! - [`MultiBlockReservation`]s for parks (pop tier ≥ 2) and transit
//! terminals (transit_hub role or pop tier ≥ 3).
//!
//! **Phase 1 scope only** — no chunk-level tiles, no NPC placement, no tile
//! condition data. All stub fields (corridors, social_sites, etc.) are empty.
//!
//! **Determinism (D-010):** Seeded LCG via the district seed; no floating-point
//! in block assignment.
use crate::atlas::block_irregularity::block_irregularity;
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::atlas::tile_condition::{tile_condition, TileCondition};
use crate::atlas::trait_catalog_reader::ExteriorCatalog;
use crate::atlas::trait_exterior;
use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates};
use crate::seed::splitmix64;
use crate::seed::{SeedChain, SeedDomain};
use std::collections::BTreeMap;
use crate::simulation::generator::{
AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton,
BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext,
ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause,
FloorExtent, FloorHeightProfile, FoundingOrientation, InterstitialCharacter, MorphologyZone,
MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction,
ReservationId, SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType,
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Generate a Phase 1 [`QuarterSkeleton`] from a city's generation context.
///
/// # Parameters
/// - `context`: Build-time city context (archetype, world tier, orientation…).
/// - `population`: Raw population count from atlas_city_names.
/// - `economic_role`: Economic role string (one of the 10 canonical values).
/// - `quarter_id`: Content-addressable identifier for this district.
/// - `founding_age_years`: Years since founding — controls block irregularity.
/// - `chain`: This district's position in the deterministic seed tree (D-224 SeedChain).
pub fn generate_quarter_skeleton(
context: &CityGenerationContext,
population: i64,
economic_role: &str,
quarter_id: QuarterId,
founding_age_years: u32,
chain: SeedChain,
) -> QuarterSkeleton {
// ── 1. SettingType ────────────────────────────────────────────────────
// Pass through the surrounding_biome from context — it already encodes
// the planet/station/wilderness classification established at atlas time.
let setting = derive_setting(&context.surrounding_biome, economic_role);
// ── 2. ComplexityTier ─────────────────────────────────────────────────
let tier = population_tier(population);
let complexity = derive_complexity(&context.world_tier, tier, population);
// ── 3. DistrictLayoutMode ─────────────────────────────────────────────
let irregularity = block_irregularity(founding_age_years, &context.political_archetype);
let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, chain);
// ── 4. District mix → block grid ─────────────────────────────────────
// A single district occupies a 4×4 block grid = 16 blocks.
let total_blocks: u32 = 16;
let mix = compute_district_mix(
population,
economic_role,
&context.political_archetype,
total_blocks,
chain,
);
// ── 5. Multi-block reservations ───────────────────────────────────────
let reservations = derive_reservations(tier, economic_role, chain);
// Build the reservation lookup: block position → reservation id.
let mut block_reservation: [[Option<ReservationId>; 4]; 4] = [[None, None, None, None]; 4];
for (idx, res) in reservations.iter().enumerate() {
let rid = idx as u64 + 1; // 1-based stable id within this district
for &(row, col) in &res.blocks {
let r = row as usize;
let c = col as usize;
if r < 4 && c < 4 {
block_reservation[r][c] = Some(rid);
}
}
}
// ── Build 4×4 block grid ──────────────────────────────────────────────
// Flat district-mix list is already in deterministic order; assign
// row-major (row 0 col 0 → row 0 col 3 → row 1 col 0 …).
let primary_district_type = mix
.districts
.first()
.cloned()
.unwrap_or(DistrictType::MixedUse);
let mut blocks = build_block_grid(
&mix.districts,
&block_reservation,
&primary_district_type,
&context.dominant_bulk_class,
);
// ── Street network (D-234) ────────────────────────────────────────────
// Local lattice modulation per block (Grid vs Organic), then the quarter's
// access nodes + morphology-gated arterial corridors.
apply_layout_to_chunks(&mut blocks, &layout_mode);
let access_points = derive_access_points(&context.road_entry_directions, &reservations);
let corridors = derive_corridors(&access_points, &context.morphology_zone);
// ── Compute z_levels ──────────────────────────────────────────────────
// Phase 1: single-storey above ground for all non-reserved blocks.
// Reserved blocks carry their own z_levels count.
let z_levels: u8 = 1;
QuarterSkeleton {
quarter_id,
seed: chain.seed(),
quarter_type: district_type_from_mix(&primary_district_type),
context: String::new(), // stub — QuarterContext = String
world_tier: context.world_tier.clone(),
complexity,
setting,
layout_mode,
blocks,
reservations,
corridors,
z_levels,
social_sites: Vec::new(),
access_points,
boundaries: String::new(),
guarantee_audit: None,
}
}
// ---------------------------------------------------------------------------
// SettingType derivation
// ---------------------------------------------------------------------------
/// Derive SettingType from the city's surrounding biome context.
///
/// The surrounding_biome on CityGenerationContext already encodes the
/// planet/station classification. For city districts we map it to
/// Urban (the default for settled cities) or pass Station/Maritime/etc.
/// through directly.
fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> SettingType {
match surrounding_biome {
// Station bodies → always Station setting regardless of role.
SettingType::Station => SettingType::Station,
// Orbital platforms.
SettingType::Orbital => SettingType::Orbital,
// Maritime worlds — coastal city districts are Maritime.
SettingType::Maritime => SettingType::Maritime,
// Agricultural worlds → Agricultural districts.
SettingType::Agricultural => SettingType::Agricultural,
// For all other planet classes, city districts are Urban.
// Exception: extraction role on wilderness worlds → Specialized.
SettingType::Wilderness { biome } => {
if economic_role == "extraction" {
SettingType::Specialized {
function: format!("extraction-{biome}"),
}
} else {
SettingType::Urban
}
}
// Transit nodes get Transitional setting.
SettingType::Transitional => SettingType::Transitional,
// Water bodies → Water districts don't host cities; treat as Specialized.
SettingType::Water { .. } => SettingType::Specialized {
function: "waterfront".into(),
},
// Generic Specialized pass-through.
SettingType::Specialized { function } => SettingType::Specialized {
function: function.clone(),
},
// Default for Urban and any unknown variant: Urban.
SettingType::Urban => SettingType::Urban,
}
}
// ---------------------------------------------------------------------------
// ComplexityTier derivation
// ---------------------------------------------------------------------------
/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218).
///
/// Backwater is "NOT budget-capped" per D-218 — it joins Epicenter/Regional
/// at Full complexity rather than being capped at Moderate like Passage.
///
/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 |
/// |-----------------|---------------|----------------------|
/// | Epicenter | Full | Moderate |
/// | Regional | Full | Moderate |
/// | Backwater | Full | Moderate |
/// | Passage | Moderate | Minimal |
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
///
/// `pub(crate)` (T-994): also called from `atlas::plugin`'s body-level dispatch
/// aggregation to compute each settlement's D-232 phase-1 K contribution
/// (`trait_draw::complexity_k`) ahead of `generate_quarter_skeleton` itself.
pub(crate) fn derive_complexity(
world_tier: &WorldTier,
pop_tier: u8,
population: i64,
) -> ComplexityTier {
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
return ComplexityTier::Empty;
}
match world_tier {
WorldTier::Epicenter | WorldTier::Regional | WorldTier::Backwater => {
if pop_tier >= 1 {
ComplexityTier::Full
} else {
ComplexityTier::Moderate
}
}
WorldTier::Passage => {
if pop_tier >= 1 {
ComplexityTier::Moderate
} else {
ComplexityTier::Minimal
}
}
WorldTier::Waypoint => ComplexityTier::Minimal,
}
}
// ---------------------------------------------------------------------------
// DistrictLayoutMode derivation
// ---------------------------------------------------------------------------
/// Derive DistrictLayoutMode from PoliticalArchetype + block irregularity (D-213, D-214).
///
/// Commission / Military / Corporate / Academic → Grid (planned geometry).
/// Pioneer / Industrial → Organic (organic growth with per-block offsets).
fn derive_layout_mode(
archetype: &PoliticalArchetype,
irregularity: f32,
chain: SeedChain,
) -> DistrictLayoutMode {
match archetype {
PoliticalArchetype::Commission
| PoliticalArchetype::Military
| PoliticalArchetype::Corporate
| PoliticalArchetype::Academic => DistrictLayoutMode::Grid,
PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => {
// Organic: generate per-block offsets and rotations seeded from district seed.
let placements = organic_placements(irregularity, chain);
DistrictLayoutMode::Organic { placements }
}
}
}
/// Generate 4×4 organic block placements seeded deterministically (D-010).
///
/// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0)
/// scaled to the ±16 subtile maximum from `block_irregularity::max_offset_sim_tiles`.
fn organic_placements(irregularity: f32, chain: SeedChain) -> [[BlockPlacement; 4]; 4] {
let max_offset = (irregularity * 16.0) as i16;
// id = 0: one placement pass per district. Sibling separation comes from the
// distinct `chain` per district/quarter (caller-derived; #957), not the id.
let mut lcg = chain.derive(SeedDomain::Block, 0).atlas_rng();
// Build the 2D array using a flat closure to keep things readable.
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
offset: (0, 0),
rotation_steps: 0,
street_width_bps: 10_000,
});
for item in flat.iter_mut() {
let raw_x = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
let raw_y = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset;
let rot = (lcg.next_u32() % 4) as u8; // 0–3 (15° increments, max 45°)
// Street width 7500–20000 bps proportional to irregularity.
let width_range = 12_500u32; // 20000 - 7500
let width = 7_500u32 + (lcg.next_u32() % (width_range + 1));
*item = BlockPlacement {
offset: (raw_x, raw_y),
rotation_steps: rot,
street_width_bps: width as u16,
};
}
core::array::from_fn(|row| core::array::from_fn(|col| flat[row * 4 + col].clone()))
}
// ---------------------------------------------------------------------------
// Block grid construction
// ---------------------------------------------------------------------------
/// Map a DistrictType to its primary ZoningType (D-194).
fn zoning_for_district(dt: &DistrictType) -> ZoningType {
match dt {
DistrictType::LogisticsHub => ZoningType::Industrial,
DistrictType::Residential => ZoningType::Residential,
DistrictType::Commercial => ZoningType::Commercial,
DistrictType::Industrial => ZoningType::Industrial,
DistrictType::Administrative => ZoningType::Administrative,
DistrictType::Entertainment => ZoningType::Commercial,
DistrictType::MixedUse => ZoningType::Mixed,
DistrictType::Transit => ZoningType::Transit,
DistrictType::Specialized => ZoningType::Restricted,
}
}
/// Map the primary district type to the DistrictType field on QuarterSkeleton.
fn district_type_from_mix(primary: &DistrictType) -> DistrictType {
primary.clone()
}
/// Build the 4×4 BlockSkeleton grid from the district mix list and reservation map.
///
/// Blocks are assigned row-major (index = row * 4 + col).
/// Reserved blocks retain their zoning from the district mix but link to the reservation.
///
/// `dominant_bulk_class` sets every block's `interstitial_character` (D-233,
/// T-1097): the settlement-wide bulk-class signal already drives
/// `roofed_coverage_pct`/`subdivide_block_footprints` uniformly across every
/// block regardless of that block's own zoning (an existing simplification,
/// not a new one) — the ops-surface classification follows the identical
/// granularity for consistency.
fn build_block_grid(
districts: &[DistrictType],
block_reservation: &[[Option<ReservationId>; 4]; 4],
primary: &DistrictType,
dominant_bulk_class: &BulkClass,
) -> [[BlockSkeleton; 4]; 4] {
// Pad or truncate district list to exactly 16.
let district_iter: Vec<&DistrictType> = (0..16)
.map(|i| districts.get(i).unwrap_or(primary))
.collect();
let interstitial_character = interstitial_character_for(dominant_bulk_class);
core::array::from_fn(|row| {
core::array::from_fn(|col| {
let idx = row * 4 + col;
let dt = district_iter[idx];
let zoning = zoning_for_district(dt);
let reservation = block_reservation[row][col];
let density = density_for_zoning(&zoning);
BlockSkeleton {
position: (row as u8, col as u8),
zoning,
district_type: dt.clone(),
reservation,
// Density-based spacing; layout_mode offset/rotation applied in
// the street-network step (D-234).
chunk_layout: ChunkLayout {
spacing: local_street_spacing(density),
offset: (0, 0),
rotation_steps: 0,
},
hosted_sites: Vec::new(),
era_cause: None,
density_pct: density,
landmark: None,
interstitial_character,
}
})
})
}
/// D-233 ops-surface classification (T-1097): bulk-industry settlements read
/// their blocks' non-roofed remainder as built economic infrastructure, never
/// generic interstitial open space.
fn interstitial_character_for(bulk: &BulkClass) -> InterstitialCharacter {
match bulk {
BulkClass::BulkSolid | BulkClass::BulkLiquid => InterstitialCharacter::OperationsSurface,
BulkClass::PrecisionDense | BulkClass::Perishable | BulkClass::NonPhysical => {
InterstitialCharacter::OpenSpace
}
}
}
/// Default build density percentage for a zoning type.
fn density_for_zoning(zoning: &ZoningType) -> u8 {
match zoning {
ZoningType::Residential => 60,
ZoningType::Commercial => 80,
ZoningType::Industrial => 70,
ZoningType::Administrative => 75,
ZoningType::Transit => 50,
ZoningType::Recreational => 30,
ZoningType::Restricted => 85,
ZoningType::Mixed => 65,
}
}
// ---------------------------------------------------------------------------
// Multi-block reservations
// ---------------------------------------------------------------------------
/// Derive Phase 1 multi-block reservations for a city (D-211, D-194).
///
/// Reservation rules:
/// - Pop tier ≥ 2 → one 2×2 park reservation at the center-right (blocks (1,2),(1,3),(2,2),(2,3)).
/// - Transit_hub role OR pop tier ≥ 3 → one 1×2 transit terminal at row 0 cols 0–1.
///
/// Phase 1 produces skeleton-only reservations — floor_zones and vertical_corridors
/// are deferred to Phase 2.
fn derive_reservations(
pop_tier: u8,
economic_role: &str,
_chain: SeedChain,
) -> Vec<MultiBlockReservation> {
let mut out = Vec::new();
// Park: large cities need open space.
if pop_tier >= 2 {
out.push(MultiBlockReservation {
blocks: vec![(1, 2), (1, 3), (2, 2), (2, 3)],
template_tag: "park-central".into(),
function: ReservationFunction::Park,
z_levels: 1,
base_z: 0,
floor_zones: Vec::new(),
z_band_count: 1,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
});
}
// Transit terminal: transit-hub economies and major cities.
if economic_role == "transit_hub" || pop_tier >= 3 {
out.push(MultiBlockReservation {
blocks: vec![(0, 0), (0, 1)],
template_tag: "transit-terminal".into(),
function: ReservationFunction::Terminal,
z_levels: 2,
base_z: -1, // one level of underground rail
floor_zones: Vec::new(),
z_band_count: 2,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
});
}
out
}
// ---------------------------------------------------------------------------
// Building-property-tag derivation (D-229, #957)
//
// Pure functions producing the per-footprint tag fields. The footprint
// subdivision + assembly into QuarterWorldState.block_tags is the next step;
// these are the field-level derivations the D-229 amendment (2026-06-05) pins.
// ---------------------------------------------------------------------------
/// D-217 condition bands as integer basis points (prosperity_baseline_bps,
/// 10_000 = 1.0): Intact > 6300, Worn 4300–6300, Cracked 2300–4300, Broken < 2300.
const PROSPERITY_BROKEN_BPS: u32 = 2300;
/// Select the `ZoneTypeId` for one footprint (D-229 amendment, #957).
///
/// Builds a candidate slice from the `(ZoningType × economic_role)` base table,
/// applies the `setting` tweaker (planetary variants only — station/orbital ids
/// belong to a separate cascade, Q-109), then deterministically seed-picks one.
/// `seed` should be derived per footprint by the caller so footprints in a block
/// differ. Every branch yields a non-empty slice.
pub fn zone_type_for(
zoning: &ZoningType,
economic_role: &str,
setting: &SettingType,
seed: SeedChain,
) -> ZoneTypeId {
use ZoningType::*;
// Base candidate slice: ZoningType default, refined by economic_role.
let mut slice: Vec<&'static str> = match (zoning, economic_role) {
(Commercial, "financial") => vec!["diplomatic_elite", "commercial_market"],
(Commercial, "transit_hub") => vec!["commercial_transit", "commercial_market"],
(Commercial, "service_mixed") => {
vec![
"commercial_market",
"entertainment_hospitality",
"entertainment_venue",
]
}
(Commercial, _) => vec!["commercial_market", "entertainment_hospitality"],
(Residential, "agricultural") => vec!["rural_agricultural", "rural_pastoral"],
(Residential, "extraction") => vec!["residential_dispersed", "residential_surface"],
(Residential, _) => vec!["residential_surface"],
(Industrial, "manufacturing") => vec!["industrial_manufacturing", "industrial_processing"],
(Industrial, "extraction") => vec!["extraction_surface", "industrial_processing"],
(Industrial, "agricultural") => vec!["industrial_processing"],
(Industrial, _) => vec!["industrial_manufacturing", "industrial_freight"],
(Administrative, "institutional") => {
vec![
"administrative_civil",
"administrative_judicial",
"diplomatic_elite",
]
}
(Administrative, "research") => vec!["research_station", "administrative_civil"],
(Administrative, "service_mixed") => vec!["administrative_civil", "medical_facility"],
(Administrative, _) => vec!["administrative_civil"],
(Transit, "transit_hub") => vec!["commercial_transit", "port_surface"],
(Transit, "manufacturing") | (Transit, "extraction") => {
vec!["industrial_freight", "port_surface"]
}
(Transit, _) => vec!["port_surface"],
(Recreational, "research") | (Recreational, "institutional") => {
vec!["archaeological_site", "entertainment_venue"]
}
(Recreational, _) => vec!["entertainment_venue", "entertainment_hospitality"],
(Restricted, "military") => {
vec![
"military_garrison",
"security_checkpoint",
"detention_facility",
]
}
(Restricted, "research") => vec!["research_station", "security_checkpoint"],
(Restricted, "institutional") => vec!["detention_facility", "security_checkpoint"],
(Restricted, _) => vec!["security_checkpoint"],
(Mixed, _) => vec![
"residential_surface",
"commercial_market",
"administrative_civil",
],
};
apply_setting_tweaker(&mut slice, zoning, setting);
// Deterministic pick (D-010): slice is never empty.
let idx = (splitmix64(seed.seed()) % slice.len() as u64) as usize;
ZoneTypeId::new(slice[idx])
}
/// Apply the `setting` tweaker to a base candidate slice (D-229 amendment).
/// Tweaks planetary variants only; never introduces station/orbital ids.
fn apply_setting_tweaker(
slice: &mut Vec<&'static str>,
zoning: &ZoningType,
setting: &SettingType,
) {
match setting {
SettingType::Maritime | SettingType::Water { .. } => {
for id in slice.iter_mut() {
*id = match *id {
"port_surface" => "port_maritime",
"extraction_surface" => "extraction_platform",
"rural_agricultural" | "rural_pastoral" => "rural_aquaculture",
other => other,
};
}
if matches!(zoning, ZoningType::Transit) {
slice.push("port_fishing");
}
}
SettingType::Agricultural => {
if matches!(zoning, ZoningType::Residential | ZoningType::Mixed) {
slice.insert(0, "rural_pastoral");
slice.insert(0, "rural_agricultural");
}
}
SettingType::Wilderness { .. } => {
if matches!(zoning, ZoningType::Residential | ZoningType::Mixed) {
slice.insert(0, "residential_dispersed");
slice.insert(0, "wilderness_frontier");
}
}
// Urban / Station / Orbital / Transitional / Specialized: base unchanged.
// (Station/Orbital bodies never reach this planetary cascade — Q-109.)
_ => {}
}
}
/// Derive `BuildingEntryClass` from zone × layout_mode × prosperity (D-229).
///
/// U-curve degrade: a normally-accessible zone at Broken prosperity seals to
/// `BreachOnly`. The Commission(Grid)/Organic fork softens credentialed zones —
/// Organic settlements use informal/social access (Public) where Grid settlements
/// require a formal credential (Restricted). The credential *type* (formal vs
/// social) is a door-level concern (D-231, #979).
pub fn building_entry_class(
zoning: &ZoningType,
layout_mode: &DistrictLayoutMode,
prosperity_bps: u32,
) -> BuildingEntryClass {
use BuildingEntryClass as E;
let derelict = prosperity_bps < PROSPERITY_BROKEN_BPS;
let organic = matches!(layout_mode, DistrictLayoutMode::Organic { .. });
match zoning {
ZoningType::Commercial => {
if derelict {
E::BreachOnly
} else {
E::Commercial
}
}
ZoningType::Recreational | ZoningType::Transit => E::Public,
ZoningType::Administrative | ZoningType::Residential => {
if organic {
E::Public
} else {
E::Restricted
}
}
ZoningType::Industrial => E::Restricted,
ZoningType::Restricted => {
if derelict {
E::BreachOnly
} else {
E::Restricted
}
}
ZoningType::Mixed => {
if derelict {
E::Restricted
} else {
E::Public
}
}
}
}
/// Derive `(ConstructionEra, EraCause)` from founding age + prosperity + seed
/// (D-229). Founding age anchors the distribution; the seed scatters outliers.
/// A long-settled, Broken-prosperity footprint reads as `Derelict`.
pub fn construction_era(
founding_age_years: u32,
prosperity_bps: u32,
seed: SeedChain,
) -> (ConstructionEra, EraCause) {
use ConstructionEra::*;
if prosperity_bps < PROSPERITY_BROKEN_BPS && founding_age_years > 200 {
// Degraded beyond economic viability → its decay is the era's cause.
return (Derelict, EraCause::EconomicDisruption);
}
let roll = splitmix64(seed.seed()) % 100;
let era = match founding_age_years {
0..=80 => {
if roll < 70 {
Modern
} else {
Established
}
}
81..=300 => {
if roll < 40 {
Established
} else if roll < 70 {
Modern
} else {
Founding
}
}
_ => {
if roll < 50 {
Founding
} else if roll < 80 {
Established
} else {
Modern
}
}
};
(era, EraCause::Original)
}
/// Derive `FloorExtent` from build density + seed (D-220 density classes).
///
/// `density_pct` (the block's build density) proxies the D-220 density class →
/// floor-count range; the seed jitters within the range, and denser blocks gain
/// basement levels (negative `base_floor`, D-110). `Uniform(3)` ≈ 3 m/floor.
pub fn floor_extent(density_pct: u8, seed: SeedChain) -> FloorExtent {
let (min_f, max_f, base_floor) = match density_pct {
0..=20 => (1u8, 1u8, 0i8), // Frontier — 1 floor, no basement
21..=45 => (1, 2, 0), // Settled — 1–2 floors
46..=65 => (2, 3, -1), // Established — basement common
66..=85 => (3, 6, -1), // Dense — 1 basement
_ => (6, 12, -2), // Compressed — 2 basement levels
};
let span = (max_f - min_f + 1) as u64;
let floor_count = min_f + (splitmix64(seed.seed()) % span) as u8;
FloorExtent {
base_floor,
floor_count,
heights: FloorHeightProfile::Uniform(3),
}
}
/// Frozen-amber `initial_condition` from prosperity + era cause (D-197/D-217).
/// Thin wrapper over the canonical D-217 derivation; the rolling overlay (D-198)
/// paints over this without mutating the tag.
pub fn initial_condition(prosperity_bps: u32, era_cause: &EraCause) -> TileCondition {
tile_condition(prosperity_bps as f32 / 10_000.0, Some(era_cause))
}
// ---------------------------------------------------------------------------
// Footprint subdivision + block-tag assembly (D-220, D-229, D-233, D-234, #957)
//
// Subdivides each 128×128-tile block into variable axis-aligned building
// footprints (D-229 fast-path), then tags each via the derivation helpers above.
// Coverage = D-233 BulkClass roofed fraction; lot size + setback scale with
// D-220 density. The morphology-gated street network + per-edge waterfront rule
// (D-234) need Layer-1 terrain water-adjacency threaded to the skeleton and land
// with the street layer; `morphology` is wired here so the seam is ready.
// ---------------------------------------------------------------------------
/// Edge length of one block in tiles (D-220: Block = 128×128 tiles).
const BLOCK_TILES: u8 = 128;
/// Perimeter street margin left unbuilt around each block (tiles).
const BLOCK_MARGIN: u8 = 2;
/// Recursion bound on the BSP subdivision.
const BSP_MAX_DEPTH: u8 = 7;
/// D-233 roofed-coverage fraction (percent) for a block's dominant `BulkClass`,
/// interpolated within the class's authored range by build density.
fn roofed_coverage_pct(density_pct: u8, bulk: &BulkClass) -> u8 {
let (lo, hi) = match bulk {
BulkClass::BulkSolid => (25u8, 40u8),
BulkClass::BulkLiquid => (20, 35),
BulkClass::Perishable => (50, 65),
BulkClass::PrecisionDense => (75, 90),
BulkClass::NonPhysical => (85, 95),
};
lo + ((hi - lo) as u32 * density_pct as u32 / 100) as u8
}
/// Split a rect into two along the given axis at `cut` (tiles from the origin).
fn split_rect(r: TileRect, along_x: bool, cut: u8) -> (TileRect, TileRect) {
let (ox, oy) = r.origin;
let (w, h) = r.size;
if along_x {
(
TileRect::new(ox, oy, cut, h),
TileRect::new(ox + cut, oy, w - cut, h),
)
} else {
(
TileRect::new(ox, oy, w, cut),
TileRect::new(ox, oy + cut, w, h - cut),
)
}
}
/// Recursive binary space partition of `rect` into lot-sized leaves. Splits the
/// over-long axis at a seed-chosen 40–60% ratio until no dimension exceeds
/// `max_lot` (or a half would fall below `min_lot`, or depth is exhausted).
fn bsp(rect: TileRect, depth: u8, min_lot: u8, max_lot: u8, seed: u64, out: &mut Vec<TileRect>) {
let (w, h) = rect.size;
let can_split_x = w > max_lot && w >= min_lot * 2;
let can_split_y = h > max_lot && h >= min_lot * 2;
if depth >= BSP_MAX_DEPTH || (!can_split_x && !can_split_y) {
out.push(rect);
return;
}
// Split the longer eligible axis.
let along_x = if can_split_x && can_split_y {
w >= h
} else {
can_split_x
};
let len = if along_x { w } else { h };
let ratio = 40 + (seed % 21) as u32; // 40..=60
let cut = ((len as u32 * ratio / 100) as u8).clamp(min_lot, len - min_lot);
let (a, b) = split_rect(rect, along_x, cut);
let child = splitmix64(seed);
bsp(a, depth + 1, min_lot, max_lot, child, out);
bsp(b, depth + 1, min_lot, max_lot, splitmix64(child), out);
}
/// A quarter (or block) edge — used for the D-234 waterfront rule.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Edge {
North,
East,
South,
West,
}
/// The quarter edge facing water, from the settlement's founding orientation
/// (D-234b). Only `Coastal` orientations have a water-facing edge; the 8-octant
/// facing snaps to the nearest cardinal edge.
fn coastal_edge(orientation: &FoundingOrientation) -> Option<Edge> {
if let FoundingOrientation::Coastal { facing_degrees } = orientation {
Some(match facing_degrees % 360 {
d if !(45..315).contains(&d) => Edge::North,
d if d < 135 => Edge::East,
d if d < 225 => Edge::South,
_ => Edge::West,
})
} else {
None
}
}
/// Whether block `(row, col)` sits on the quarter's `edge` (4×4 grid).
fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool {
match edge {
Edge::North => row == 0,
Edge::South => row == 3,
Edge::West => col == 0,
Edge::East => col == 3,
}
}
/// Subdivide one block into building footprints (D-220/D-229/D-233/D-234).
///
/// Lot size + setback scale with `density_pct` (Frontier → few big lots, wide
/// gaps; Compressed → many small lots, shared walls). The D-233 roofed coverage
/// (from `bulk`) decides what fraction of lots are buildings vs interstitial
/// open space (yards/parks/lots). Footprints are axis-aligned `TileRect`s in
/// block tile-space. `waterfront` (D-234b): on the water-facing edge the street
/// margin drops to 0 — buildings present flush to the quay (dock-orthogonal).
fn subdivide_block_footprints(
density_pct: u8,
bulk: &BulkClass,
// Reserved for morphology-specific lot shaping (narrow canyon lots,
// pier-oriented delta lots) once that detail is designed; the waterfront
// rule already consumes the water-facing geometry via `waterfront`.
_morphology: &MorphologyZone,
waterfront: Option<Edge>,
seed: SeedChain,
) -> Vec<TileRect> {
let (min_lot, max_lot, setback) = match density_pct {
0..=20 => (24u8, 48u8, 3u8), // Frontier
21..=45 => (16, 32, 2), // Settled
46..=65 => (12, 24, 2), // Established
66..=85 => (8, 16, 1), // Dense
_ => (6, 12, 1), // Compressed — shared walls
};
let coverage = roofed_coverage_pct(density_pct, bulk);
// Perimeter street margin — dropped to 0 on a water-facing edge (D-234b).
let (mut top, mut bottom, mut left, mut right) =
(BLOCK_MARGIN, BLOCK_MARGIN, BLOCK_MARGIN, BLOCK_MARGIN);
match waterfront {
Some(Edge::North) => top = 0,
Some(Edge::South) => bottom = 0,
Some(Edge::West) => left = 0,
Some(Edge::East) => right = 0,
None => {}
}
let inner = TileRect::new(
left,
top,
BLOCK_TILES - left - right,
BLOCK_TILES - top - bottom,
);
let mut leaves = Vec::new();
bsp(inner, 0, min_lot, max_lot, seed.seed(), &mut leaves);
// Keep the coverage fraction as buildings; shrink each by the setback to
// leave street frontage. The rest is interstitial open space.
let mut out = Vec::new();
for (i, lot) in leaves.iter().enumerate() {
let roll = (splitmix64(seed.seed() ^ (i as u64 + 1)) % 100) as u8;
if roll >= coverage {
continue; // interstitial gap
}
let w = lot.size.0.saturating_sub(setback);
let h = lot.size.1.saturating_sub(setback);
if w >= 1 && h >= 1 {
out.push(TileRect::new(lot.origin.0, lot.origin.1, w, h));
}
}
out
}
/// Tag every building footprint in one block (D-229). Reserved blocks (parks,
/// terminals, plazas) are open/special-use and get no standard building fill.
///
/// `exterior_catalog` is the D-235 exterior-grammar content (T-988),
/// pre-resolved at L3→L4 dispatch time alongside the rest of the D-232 catalog
/// data — this function never touches `systems.db` (T-987/D-230 purity).
fn assign_block_tags(
block: &BlockSkeleton,
skeleton: &QuarterSkeleton,
context: &CityGenerationContext,
economic_role: &str,
founding_age_years: u32,
waterfront: Option<Edge>,
block_chain: SeedChain,
exterior_catalog: &ExteriorCatalog,
) -> Vec<BuildingPropertyTag> {
if block.reservation.is_some() {
return Vec::new();
}
let prosperity = context.prosperity_baseline_bps;
let setting = &context.surrounding_biome;
// D-232 phase 2 (T-994): the dominant template for this block's DistrictType
// was already pre-resolved at L3→L4 dispatch time (`context.district_dominant_by_type`
// — see `atlas::trait_draw::pick_district_dominant_by_type`), keyed by the
// settlement's D-243 District cell so every block of this DistrictType across
// the whole quarter (and any sibling quarter in the same District) reads the
// identical template. Falls back to index 0 if the type has no entry (should
// not happen — the map always has all 9 DistrictType keys). Usually
// InVocabulary; already a Swerve when the sparsity escape hatch fired (T-1003).
let district_dominant = context
.district_dominant_by_type
.get(&block.district_type)
.cloned()
.unwrap_or(ArchitectureFlavorRef::InVocabulary(0));
let swerve_rates = SwerveRates {
foreign_bps: context.swerve_rates_bps.0,
heritage_bps: context.swerve_rates_bps.1,
};
let footprints = subdivide_block_footprints(
block.density_pct,
&context.dominant_bulk_class,
&context.morphology_zone,
waterfront,
block_chain,
);
footprints
.into_iter()
.enumerate()
.map(|(i, footprint)| {
// Distinct per-field sub-chains so zone / era / floors / flavor draw
// independent entropy — sharing one `fp_chain` made every field a
// modulo of the same `splitmix64(seed)` value and correlated them
// (e.g. construction era tracking floor height across the city).
let fp_chain = block_chain.derive(SeedDomain::Block, i as u64 + 1);
let zone_type_id = zone_type_for(
&block.zoning,
economic_role,
setting,
fp_chain.derive(SeedDomain::Block, 0),
);
let entry_class =
building_entry_class(&block.zoning, &skeleton.layout_mode, prosperity);
let (era, era_cause) = construction_era(
founding_age_years,
prosperity,
fp_chain.derive(SeedDomain::Block, 1),
);
let initial = initial_condition(prosperity, &era_cause);
let extent = floor_extent(block.density_pct, fp_chain.derive(SeedDomain::Block, 2));
// T-1003 (D-232 deviation system): the rare per-building wildcard —
// its own seed domain off the footprint chain, so the roll can never
// correlate with the zone/era/extent draws above. Overwhelmingly
// None → the district-dominant template applies as usual.
let mut swerve_rng = fp_chain.derive(SeedDomain::TraitSwerve, 0).atlas_rng();
let flavor_ref = match roll_building_swerve(
swerve_rates,
&context.swerve_foreign_pool,
&context.swerve_heritage_pool,
&mut swerve_rng,
) {
Some(tag) => ArchitectureFlavorRef::Swerve(tag),
None => district_dominant.clone(),
};
// D-235 (T-988): resolve the exterior grammar from this SAME
// finalized flavor_ref, at the same plan-time point — "Swerve(tag)
// refs resolve against the full catalog at the same point" (the
// T-994 phase-timing precedent this ticket follows). InVocabulary
// indexes into context.trait_selection; an out-of-bounds index or
// a Swerve tag absent from the catalog (a content gap, or a test
// fixture with no catalog wired) resolves to `None` — the
// derivation degrades to Generic/neutral rather than panicking.
let resolved_tag: Option<&str> = match &flavor_ref {
ArchitectureFlavorRef::InVocabulary(idx) => context
.trait_selection
.get(*idx as usize)
.map(String::as_str),
ArchitectureFlavorRef::Swerve(tag) => Some(tag.as_str()),
};
let template = resolved_tag
.and_then(|tag| exterior_catalog.templates.iter().find(|t| t.tag == tag));
let zone_bias = resolved_tag.and_then(|tag| {
exterior_catalog
.zone_bias
.get(&(tag.to_string(), zone_type_id.as_str().to_string()))
});
let exterior = trait_exterior::derive_building_exterior(
template,
zone_bias,
&exterior_catalog.color_bands,
block.density_pct,
fp_chain,
);
BuildingPropertyTag {
zone_type_id,
footprint,
extent,
entry_class,
flavor_ref,
era,
era_cause,
initial_condition: initial,
exterior,
doors: Vec::new(), // D-231 door derivation is #979
}
})
.collect()
}
/// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag
/// every non-reserved block's footprints. Keyed by 4×4 block grid position.
///
/// `exterior_catalog` is the D-235 exterior-grammar content (T-988) —
/// threaded straight through to [`assign_block_tags`]; see its doc comment.
pub fn assign_all_block_tags(
skeleton: &QuarterSkeleton,
context: &CityGenerationContext,
economic_role: &str,
founding_age_years: u32,
chain: SeedChain,
exterior_catalog: &ExteriorCatalog,
) -> BTreeMap<(u8, u8), Vec<BuildingPropertyTag>> {
// Water-facing quarter edge from the settlement's coastal founding
// orientation (D-234b); blocks on it present flush to the quay.
let water_edge = coastal_edge(&context.founding_orientation);
let mut map = BTreeMap::new();
for row in 0..4u8 {
for col in 0..4u8 {
let block = &skeleton.blocks[row as usize][col as usize];
let block_chain = chain.derive(SeedDomain::Block, (row * 4 + col) as u64);
let waterfront = water_edge.filter(|&e| block_on_quarter_edge(row, col, e));
let tags = assign_block_tags(
block,
skeleton,
context,
economic_role,
founding_age_years,
waterfront,
block_chain,
exterior_catalog,
);
if !tags.is_empty() {
map.insert((row, col), tags);
}
}
}
map
}
// ---------------------------------------------------------------------------
// Street network — access points, arterial corridors, local lattice (D-234, #957)
//
// Arterials (`corridors`) = a least-cost graph over the quarter's access nodes,
// ±45°-snapped (D-234 refined; the node/edge graph the D-097 audit reads). Local
// streets (`chunk_layout`) = a ±45° grid lattice per block, modulated by the
// D-096 layout mode. Morphology gates the trunk topology (D-234a). The per-edge
// waterfront pier rule (D-234b) needs Layer-1 terrain water-adjacency threaded to
// the skeleton and is the remaining piece.
// ---------------------------------------------------------------------------
/// Quarter edge length in tiles (4 blocks × 128).
const QUARTER_TILES: u16 = 512;
/// Local-street lattice spacing (tiles) from build density — denser → tighter.
fn local_street_spacing(density_pct: u8) -> u8 {
match density_pct {
0..=30 => 32,
31..=60 => 24,
61..=85 => 16,
_ => 12,
}
}
/// Perimeter point for a compass octant (0=N…7=NW) on the 512-tile quarter.
fn octant_perimeter(octant: u8) -> (u16, u16) {
let max = QUARTER_TILES - 1;
let mid = QUARTER_TILES / 2;
match octant % 8 {
0 => (mid, 0),
1 => (max, 0),
2 => (max, mid),
3 => (max, max),
4 => (mid, max),
5 => (0, max),
6 => (0, mid),
_ => (0, 0),
}
}
/// Quarter access nodes (D-234): one per road-entry octant + one gate per
/// reservation. Always non-empty (a central junction if nothing else), so the
/// arterial graph is well-defined.
fn derive_access_points(
road_entry_directions: &[u8],
reservations: &[MultiBlockReservation],
) -> Vec<AccessPoint> {
let mut pts = Vec::new();
for &octant in road_entry_directions {
pts.push(AccessPoint {
position: octant_perimeter(octant),
kind: AccessKind::QuarterEdge { octant },
});
}
for (i, res) in reservations.iter().enumerate() {
if let Some(&(r, c)) = res.blocks.first() {
pts.push(AccessPoint {
position: (c as u16 * 128 + 64, r as u16 * 128 + 64),
kind: AccessKind::ReservationGate {
reservation: i as ReservationId + 1,
},
});
}
}
if pts.is_empty() {
pts.push(AccessPoint {
position: (QUARTER_TILES / 2, QUARTER_TILES / 2),
kind: AccessKind::BlockJunction,
});
}
pts
}
/// Trunk-road topology permitted by the region morphology (D-234a).
enum Topology {
/// Linear spine along the terrain axis (fjord/canyon/mountain-pass).
Ribbon,
/// Star from a central hub (delta/island/enclosed water).
HubSpoke,
/// Least-cost mesh — any pattern (plains/meander/coastal-lowland).
Mesh,
}
fn street_topology(m: &MorphologyZone) -> Topology {
// D-234 morphology-gated trunk topology.
// Updated for D-239 §6 frozen 17-zone vocabulary (T-1027).
match m {
// Ribbon: linear/constrained terrain — follow the single axis.
MorphologyZone::Fjord
| MorphologyZone::CliffCoast
| MorphologyZone::MountainPass
| MorphologyZone::Alpine
| MorphologyZone::ValleyFloor => Topology::Ribbon,
// HubSpoke: water-enclosed or island-like contexts — radiate from a centre.
MorphologyZone::Delta
| MorphologyZone::Estuarine
| MorphologyZone::OpenOcean
| MorphologyZone::Lake
| MorphologyZone::DuneStrand
| MorphologyZone::TidalFlat => Topology::HubSpoke,
// Mesh: flat/open terrain — any pattern.
MorphologyZone::AlluvialPlain
| MorphologyZone::MeanderReach
| MorphologyZone::RiverBank
| MorphologyZone::BraidedPlain
| MorphologyZone::Wetland
| MorphologyZone::Volcanic => Topology::Mesh,
}
}
/// Chebyshev distance (diagonal-friendly, matches the ±45° path metric).
fn node_dist(a: (u16, u16), b: (u16, u16)) -> i32 {
let dx = (a.0 as i32 - b.0 as i32).abs();
let dy = (a.1 as i32 - b.1 as i32).abs();
dx.max(dy)
}
/// A ±45°-snapped polyline from `a` to `b`: a 45° diagonal run, then an
/// axis-aligned run (D-096 ±45° cap — only 0°/45°/90° segments).
fn snap45_path(a: (u16, u16), b: (u16, u16)) -> Vec<(u16, u16)> {
let (ax, ay) = (a.0 as i32, a.1 as i32);
let (bx, by) = (b.0 as i32, b.1 as i32);
let diag = (bx - ax).abs().min((by - ay).abs());
let corner = (
(ax + (bx - ax).signum() * diag) as u16,
(ay + (by - ay).signum() * diag) as u16,
);
if corner == a || corner == b {
vec![a, b]
} else {
vec![a, corner, b]
}
}
fn spine(from: usize, to: usize, aps: &[AccessPoint]) -> CorridorSpine {
CorridorSpine {
from: from as u16,
to: to as u16,
path: snap45_path(aps[from].position, aps[to].position),
}
}
/// Prim's minimum-spanning tree over node positions (O(n²); n is small).
fn prim_mst(aps: &[AccessPoint]) -> Vec<(usize, usize)> {
let n = aps.len();
let mut in_tree = vec![false; n];
let mut edges = Vec::new();
in_tree[0] = true;
for _ in 1..n {
let mut best: Option<(usize, usize, i32)> = None;
for i in 0..n {
if !in_tree[i] {
continue;
}
for j in 0..n {
if in_tree[j] {
continue;
}
let d = node_dist(aps[i].position, aps[j].position);
if best.is_none_or(|(_, _, bd)| d < bd) {
best = Some((i, j, d));
}
}
}
if let Some((i, j, _)) = best {
in_tree[j] = true;
edges.push((i, j));
}
}
edges
}
/// Node closest to the quarter centre — the hub for hub-and-spoke topology.
fn central_node(aps: &[AccessPoint]) -> usize {
let c = (QUARTER_TILES / 2, QUARTER_TILES / 2);
(0..aps.len())
.min_by_key(|&i| node_dist(aps[i].position, c))
.unwrap_or(0)
}
/// Derive the quarter's arterial corridors over its access points, with topology
/// gated by morphology (D-234a).
fn derive_corridors(
access_points: &[AccessPoint],
morphology: &MorphologyZone,
) -> Vec<CorridorSpine> {
let n = access_points.len();
if n < 2 {
return Vec::new();
}
match street_topology(morphology) {
Topology::HubSpoke => {
let hub = central_node(access_points);
(0..n)
.filter(|&i| i != hub)
.map(|i| spine(hub, i, access_points))
.collect()
}
Topology::Ribbon => {
// Connect consecutive nodes ordered along the dominant terrain axis.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| {
access_points[i].position.0 as i32 + access_points[i].position.1 as i32
});
order
.windows(2)
.map(|w| spine(w[0], w[1], access_points))
.collect()
}
Topology::Mesh => prim_mst(access_points)
.into_iter()
.map(|(i, j)| spine(i, j, access_points))
.collect(),
}
}
/// Apply the D-096 layout mode to each block's local lattice (D-234): organic
/// settlements jitter the lattice offset + rotation from their `BlockPlacement`;
/// grid settlements keep the axis-aligned default.
fn apply_layout_to_chunks(blocks: &mut [[BlockSkeleton; 4]; 4], layout: &DistrictLayoutMode) {
if let DistrictLayoutMode::Organic { placements } = layout {
for r in 0..4 {
for c in 0..4 {
let p = &placements[r][c];
blocks[r][c].chunk_layout.offset = (
p.offset.0.unsigned_abs().min(255) as u8,
p.offset.1.unsigned_abs().min(255) as u8,
);
blocks[r][c].chunk_layout.rotation_steps = p.rotation_steps;
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::generator::{
BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype,
ProductionUbiquity, SettingType, WorldTier,
};
fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext {
CityGenerationContext {
city_id: 1,
political_archetype: archetype,
prosperity_baseline_bps: 7_000,
surrounding_biome: SettingType::Urban,
road_entry_directions: vec![0, 4],
footprint_radius_km: 10.0,
founding_orientation: FoundingOrientation::Cardinal,
world_tier,
// D-229/D-232/D-233 additions — sensible stubs for existing tests.
morphology_zone: MorphologyZone::AlluvialPlain,
trait_selection: Vec::new(),
dominant_bulk_class: BulkClass::NonPhysical,
dominant_production_ubiquity: ProductionUbiquity::Common,
// T-994 additions — sensible stubs for existing tests.
geographic_sector: None,
body_district_type_mix: Vec::new(),
settlement_district_pos: (0, 0),
district_dominant_by_type: BTreeMap::new(),
// T-1003 additions — zero rates / empty pools: no swerve in
// existing tests.
swerve_rates_bps: (0, 0),
swerve_foreign_pool: Vec::new(),
swerve_heritage_pool: Vec::new(),
}
}
#[test]
fn setting_station_passthrough() {
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
ctx.surrounding_biome = SettingType::Station;
let sk =
generate_quarter_skeleton(&ctx, 500_000, "institutional", 1, 200, SeedChain::root(42));
assert!(matches!(sk.setting, SettingType::Station));
}
#[test]
fn setting_urban_for_city_on_planet() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
assert!(matches!(sk.setting, SettingType::Urban));
}
#[test]
fn complexity_epicenter_high_pop_is_full() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
// 10M pop → pop_tier = 1 → Full on Epicenter
let sk =
generate_quarter_skeleton(&ctx, 10_000_000, "financial", 1, 200, SeedChain::root(42));
assert_eq!(sk.complexity, ComplexityTier::Full);
}
#[test]
fn complexity_waypoint_tiny_pop_is_empty() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint);
let sk = generate_quarter_skeleton(&ctx, 1_000, "residential", 1, 50, SeedChain::root(42));
assert_eq!(sk.complexity, ComplexityTier::Empty);
}
#[test]
fn complexity_backwater_low_pop_is_moderate() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
// 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped)
let sk = generate_quarter_skeleton(&ctx, 50_000, "residential", 1, 50, SeedChain::root(42));
assert_eq!(sk.complexity, ComplexityTier::Moderate);
}
#[test]
fn complexity_backwater_high_pop_is_full() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
// 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped)
let sk =
generate_quarter_skeleton(&ctx, 10_000_000, "residential", 1, 50, SeedChain::root(42));
assert_eq!(sk.complexity, ComplexityTier::Full);
}
#[test]
fn complexity_passage_low_pop_is_minimal() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage);
// 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped)
let sk =
generate_quarter_skeleton(&ctx, 50_000, "transit_hub", 1, 100, SeedChain::root(42));
assert_eq!(sk.complexity, ComplexityTier::Minimal);
}
#[test]
fn layout_commission_is_grid() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk =
generate_quarter_skeleton(&ctx, 500_000, "institutional", 1, 100, SeedChain::root(99));
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid));
}
#[test]
fn layout_pioneer_is_organic() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
let sk =
generate_quarter_skeleton(&ctx, 500_000, "residential", 1, 400, SeedChain::root(99));
assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }));
}
#[test]
fn block_grid_is_fully_populated() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
// All 16 blocks must have valid positions.
for row in 0..4 {
for col in 0..4 {
let b = &sk.blocks[row][col];
assert_eq!(b.position, (row as u8, col as u8));
assert!(b.density_pct <= 100);
}
}
}
#[test]
fn no_reservations_for_small_city() {
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
// pop_tier 0, not transit_hub → no reservations.
let sk = generate_quarter_skeleton(&ctx, 80_000, "residential", 1, 50, SeedChain::root(42));
assert!(sk.reservations.is_empty());
}
#[test]
fn park_reservation_for_large_city() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
// 100M pop → pop_tier 2 → park reservation.
let sk =
generate_quarter_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
let has_park = sk
.reservations
.iter()
.any(|r| matches!(r.function, ReservationFunction::Park));
assert!(has_park);
}
#[test]
fn transit_terminal_for_transit_hub_role() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
// pop_tier 0 but transit_hub → terminal reservation.
let sk =
generate_quarter_skeleton(&ctx, 80_000, "transit_hub", 1, 200, SeedChain::root(42));
let has_terminal = sk
.reservations
.iter()
.any(|r| matches!(r.function, ReservationFunction::Terminal));
assert!(has_terminal);
}
#[test]
fn reserved_blocks_linked_in_grid() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
// 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1.
let sk =
generate_quarter_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
// All park blocks must reference the park reservation (id=1).
for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
let b = &sk.blocks[row as usize][col as usize];
assert!(
b.reservation.is_some(),
"block ({row},{col}) should be reserved"
);
}
}
#[test]
fn determinism_same_seed_same_output() {
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
let sk1 = generate_quarter_skeleton(
&ctx,
2_000_000,
"manufacturing",
77,
250,
SeedChain::root(12345),
);
let sk2 = generate_quarter_skeleton(
&ctx,
2_000_000,
"manufacturing",
77,
250,
SeedChain::root(12345),
);
// Compare block grid zoning and positions.
for row in 0..4 {
for col in 0..4 {
assert_eq!(sk1.blocks[row][col].zoning, sk2.blocks[row][col].zoning);
assert_eq!(sk1.blocks[row][col].position, sk2.blocks[row][col].position);
assert_eq!(
sk1.blocks[row][col].density_pct,
sk2.blocks[row][col].density_pct
);
}
}
assert_eq!(sk1.reservations.len(), sk2.reservations.len());
}
// ── Building-property-tag derivation (D-229, #957) ───────────────────────
use crate::simulation::generator::{BuildingEntryClass, ConstructionEra, EraCause, ZoningType};
fn seed() -> SeedChain {
SeedChain::root(42)
}
#[test]
fn zone_type_role_refinement() {
// economic_role refines the slice within a ZoningType.
let z = zone_type_for(
&ZoningType::Industrial,
"manufacturing",
&SettingType::Urban,
seed(),
);
assert!(["industrial_manufacturing", "industrial_processing"].contains(&z.as_str()));
let r = zone_type_for(
&ZoningType::Restricted,
"military",
&SettingType::Urban,
seed(),
);
assert!([
"military_garrison",
"security_checkpoint",
"detention_facility"
]
.contains(&r.as_str()));
}
#[test]
fn zone_type_unknown_role_uses_default() {
let z = zone_type_for(
&ZoningType::Commercial,
"totally_unknown_role",
&SettingType::Urban,
seed(),
);
assert!(["commercial_market", "entertainment_hospitality"].contains(&z.as_str()));
}
#[test]
fn zone_type_maritime_tweaker_swaps_to_water_variants() {
// Transit on a Maritime body → port_maritime / port_fishing, never port_surface.
let mut got = std::collections::BTreeSet::new();
for s in 0..40u64 {
let z = zone_type_for(
&ZoningType::Transit,
"transit_hub",
&SettingType::Maritime,
SeedChain::root(s),
);
got.insert(z.as_str().to_string());
}
assert!(
!got.contains("port_surface"),
"maritime must not yield port_surface: {got:?}"
);
assert!(got
.iter()
.all(|id| id == "port_maritime" || id == "port_fishing" || id == "commercial_transit"));
}
#[test]
fn zone_type_never_yields_station_only_ids() {
// Station/orbital ids belong to the separate cascade (Q-109) — never selected here.
let banned = [
"residential_station",
"extraction_space",
"port_space",
"rural_orbital",
];
let settings = [
SettingType::Urban,
SettingType::Maritime,
SettingType::Agricultural,
SettingType::Wilderness {
biome: Default::default(),
},
];
let roles = [
"manufacturing",
"extraction",
"residential",
"agricultural",
"transit_hub",
];
for set in &settings {
for role in &roles {
for zoning in [
ZoningType::Residential,
ZoningType::Industrial,
ZoningType::Transit,
ZoningType::Mixed,
] {
for s in 0..8u64 {
let z = zone_type_for(&zoning, role, set, SeedChain::root(s));
assert!(!banned.contains(&z.as_str()), "{z} is station-only");
}
}
}
}
}
#[test]
fn zone_type_is_deterministic() {
let a = zone_type_for(
&ZoningType::Mixed,
"service_mixed",
&SettingType::Urban,
seed(),
);
let b = zone_type_for(
&ZoningType::Mixed,
"service_mixed",
&SettingType::Urban,
seed(),
);
assert_eq!(a, b);
}
#[test]
fn entry_class_u_curve_and_layout_fork() {
use BuildingEntryClass::*;
// Commercial at Broken prosperity → sealed.
assert_eq!(
building_entry_class(&ZoningType::Commercial, &DistrictLayoutMode::Grid, 1000),
BreachOnly
);
// Commercial healthy → Commercial.
assert_eq!(
building_entry_class(&ZoningType::Commercial, &DistrictLayoutMode::Grid, 7000),
Commercial
);
// Residential: Grid → formal Restricted; Organic → informal Public.
assert_eq!(
building_entry_class(&ZoningType::Residential, &DistrictLayoutMode::Grid, 7000),
Restricted
);
assert_eq!(
building_entry_class(
&ZoningType::Residential,
&DistrictLayoutMode::Organic {
placements: Default::default()
},
7000
),
Public
);
}
#[test]
fn construction_era_derelict_when_old_and_broken() {
let (era, cause) = construction_era(800, 1000, seed());
assert_eq!(era, ConstructionEra::Derelict);
assert_eq!(cause, EraCause::EconomicDisruption);
}
#[test]
fn construction_era_young_skews_modern_and_is_deterministic() {
let (era1, _) = construction_era(20, 7000, SeedChain::root(7));
let (era2, _) = construction_era(20, 7000, SeedChain::root(7));
assert_eq!(era1, era2);
assert!(matches!(
era1,
ConstructionEra::Modern | ConstructionEra::Established
));
}
#[test]
fn floor_extent_scales_with_density() {
// Frontier density → exactly 1 floor, no basement.
let low = floor_extent(10, seed());
assert_eq!(low.floor_count, 1);
assert_eq!(low.base_floor, 0);
// Dense → 3–6 floors with a basement.
let high = floor_extent(80, seed());
assert!((3..=6).contains(&high.floor_count));
assert_eq!(high.base_floor, -1);
}
#[test]
fn initial_condition_tracks_prosperity() {
use crate::atlas::tile_condition::TileCondition;
assert_eq!(
initial_condition(9000, &EraCause::Original),
TileCondition::Intact
);
assert_eq!(
initial_condition(1000, &EraCause::Original),
TileCondition::Broken
);
}
// ── Footprint subdivision + block-tag assembly (#957) ────────────────────
#[test]
fn footprints_fit_within_block_bounds() {
let fps = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(1),
);
assert!(!fps.is_empty(), "a dense block should produce footprints");
for fp in &fps {
assert!(fp.size.0 >= 1 && fp.size.1 >= 1);
assert!(fp.origin.0 as u16 + fp.size.0 as u16 <= BLOCK_TILES as u16);
assert!(fp.origin.1 as u16 + fp.size.1 as u16 <= BLOCK_TILES as u16);
}
}
#[test]
fn denser_blocks_pack_more_footprints() {
let sparse = subdivide_block_footprints(
15,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(7),
);
let dense = subdivide_block_footprints(
85,
&BulkClass::NonPhysical,
&MorphologyZone::AlluvialPlain,
None,
SeedChain::root(7),
);
assert!(
dense.len() > sparse.len(),
"dense ({}) should pack more lots than sparse ({})",
dense.len(),
sparse.len()
);
}
#[test]
fn bulk_class_drives_coverage() {
// Open-yard bulk (BulkSolid, 25–40%) roofs less than information economy.
assert!(roofed_coverage_pct(100, &BulkClass::BulkSolid) <= 40);
assert!(roofed_coverage_pct(100, &BulkClass::NonPhysical) >= 85);
}
#[test]
fn block_tags_cover_quarter_and_skip_reservations() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
let sk =
generate_quarter_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42));
let tags = assign_all_block_tags(
&sk,
&ctx,
"financial",
300,
SeedChain::root(42),
&ExteriorCatalog::default(),
);
assert!(!tags.is_empty(), "quarter should produce building tags");
// Park reservation blocks (1,2),(1,3),(2,2),(2,3) get no standard fill.
for &reserved in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] {
assert!(
!tags.contains_key(&reserved),
"reserved block {reserved:?} must not be tagged"
);
}
// Every tag is a planetary (non-station) zone with ≥1 floor.
let banned = [
"residential_station",
"extraction_space",
"port_space",
"rural_orbital",
];
for block in tags.values() {
for tag in block {
assert!(!banned.contains(&tag.zone_type_id.as_str()));
assert!(tag.extent.floor_count >= 1);
assert!(tag.doors.is_empty()); // D-231 doors deferred to #979
}
}
}
#[test]
fn block_tags_are_deterministic() {
let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
let sk =
generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5));
let a = assign_all_block_tags(
&sk,
&ctx,
"manufacturing",
250,
SeedChain::root(5),
&ExteriorCatalog::default(),
);
let b = assign_all_block_tags(
&sk,
&ctx,
"manufacturing",
250,
SeedChain::root(5),
&ExteriorCatalog::default(),
);
assert_eq!(a, b);
}
// ── D-232 phase-2 dominant-template lookup (T-994) ───────────────────────
#[test]
fn flavor_ref_reads_the_pre_resolved_district_dominant_index() {
// The three-phase draw pre-resolves one dominant trait-selection index
// per DistrictType at dispatch time (context.district_dominant_by_type);
// assign_block_tags must be a pure lookup over it — no independent RNG
// draw of its own. Residential is unconditionally present (D-194 pop-tier
// guarantee, tier_guarantees(0).2 == 1) for a sub-1M-population city, so
// this isn't a vacuous check regardless of seed.
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["a".into(), "b".into(), "c".into()];
ctx.district_dominant_by_type.insert(
DistrictType::Residential,
ArchitectureFlavorRef::InVocabulary(2),
);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&ExteriorCatalog::default(),
);
let mut found_residential = false;
for (pos, block_tags) in &tags {
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
if block.district_type != DistrictType::Residential {
continue;
}
found_residential = true;
for tag in block_tags {
assert_eq!(
tag.flavor_ref,
ArchitectureFlavorRef::InVocabulary(2),
"Residential block {pos:?} should read the pre-resolved index"
);
}
}
assert!(
found_residential,
"expected at least one Residential block (D-194 pop-tier guarantee)"
);
}
#[test]
fn flavor_ref_falls_back_to_zero_for_unmapped_district_type() {
// An empty district_dominant_by_type (e.g. no trait catalog reader wired,
// or K=0) must not panic — every block falls back to index 0, matching
// the harmless pre-T-994 degenerate behaviour.
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&ExteriorCatalog::default(),
);
for block_tags in tags.values() {
for tag in block_tags {
assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0));
}
}
}
// ── D-232 deviation/swerve wildcard (T-1003) ─────────────────────────────
#[test]
fn swerve_wildcard_is_rare_deterministic_and_draws_from_the_pools() {
// With both pools populated and rates at the 300 bps cap (6% per
// building total), a full quarter must still be overwhelmingly
// district-dominant, any swerved building must carry a pool tag, and
// the whole assignment must be reproducible (D-010).
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["own".into()];
ctx.swerve_rates_bps = (300, 300);
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags_a = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&ExteriorCatalog::default(),
);
let tags_b = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&ExteriorCatalog::default(),
);
assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)");
let mut total = 0usize;
let mut swerved = 0usize;
for block_tags in tags_a.values() {
for tag in block_tags {
total += 1;
match &tag.flavor_ref {
ArchitectureFlavorRef::InVocabulary(_) => {}
ArchitectureFlavorRef::Swerve(t) => {
swerved += 1;
assert!(
t == "foreign_temple" || t == "old_hacienda",
"swerve must draw a pool tag, got '{t}'"
);
}
}
}
}
assert!(
total > 20,
"expected a meaningful building count, got {total}"
);
assert!(
swerved * 100 < total * 25,
"swerves must stay rare: {swerved}/{total}"
);
}
#[test]
fn zero_rates_never_swerve_even_with_populated_pools() {
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
ctx.trait_selection = vec!["own".into()];
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
// rates stay (0, 0) from make_context
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&ExteriorCatalog::default(),
);
for block_tags in tags.values() {
for tag in block_tags {
assert!(matches!(
tag.flavor_ref,
ArchitectureFlavorRef::InVocabulary(_)
));
}
}
}
// ── D-235 BuildingExteriorTag integration (T-988) ────────────────────────
use crate::atlas::trait_catalog_reader::{TraitTemplate, VisualBundle};
use crate::simulation::generator::{FacadeRhythm, RoofForm, StreetSurface, WallMaterial};
fn exterior_template(tag: &str, wall: WallMaterial, roof: RoofForm) -> TraitTemplate {
TraitTemplate {
tag: tag.to_string(),
corridor_pool: "cross_corridor".to_string(),
geographic_sector: None,
bulk_class_gate: Vec::new(),
production_ubiquity_gate: Vec::new(),
min_prosperity_bps: 0,
base_weight: 10_000,
weight_mods: BTreeMap::new(),
zone_affinity: BTreeMap::new(),
visual_bundle: VisualBundle {
wall: vec![wall],
roof: vec![roof],
facade: vec![FacadeRhythm::IndustrialGlazing],
street: vec![StreetSurface::HeavyHaul],
color_register: None,
fallback: BTreeMap::new(),
},
}
}
#[test]
fn assign_block_tags_resolves_exterior_from_in_vocabulary_flavor_ref() {
// The InVocabulary(0) path: flavor_ref indexes into trait_selection,
// which must resolve against exterior_catalog.templates to drive the
// frozen BuildingExteriorTag — the T-988 deliverable.
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["camp".to_string()];
ctx.district_dominant_by_type.insert(
DistrictType::Residential,
ArchitectureFlavorRef::InVocabulary(0),
);
let exterior_catalog = ExteriorCatalog {
templates: vec![exterior_template(
"camp",
WallMaterial::SteelFrame,
RoofForm::CorrugatedRoof,
)],
zone_bias: BTreeMap::new(),
color_bands: BTreeMap::new(),
};
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&exterior_catalog,
);
let mut found_residential = false;
for (pos, block_tags) in &tags {
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
if block.district_type != DistrictType::Residential {
continue;
}
found_residential = true;
let expected_setback = trait_exterior::derive_setback_tier(block.density_pct);
for tag in block_tags {
assert_eq!(tag.exterior.wall_material, WallMaterial::SteelFrame);
assert_eq!(tag.exterior.roof_form, RoofForm::CorrugatedRoof);
assert_eq!(
tag.exterior.setback_tier, expected_setback,
"setback_tier must follow the block's own density_pct (D-235 step 3)"
);
}
}
assert!(
found_residential,
"expected at least one Residential block (D-194 pop-tier guarantee)"
);
}
#[test]
fn assign_block_tags_resolves_exterior_for_swerved_buildings_too() {
// "Swerve(tag) refs resolve against the full catalog at the same
// point" — a swerved building's exterior must come from ITS OWN
// (out-of-vocabulary) template, distinct from the district-dominant one.
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["own".to_string()];
ctx.swerve_rates_bps = (10_000, 0); // always swerve, foreign only
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
let exterior_catalog = ExteriorCatalog {
templates: vec![
exterior_template("own", WallMaterial::ConcreteWall, RoofForm::FlatRoof),
exterior_template(
"foreign_temple",
WallMaterial::TimberWall,
RoofForm::ClayTileRoof,
),
],
zone_bias: BTreeMap::new(),
color_bands: BTreeMap::new(),
};
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&exterior_catalog,
);
let mut any_swerved = false;
for block_tags in tags.values() {
for tag in block_tags {
if matches!(&tag.flavor_ref, ArchitectureFlavorRef::Swerve(t) if t == "foreign_temple")
{
any_swerved = true;
assert_eq!(tag.exterior.wall_material, WallMaterial::TimberWall);
assert_eq!(tag.exterior.roof_form, RoofForm::ClayTileRoof);
}
}
}
assert!(
any_swerved,
"swerve_rates_bps=10_000 must swerve every building"
);
}
#[test]
fn assign_block_tags_falls_back_to_generic_when_template_unresolvable() {
// An InVocabulary index with no matching catalog entry (empty
// ExteriorCatalog, e.g. no catalog reader wired) must degrade to
// Generic/neutral, never panic.
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(
&sk,
&ctx,
"financial",
200,
SeedChain::root(42),
&ExteriorCatalog::default(),
);
assert!(!tags.is_empty());
for block_tags in tags.values() {
for tag in block_tags {
assert_eq!(tag.exterior.wall_material, WallMaterial::Generic);
assert_eq!(tag.exterior.roof_form, RoofForm::Generic);
}
}
}
#[test]
fn assign_block_tags_is_deterministic_including_exterior() {
let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
ctx.trait_selection = vec!["camp".to_string()];
let exterior_catalog = ExteriorCatalog {
templates: vec![exterior_template(
"camp",
WallMaterial::BrickWall,
RoofForm::PitchedRoof,
)],
zone_bias: BTreeMap::new(),
color_bands: BTreeMap::new(),
};
let sk =
generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5));
let a = assign_all_block_tags(
&sk,
&ctx,
"manufacturing",
250,
SeedChain::root(5),
&exterior_catalog,
);
let b = assign_all_block_tags(
&sk,
&ctx,
"manufacturing",
250,
SeedChain::root(5),
&exterior_catalog,
);
assert_eq!(a, b, "same seeds must produce identical exterior tags too");
}
// ── D-233 operations-surface (T-1097) ────────────────────────────────────
#[test]
fn bulk_industry_blocks_read_as_operations_surface() {
assert_eq!(
interstitial_character_for(&BulkClass::BulkSolid),
InterstitialCharacter::OperationsSurface
);
assert_eq!(
interstitial_character_for(&BulkClass::BulkLiquid),
InterstitialCharacter::OperationsSurface
);
assert_eq!(
interstitial_character_for(&BulkClass::PrecisionDense),
InterstitialCharacter::OpenSpace
);
assert_eq!(
interstitial_character_for(&BulkClass::Perishable),
InterstitialCharacter::OpenSpace
);
assert_eq!(
interstitial_character_for(&BulkClass::NonPhysical),
InterstitialCharacter::OpenSpace
);
}
#[test]
fn every_block_in_a_bulk_industry_settlement_reads_operations_surface() {
let mut ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional);
ctx.dominant_bulk_class = BulkClass::BulkSolid;
let sk =
generate_quarter_skeleton(&ctx, 2_000_000, "manufacturing", 9, 250, SeedChain::root(5));
for row in &sk.blocks {
for block in row {
assert_eq!(
block.interstitial_character,
InterstitialCharacter::OperationsSurface
);
}
}
}
#[test]
fn non_bulk_settlement_keeps_open_space() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
// make_context defaults dominant_bulk_class to NonPhysical.
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
for row in &sk.blocks {
for block in row {
assert_eq!(
block.interstitial_character,
InterstitialCharacter::OpenSpace
);
}
}
}
// ── Street network (#957, D-234) ─────────────────────────────────────────
use crate::simulation::generator::AccessKind;
#[test]
fn access_points_from_entries_and_reservations() {
let res = vec![MultiBlockReservation {
blocks: vec![(1, 2), (1, 3)],
template_tag: String::new(),
function: ReservationFunction::Park,
z_levels: 1,
base_z: 0,
floor_zones: Vec::new(),
z_band_count: 0,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
}];
let aps = derive_access_points(&[0, 4], &res);
assert_eq!(aps.len(), 3); // 2 edges + 1 reservation gate
assert!(matches!(aps[0].kind, AccessKind::QuarterEdge { octant: 0 }));
assert!(aps
.iter()
.any(|a| matches!(a.kind, AccessKind::ReservationGate { .. })));
}
#[test]
fn access_points_never_empty() {
let aps = derive_access_points(&[], &[]);
assert_eq!(aps.len(), 1);
assert!(matches!(aps[0].kind, AccessKind::BlockJunction));
}
#[test]
fn snap45_segments_are_axis_or_diagonal() {
let path = snap45_path((10, 20), (200, 60));
assert!(path.len() >= 2);
for w in path.windows(2) {
let dx = (w[0].0 as i32 - w[1].0 as i32).abs();
let dy = (w[0].1 as i32 - w[1].1 as i32).abs();
// axis-aligned (one delta 0) or perfect 45° diagonal (dx == dy).
assert!(
dx == 0 || dy == 0 || dx == dy,
"segment {:?}->{:?} not ±45°",
w[0],
w[1]
);
}
}
#[test]
fn corridors_form_a_tree() {
// A spanning tree over n nodes has n-1 edges, for each topology.
let mesh = derive_access_points(&[0, 2, 4, 6], &[]);
assert_eq!(
derive_corridors(&mesh, &MorphologyZone::AlluvialPlain).len(),
3
);
assert_eq!(
derive_corridors(&mesh, &MorphologyZone::MountainPass).len(),
3
); // ribbon
assert_eq!(derive_corridors(&mesh, &MorphologyZone::Delta).len(), 3); // hub-spoke
}
#[test]
fn corridors_single_node_returns_empty() {
let aps = derive_access_points(&[], &[]);
assert_eq!(aps.len(), 1);
assert!(derive_corridors(&aps, &MorphologyZone::AlluvialPlain).is_empty());
}
#[test]
fn hub_spoke_shares_a_common_node() {
let aps = derive_access_points(&[0, 2, 4, 6], &[]);
let corridors = derive_corridors(&aps, &MorphologyZone::OpenOcean);
// Every spoke touches the hub.
let hub = central_node(&aps) as u16;
assert!(corridors.iter().all(|c| c.from == hub || c.to == hub));
}
#[test]
fn coastal_edge_maps_facing_to_cardinal() {
let c = |d| coastal_edge(&FoundingOrientation::Coastal { facing_degrees: d });
assert_eq!(c(0), Some(Edge::North));
assert_eq!(c(90), Some(Edge::East));
assert_eq!(c(180), Some(Edge::South));
assert_eq!(c(270), Some(Edge::West));
assert_eq!(coastal_edge(&FoundingOrientation::Cardinal), None);
}
#[test]
fn waterfront_footprints_present_to_the_quay() {
// The water-facing (north) edge drops its setback → buildings sit flush
// (origin.1 == 0), unlike the standard margin (D-234b).
let inland = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::RiverBank,
None,
SeedChain::root(3),
);
let quay = subdivide_block_footprints(
70,
&BulkClass::NonPhysical,
&MorphologyZone::RiverBank,
Some(Edge::North),
SeedChain::root(3),
);
let min_y = |v: &[TileRect]| v.iter().map(|r| r.origin.1).min().unwrap_or(u8::MAX);
assert!(min_y(&inland) >= BLOCK_MARGIN);
assert!(
min_y(&quay) < min_y(&inland),
"quay buildings should reach the water edge"
);
}
#[test]
fn waterfront_applies_at_frontier_density() {
// A coastal frontier settlement (few large lots) still presents to the quay.
let inland = subdivide_block_footprints(
10,
&BulkClass::BulkSolid,
&MorphologyZone::RiverBank,
None,
SeedChain::root(11),
);
let quay = subdivide_block_footprints(
10,
&BulkClass::BulkSolid,
&MorphologyZone::RiverBank,
Some(Edge::North),
SeedChain::root(11),
);
let min_y = |v: &[TileRect]| v.iter().map(|r| r.origin.1).min().unwrap_or(u8::MAX);
assert!(min_y(&quay) < min_y(&inland));
}
#[test]
fn skeleton_has_streets_and_local_lattice() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
assert!(!sk.access_points.is_empty());
assert!(!sk.corridors.is_empty());
// Local lattice spacing is set on every block.
for row in &sk.blocks {
for b in row {
assert!(b.chunk_layout.spacing > 0);
}
}
}
#[test]
fn organic_layout_jitters_local_lattice() {
// An old Pioneer settlement is Organic → some block lattice is rotated/offset.
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
let sk =
generate_quarter_skeleton(&ctx, 500_000, "residential", 1, 600, SeedChain::root(7));
if matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }) {
let jittered = sk
.blocks
.iter()
.flatten()
.any(|b| b.chunk_layout.rotation_steps > 0 || b.chunk_layout.offset != (0, 0));
assert!(jittered, "organic quarter should jitter some local lattice");
}
}
}