diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 7e34f728a..c901fbad9 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -20,11 +20,14 @@ 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::seed::splitmix64; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{ - BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictLayoutMode, - DistrictType, MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, - ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType, + BlockPlacement, BlockSkeleton, BuildingEntryClass, CityGenerationContext, ComplexityTier, + ConstructionEra, DistrictLayoutMode, DistrictType, EraCause, FloorExtent, FloorHeightProfile, + MultiBlockReservation, PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, + ReservationId, SettingType, WorldTier, ZoneTypeId, ZoningType, }; // --------------------------------------------------------------------------- @@ -403,6 +406,262 @@ fn derive_reservations( 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)) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -603,4 +862,200 @@ mod tests { } 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 + ); + } }