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>
2383 lines
91 KiB
Rust
2383 lines
91 KiB
Rust
//! 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");
|
||
}
|
||
}
|
||
}
|