//! 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; 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; 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 { 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) { 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 { 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, seed: SeedChain, ) -> Vec { 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, block_chain: SeedChain, exterior_catalog: &ExteriorCatalog, ) -> Vec { 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> { // 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 { 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 { 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 = (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"); } } }