From 46b8688fa34b76f9995e7d2d5735a0f0d7fdff27 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 30 May 2026 16:07:33 +0200 Subject: [PATCH] feat(simulation): foundation shared types + struct plumbing (#1006) Define the type-definition layer the Phase-4 fill-seam tickets depend on (D-229/D-230/D-231/D-232/D-233), compiling with stubs/defaults; behavior logic lands in #982-985/#998. - New types in generator.rs: BuildingPropertyTag, FloorExtent + FloorHeightProfile (floor_at_voxel_z/voxel_range_for_floor, resolves Q-104), BuildingEntryClass, ConstructionEra, ZoneTypeId, MorphologyZone, BulkClass(5), ProductionUbiquity, DoorSpec, InteriorDescriptor, DistrictWorldState. - Rename spatial AccessTier -> ZoneAccessTier to free the name for the new per-building BuildingEntryClass. - CityGenerationContext: +morphology_zone, +trait_selection, +dominant_bulk_class, +dominant_production_ubiquity. - BodyWorldState: +districts (DistrictWorldState w/ block_tags). - GenCompletion::SkeletonGenerated carries body_id + DistrictWorldState; plugin handler inserts into BodyWorldState.districts. - Add smallvec as a direct dep (DoorSpec list stays Vec for now, TODO). cargo check --all-targets / clippy clean; 1259 lib tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/Cargo.lock | 4 + server/Cargo.toml | 1 + server/src/atlas/body_world_state.rs | 16 +- server/src/atlas/cascade.rs | 1 + server/src/atlas/gen_queue.rs | 14 +- server/src/atlas/layer_proxy.rs | 1 + server/src/atlas/plugin.rs | 22 +- server/src/atlas/skeleton_gen.rs | 8 +- server/src/atlas/tile_condition.rs | 4 +- server/src/simulation/generator.rs | 476 ++++++++++++++++++++++++++- 10 files changed, 535 insertions(+), 12 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index eac2ab22c..0211a4fc1 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1492,6 +1492,7 @@ dependencies = [ "serde_json", "serde_norway", "sha2", + "smallvec", "sysinfo", "thiserror", "toml", @@ -1551,6 +1552,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smol_str" diff --git a/server/Cargo.toml b/server/Cargo.toml index d8f6dc1bc..13ecd556c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -31,6 +31,7 @@ bytemuck = "1" png = "0.17" toml = "0.8" aho-corasick = "1" +smallvec = { version = "1", features = ["serde"] } # Economics simulation — Leontief + tâtonnement + D-180 event port (#821) econ-sim = { path = "../tooling/econ-sim" } diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs index ce868a66d..8f6d1deef 100644 --- a/server/src/atlas/body_world_state.rs +++ b/server/src/atlas/body_world_state.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, BTreeSet}; use bevy_ecs::prelude::Resource; use serde::{Deserialize, Serialize}; -use crate::simulation::generator::GeographicAttractor; +use crate::simulation::generator::{DistrictId, DistrictWorldState, GeographicAttractor}; /// Simulation tick counter — monotonically increasing u64. pub type SimTick = u64; @@ -71,6 +71,11 @@ pub struct BodyWorldState { pub drainage_basins: Vec, /// Geographic attractors (D-195, D-209). Empty until attractor task completes. pub attractors: Vec, + /// District-level world state, keyed by `DistrictId` (D-230). + /// + /// Populated by `GenCompletion::SkeletonGenerated` after the plan phase + /// completes for each city. `BTreeMap` for D-010 determinism. + pub districts: BTreeMap, /// Last sim tick this entry was read. Used for LRU eviction. pub last_accessed: SimTick, } @@ -129,6 +134,14 @@ impl BodyWorldStateCache { self.entries.get(body_id) } + /// Get a mutable reference without bumping `last_accessed`. + /// + /// Used by the district-state insertion path (D-230) which writes into + /// the cached state without constituting a "read" for LRU purposes. + pub fn peek_mut(&mut self, body_id: &str) -> Option<&mut BodyWorldState> { + self.entries.get_mut(body_id) + } + /// Returns `true` if the cache has an entry for `body_id`. pub fn contains(&self, body_id: &str) -> bool { self.entries.contains_key(body_id) @@ -183,6 +196,7 @@ mod tests { river_network: RiverNetwork::default(), drainage_basins: vec![], attractors: vec![], + districts: BTreeMap::new(), last_accessed: tick, } } diff --git a/server/src/atlas/cascade.rs b/server/src/atlas/cascade.rs index 24b04512d..17d8a31f6 100644 --- a/server/src/atlas/cascade.rs +++ b/server/src/atlas/cascade.rs @@ -69,6 +69,7 @@ impl CascadeSnapshot { river_network, drainage_basins, attractors, + districts: std::collections::BTreeMap::new(), last_accessed: 0, } } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index b66ca86f4..40b747fd4 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -31,6 +31,7 @@ use crate::atlas::body_world_state::BodyWorldState; use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; use crate::seed::SeedChain; +use crate::simulation::generator::DistrictWorldState; // --------------------------------------------------------------------------- // Priority @@ -99,6 +100,11 @@ pub enum GenCompletion { }, SkeletonGenerated { city_id: u64, + /// The body this skeleton belongs to — used to route state into + /// `BodyWorldState.districts` (D-230). + body_id: String, + /// District-level world state (block tags) produced by the plan phase (D-230). + state: DistrictWorldState, }, ChunkFilled { district_id: u64, @@ -336,7 +342,13 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { }, }, GenWorkItem::GenerateSkeleton { city_id } => { - GenCompletion::SkeletonGenerated { city_id: *city_id } + // Stub: real skeleton generation (#957) will populate `body_id` from + // the CityGenerationContext and `state` from the plan phase (D-230). + GenCompletion::SkeletonGenerated { + city_id: *city_id, + body_id: String::new(), + state: DistrictWorldState::default(), + } } GenWorkItem::FillChunk { district_id, diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 33267c1b1..9ae624a42 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -200,6 +200,7 @@ mod tests { river_network: RiverNetwork::default(), drainage_basins: vec![], attractors: vec![], + districts: std::collections::BTreeMap::new(), last_accessed: 0, }); let (_db, resolver) = empty_resolver(); diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index f3d9dd723..cd48473fa 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -80,8 +80,26 @@ fn drain_generation_completions( GenCompletion::Failed { item, reason } => { tracing::warn!(?item, %reason, "background generation work item failed"); } - // Produced once the later layers land (#957 / #959); no consumer yet. - GenCompletion::SkeletonGenerated { .. } | GenCompletion::ChunkFilled { .. } => {} + // Insert district world state into the matching body's cache entry (D-230). + GenCompletion::SkeletonGenerated { + city_id, + body_id, + state, + } => { + if !body_id.is_empty() { + if let Some(body_state) = cache.peek_mut(&body_id) { + body_state.districts.insert(city_id, state); + } else { + tracing::warn!( + city_id, + body_id, + "SkeletonGenerated: body not in cache — district state dropped" + ); + } + } + // body_id empty = stub result from GenerateSkeleton stub; silently ignore. + } + GenCompletion::ChunkFilled { .. } => {} } } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index d20b07132..8ef56f31c 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -411,7 +411,8 @@ fn derive_reservations( mod tests { use super::*; use crate::simulation::generator::{ - CityGenerationContext, FoundingOrientation, PoliticalArchetype, SettingType, WorldTier, + BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype, + ProductionUbiquity, SettingType, WorldTier, }; fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext { @@ -424,6 +425,11 @@ mod tests { 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, } } diff --git a/server/src/atlas/tile_condition.rs b/server/src/atlas/tile_condition.rs index 7645f7449..a9c7bf08a 100644 --- a/server/src/atlas/tile_condition.rs +++ b/server/src/atlas/tile_condition.rs @@ -23,6 +23,8 @@ //! //! Threshold values are authored constants (D-217): 0.63, 0.43, 0.23. +use serde::{Deserialize, Serialize}; + use crate::simulation::generator::EraCause; // --------------------------------------------------------------------------- @@ -30,7 +32,7 @@ use crate::simulation::generator::EraCause; // --------------------------------------------------------------------------- /// Visual condition band for a tile, derived from prosperity_score (D-217). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum TileCondition { /// prosperity_score > 0.63. Clean, undamaged, well-maintained. Intact, diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index c3e0262a0..469de879e 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -15,6 +15,8 @@ //! //! Sources: workshop-outcomes.md, tyre-round4.md +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- @@ -196,9 +198,12 @@ pub enum ReservationFunction { UndergroundComplex, } -/// Access tier — who is allowed into a zone under normal circumstances. +/// Zone access tier — who is allowed into a spatial zone under normal circumstances. +/// +/// Renamed from `AccessTier` to `ZoneAccessTier` to avoid collision with the +/// dialogue-layer `AccessTier` (D-028) in `simulation::line_pool`. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub enum AccessTier { +pub enum ZoneAccessTier { /// Anyone can enter. Public, /// Requires employment or residence credential. @@ -445,10 +450,457 @@ pub struct CompatibilityMatrix { pub weights: [[f32; 7]; 10], } +// --------------------------------------------------------------------------- +// D-229/D-230/D-231/D-232/D-233 shared types (economic-built-world workshop) +// --------------------------------------------------------------------------- + +/// Physical access character of a building entrance. +/// +/// D-229: Named `BuildingEntryClass`, NOT `AccessTier`, to avoid colliding with +/// the dialogue-layer `AccessTier` (D-028) in `simulation::line_pool`. Both +/// types share the `Public` and `BreachOnly` labels but have orthogonal semantics. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum BuildingEntryClass { + /// Openly accessible — no credential required. + Public, + /// Requires a commercial transaction or active business purpose. + Commercial, + /// Requires explicit invitation, resident status, or employment credential. + Restricted, + /// Normally sealed — only accessible via breach mechanics. + BreachOnly, +} + +/// Construction era of a building block (D-229). +/// +/// Derived from `founding_age_years + prosperity_baseline + seed`. +/// Reads primarily as **age/wear** via the condition layer (D-217/D-198); +/// not a material-technology ladder (era = maintenance signal, not style signal). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum ConstructionEra { + /// Built during the settlement's founding period. + Founding, + /// Built during the established growth phase. + Established, + /// Recent construction. + Modern, + /// Original structure; now degraded beyond economic viability. + Derelict, +} + +/// Zone-type identifier — references a RON zone-type definition (D-229, D-142). +/// +/// A `Box` newtype matching the `id` field of one of the 31 D-142 zone-type +/// RON files. Using `Box` (heap-intern) rather than `String` to discourage +/// mutation and signal that the value is a stable content identifier. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct ZoneTypeId(pub Box); + +impl ZoneTypeId { + pub fn new(s: impl Into>) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ZoneTypeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Per-floor height specification for a building (D-229, resolves Q-104). +/// +/// `Uniform(voxels_per_floor)` covers the common case (3 voxels ≈ 3 m/floor). +/// `Variable` carries per-floor memory only when floors actually differ +/// (e.g. ground-floor retail at 5 voxels, offices at 3 voxels each). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum FloorHeightProfile { + /// All floors have the same height in voxels. Default: `Uniform(3)`. + Uniform(u8), + /// Per-floor heights in voxels, index 0 = `base_floor`. + Variable(Vec), +} + +impl Default for FloorHeightProfile { + fn default() -> Self { + // 3 voxels ≈ 3 m/floor (Jeroen confirmed default). + FloorHeightProfile::Uniform(3) + } +} + +/// Floor/basement extent for a building, bridging D-110 floor-index addressing +/// and D-227 physical voxel-z (D-229, resolves Q-104). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct FloorExtent { + /// Index of the bottom floor (negative = basement, per D-110). + pub base_floor: i8, + /// Total number of floors, always ≥ 1. + pub floor_count: u8, + /// Per-floor height specification. + pub heights: FloorHeightProfile, +} + +impl FloorExtent { + /// Number of floors above ground (base_floor ≥ 0 inclusive). + pub fn above_ground(&self) -> u8 { + if self.base_floor >= 0 { + self.floor_count + } else { + let basement_floors = (-self.base_floor) as u8; + self.floor_count.saturating_sub(basement_floors) + } + } + + /// Map a D-110 floor index to the first voxel-z value at that floor's base. + /// + /// Returns `None` if `floor_index` is outside `[base_floor, base_floor + floor_count)`. + pub fn floor_at_voxel_z(&self, voxel_z: i32) -> Option { + let voxel_z_for_base = self.voxel_z_for_floor(self.base_floor)?; + if voxel_z < voxel_z_for_base { + return None; + } + + let mut accum: i32 = voxel_z_for_base; + let top_floor = self.base_floor as i16 + self.floor_count as i16 - 1; + for f_offset in 0..self.floor_count { + let floor_idx = self.base_floor as i16 + f_offset as i16; + let h = self.floor_height_voxels(floor_idx as i8); + let next_accum = accum + h as i32; + if voxel_z >= accum && voxel_z < next_accum { + return Some(floor_idx as i8); + } + accum = next_accum; + if floor_idx >= top_floor { + break; + } + } + None + } + + /// Map a D-110 floor index to the inclusive voxel-z range `(min_z, max_z)`. + /// + /// Returns `None` if `floor_index` is outside the building's extent. + pub fn voxel_range_for_floor(&self, floor_index: i8) -> Option<(i32, i32)> { + let base_z = self.voxel_z_for_floor(self.base_floor)?; + let relative = floor_index as i16 - self.base_floor as i16; + if relative < 0 || relative >= self.floor_count as i16 { + return None; + } + let mut start: i32 = base_z; + for f_offset in 0..relative { + let fi = self.base_floor as i16 + f_offset; + start += self.floor_height_voxels(fi as i8) as i32; + } + let h = self.floor_height_voxels(floor_idx_for_offset(self.base_floor, relative)) as i32; + Some((start, start + h - 1)) + } + + // ── Private helpers ──────────────────────────────────────────────────── + + /// Convert a floor index to its cumulative voxel-z offset from the ground-floor + /// voxel origin (ground floor base = 0). + fn voxel_z_for_floor(&self, floor: i8) -> Option { + let relative = floor as i16 - self.base_floor as i16; + if relative < 0 || relative >= self.floor_count as i16 { + return None; + } + let mut z: i32 = 0; + for f_offset in 0..relative { + let fi = self.base_floor as i16 + f_offset; + z += self.floor_height_voxels(fi as i8) as i32; + } + Some(z) + } + + fn floor_height_voxels(&self, floor_index: i8) -> u8 { + match &self.heights { + FloorHeightProfile::Uniform(h) => *h, + FloorHeightProfile::Variable(v) => { + let offset = (floor_index as i16 - self.base_floor as i16) as usize; + v.get(offset).copied().unwrap_or(3) + } + } + } +} + +fn floor_idx_for_offset(base: i8, offset: i16) -> i8 { + (base as i16 + offset) as i8 +} + +/// Axis-aligned rectangle in integer tile space (D-229, D-010 integer-only). +/// +/// Represents a building footprint within a 128-tile block. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TileRect { + /// Origin corner (tile-space x, y within the block, 0-based). + pub origin: (u8, u8), + /// Width and height in tiles (always ≥ 1). + pub size: (u8, u8), +} + +impl TileRect { + pub fn new(origin_x: u8, origin_y: u8, width: u8, height: u8) -> Self { + Self { + origin: (origin_x, origin_y), + size: (width, height), + } + } + + /// Returns `true` if the point `(x, y)` is inside this rect (inclusive). + pub fn contains(&self, x: u8, y: u8) -> bool { + x >= self.origin.0 + && x < self.origin.0.saturating_add(self.size.0) + && y >= self.origin.1 + && y < self.origin.1.saturating_add(self.size.1) + } +} + +/// Architecture flavor index into the body's trait-template draw (D-229, D-232). +/// +/// Records which template the generator selected at skeleton time for Phase-6 to +/// read cold. The selection mechanism is D-232's weighted `allow`/`block` filter; +/// the index is frozen-amber once written. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ArchitectureFlavorRef { + /// Index into `CityGenerationContext::trait_selection` (0-based). + pub flavor_index: u8, +} + +/// A single building footprint tag — the frozen step-3 output placed on every +/// building footprint at plan time (D-229). +/// +/// Written once inside `GenerateSkeleton`; read-only thereafter by FillChunk +/// (D-230), the guarantee audit (D-097), and the Phase-6 interior generator (D-231). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct BuildingPropertyTag { + /// What the building is — matches a D-142 RON zone-type `id`. + pub zone_type_id: ZoneTypeId, + /// Axis-aligned footprint within the 128-tile block. + pub footprint: TileRect, + /// Floor/basement extent (D-110 ↔ D-227 bridge, resolves Q-104). + pub extent: FloorExtent, + /// Physical access character (D-229). + pub entry_class: BuildingEntryClass, + /// Architecture flavor index into the body's trait-template draw (D-232). + pub flavor_ref: ArchitectureFlavorRef, + /// Construction era tag (D-229). + pub era: ConstructionEra, + /// Cause of this block's era classification. + pub era_cause: EraCause, + /// Frozen-amber condition snapshot from `prosperity_baseline` (D-197/D-217). + /// The rolling condition overlay (D-198) paints over this; never mutates the tag. + pub initial_condition: crate::atlas::tile_condition::TileCondition, + /// Doors into / out of this building (D-231). At least one `Main` door. + /// + /// Using `Vec` rather than `SmallVec<[DoorSpec; 4]>` for now; + /// the vast majority of buildings have 1–4 doors and the heap allocation + /// matches the D-231 intent. SmallVec upgrade tracked as a future + /// micro-optimisation once the fill layer is profiled. + // TODO: SmallVec<[DoorSpec; 4]> per D-231 once the door subsystem is hot. + pub doors: Vec, +} + +/// Cardinal compass direction (used by DoorSpec.facing, D-231). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum CardinalDirection { + North, + East, + South, + West, +} + +/// Door class within a building (D-231). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum DoorClass { + /// Primary public / commercial entrance — at least one per building. + Main, + /// Service or logistics entrance. + Service, + /// Emergency egress (required when `above_ground ≥ 2`, D-231). + Emergency, + /// Hidden entrance (D-106 rooftop/hidden discovery layer). + Hidden, +} + +/// Initial state of a door (D-231). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum DoorInitialState { + Open, + Closed, + Locked, + /// Permanently sealed — breach mechanics only. + Sealed, +} + +/// Door credential requirement (D-231). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum DoorCredential { + None, + /// Door is only accessible during specified hours (0–23, inclusive range). + TemporalWindow { open_hour: u8, close_hour: u8 }, + /// Requires an employment credential from this corporation. + Corporate { corp_id: String }, + /// Requires a residence credential in this block. + Resident { block_id: String }, + /// Law-enforcement / government authority only. + Authority, + /// Social trust score threshold (basis points; D-010 integer, 10 000 = 1.0). + Social { trust_threshold_bps: u32 }, +} + +/// What a door connects to on the other side (D-231). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum DoorConnectsTo { + /// Opens onto a named street. + Street { street_id: String }, + /// Opens into an adjacent building in the same block. + AdjacentBuilding { block_pos: (u8, u8) }, + /// Opens into interstitial space (courtyard, alley, gap). + Interstitial, +} + +/// The complete Phase-6 seed for an interior (D-231). +/// +/// A future interior generator produces a deterministic floor plan from this +/// descriptor + `SeedChain` with **no other system queried**. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct InteriorDescriptor { + pub zone_type_id: ZoneTypeId, + pub entry_class: BuildingEntryClass, + pub floor_extent: FloorExtent, + pub era: ConstructionEra, + pub flavor_ref: ArchitectureFlavorRef, + /// Prosperity baseline (0–10 000 basis points; D-010 integer-only). + pub prosperity_bps: u32, + pub layout_mode: LayoutMode, +} + +/// Layout mode for interior generation (D-231, D-096). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum LayoutMode { + /// Grid-planned interior — rectilinear rooms. + Grid, + /// Organic interior — irregular room shapes with offsets. + Organic, +} + +/// A single door on a building — the step3→step4 boundary (D-231). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DoorSpec { + /// Which face of the building footprint this door is on. + pub facing: CardinalDirection, + pub door_class: DoorClass, + pub entry_class: BuildingEntryClass, + pub initial_state: DoorInitialState, + pub credential: DoorCredential, + pub connects_to: DoorConnectsTo, + /// The complete Phase-6 interior seed (D-231). + pub interior_descriptor: InteriorDescriptor, +} + +/// Region-level morphology zone (D-228, D-232, D-234). +/// +/// Shared by all tiles in a region; constrains street geometry (D-234) +/// and acts as a soft weight on cultural-template eligibility (D-232). +/// Carried on `CityGenerationContext` (D-233 amend to D-199). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum MorphologyZone { + /// Steep-sided inlet — ribbon/hub-and-spoke streets only; pier geometry on water edges. + Fjord, + /// River delta / braided channel — hub-and-spoke following channels; bridges as forced nodes. + Delta, + /// Meandering river reach — any pattern. + MeanderReach, + /// Flat alluvial plain — any pattern; primary default for plains settlements. + AlluvialPlain, + /// Open ocean surface (deep-water context) — hub-and-spoke; perimeter access priority. + OpenOcean, + /// Lake shore — hub-and-spoke; perimeter access toward water. + Lake, + /// Interior sea body. + Sea, + /// Mountain pass terrain — ribbon only; elevation steps as block boundaries. + MountainPass, + /// Coastal lowland — any pattern; pier geometry on water-facing edges. + CoastalLowland, + /// Island context — hub-and-spoke; perimeter access priority. + Island, + /// Canyon floor — ribbon or hub-and-spoke only. + Canyon, + /// Unknown / unclassified — fallback to AlluvialPlain behaviour. + Unknown, +} + +impl Default for MorphologyZone { + fn default() -> Self { + MorphologyZone::AlluvialPlain + } +} + +/// Built-form archetype derived from a settlement's dominant commodity (D-233). +/// +/// 5 archetypes projected from the 8 cargo-type classifications in `commodities.toml`; +/// drives building-vocabulary pool selection and roofed-coverage fraction. +/// The 8 cargo types stay on the economics model — no fidelity lost. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum BulkClass { + /// Solid bulk commodity (ore, aggregate, grain). Coverage: 0.25–0.40. + /// Vocabulary: mine_head / conveyor_run / tailings_area. + BulkSolid, + /// Liquid bulk commodity (fuel, chemical, water). Coverage: 0.20–0.35. + /// Vocabulary: tank_farm / flare_stack / pump_station. + BulkLiquid, + /// Precision-dense, high-value goods (electronics, manufactured parts). + /// Coverage: 0.75–0.90. Vocabulary: cleanroom_facility / qc_lab / assembly_bay. + PrecisionDense, + /// Perishable goods (food, pharma, biologics). Coverage: 0.50–0.65. + /// Vocabulary: field_shed / cold_store / processing_plant. + Perishable, + /// Non-physical services / information economy. Coverage: 0.85–0.95. + /// Vocabulary: office_tower / civic_hall / data_centre. + NonPhysical, +} + +/// Spatial concentration of a settlement's dominant production (D-233). +/// +/// Controls how zone-type blocks are spread across the district grid — +/// spread vs cluster — not per-block weighting. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum ProductionUbiquity { + /// Production scattered through mixed-use (e.g. ubiquitous water infrastructure). + Ubiquitous, + /// Present in most districts but not dominant (e.g. common agriculture). + Common, + /// Concentrated in a few specialist districts. + Specialist, + /// Contiguous block groups dominate the settlement (the mine IS the city). + MonopolySource, +} + +/// District-level world state produced by the plan phase (D-230). +/// +/// Output of `GenerateSkeleton` extended to include building-property tags. +/// Stored in `BodyWorldState.districts: BTreeMap`. +/// `BTreeMap` for D-010 determinism. +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct DistrictWorldState { + /// Building property tags keyed by block position (row, col) within the 4×4 grid. + /// Each entry is a Vec of one tag per building footprint placed in that block. + /// `BTreeMap` for D-010 determinism (no HashMap non-determinism). + pub block_tags: BTreeMap<(u8, u8), Vec>, +} + /// Data contract between build-time (systems.db) and the runtime-background /// generation tier. Populated from atlas_city_names + bodies at generation /// dispatch time. All 8 fields are required before a generation task may run. /// Source: D-200, D-199 +/// +/// Amended by D-229/D-232/D-233: adds `morphology_zone`, `trait_selection`, +/// `dominant_bulk_class`, `dominant_production_ubiquity`. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct CityGenerationContext { /// Foreign key into atlas_city_names.id @@ -463,6 +915,18 @@ pub struct CityGenerationContext { pub footprint_radius_km: f32, pub founding_orientation: FoundingOrientation, pub world_tier: WorldTier, + // ── D-233/D-232 additions ───────────────────────────────────────────── + /// Region morphology zone (D-228). Constrains street geometry (D-234) and + /// acts as a soft weight on cultural-template eligibility (D-232). + pub morphology_zone: MorphologyZone, + /// Body vocabulary draw result — K trait-template tags selected at skeleton + /// time (D-232). K is locked to `complexity_tier`: Full=5, Moderate=3, + /// Minimal=1, Empty=0. NOT named `flavor_profile` (that was the round-2 name). + pub trait_selection: Vec, + /// Dominant `BulkClass` for this settlement's primary commodity (D-233). + pub dominant_bulk_class: BulkClass, + /// Spatial concentration of dominant production (D-233). + pub dominant_production_ubiquity: ProductionUbiquity, } // --------------------------------------------------------------------------- @@ -512,7 +976,7 @@ pub struct FloorZone { pub z_level: i8, pub zone_type: ZoningType, pub zone_palette: ZonePalette, - pub access_tier: AccessTier, + pub access_tier: ZoneAccessTier, } /// Vertical connection spec within a multi-level reservation. @@ -523,7 +987,7 @@ pub struct VerticalCorridorSpec { /// Which z-bands (0-indexed band indices, not absolute z-levels) this /// corridor connects. See D-110 for signed z-level coordinate system. pub z_bands_connected: Vec, - pub access_tier: AccessTier, + pub access_tier: ZoneAccessTier, pub corridor_type: VerticalCorridorType, } @@ -551,7 +1015,7 @@ pub struct SocialSitePlacement { /// Which blocks this site spans. pub blocks: Vec<(u8, u8)>, pub template_tag: String, - pub access_tier: AccessTier, + pub access_tier: ZoneAccessTier, pub triangles: Vec, pub role_slots: Vec, pub active_phases: Vec, @@ -713,7 +1177,7 @@ mod tests { base: String::new(), modifiers: vec![], }, - access_tier: AccessTier::BreachOnly, + access_tier: ZoneAccessTier::BreachOnly, }; assert_eq!(fz.z_level, -2, "sub-basement z_level must round-trip as i8"); }