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..eac45a142 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,16 +100,19 @@ 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 (skeleton + block tags) produced by the plan phase (D-230). + /// Boxed to keep `GenCompletion` variant sizes balanced (D-230 skeleton is ~2.7 KB). + state: Box, }, ChunkFilled { district_id: u64, block_pos: (u32, u32), }, /// Work item failed — body_id or city_id for logging. - Failed { - item: GenWorkItem, - reason: String, - }, + Failed { item: GenWorkItem, reason: String }, } // --------------------------------------------------------------------------- @@ -336,7 +340,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: Box::new(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..a2fbe6d72 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -80,8 +80,35 @@ 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) { + // Key by state.skeleton.district_id (D-194/D-230): a city has many + // districts, each with its own DistrictId. `city_id` is only the + // dispatch key used in the work item — the canonical insert key is + // the district's own stable id. TODO(#957): the stub GenerateSkeleton + // returns a default skeleton with district_id=0; real gen (#957) will + // populate it from CityGenerationContext. + let _ = city_id; // used as dispatch key only; district_id is the map key + body_state + .districts + .insert(state.skeleton.district_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..867a792c4 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}; // --------------------------------------------------------------------------- @@ -101,7 +103,7 @@ pub type PlacedObject = String; /// Determines simulation fidelity budget and NPC complexity ceiling. /// /// Source: D-218, workshop-outcomes.md -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum WorldTier { /// Hub system. Full simulation, high faction pressure. Epicenter, @@ -112,12 +114,13 @@ pub enum WorldTier { /// Transit stop. Pass-through node. Moderate complexity ceiling. Passage, /// Not simulated until player approaches. Minimal complexity ceiling. + #[default] Waypoint, } /// Generator content budget for a district. /// Derived from WorldTier + SettingType at Phase 1. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] pub enum ComplexityTier { /// Full social architecture. All Tier 1+2 guarantees. 20-80+ NPCs. Full, @@ -126,26 +129,34 @@ pub enum ComplexityTier { /// Minimal social architecture. Tier 1 only. 1-8 NPCs. Minimal, /// No social architecture. Pure terrain. 0 NPCs. No guarantees. + #[default] Empty, } /// Physical setting type for a district. /// Merged from Gestalt's SettingGeometry and Tyre's TerrainType. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] pub enum SettingType { Station, + #[default] Urban, Agricultural, Maritime, - Wilderness { biome: Biome }, - Water { water_type: WaterType }, + Wilderness { + biome: Biome, + }, + Water { + water_type: WaterType, + }, Transitional, Orbital, - Specialized { function: SpecializedFunction }, + Specialized { + function: SpecializedFunction, + }, } /// Classification of a district (high-level function). -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] pub enum DistrictType { LogisticsHub, Residential, @@ -153,15 +164,17 @@ pub enum DistrictType { Industrial, Administrative, Entertainment, + #[default] MixedUse, Transit, Specialized, } /// How blocks are placed within the district's 512×512 footprint. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] pub enum DistrictLayoutMode { /// Standard Cartesian grid — perpendicular streets. + #[default] Grid, /// Organic placement with per-block offsets and rotations. Organic { @@ -170,7 +183,7 @@ pub enum DistrictLayoutMode { } /// Zoning classification for a block or floor zone. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] pub enum ZoningType { Commercial, Residential, @@ -179,11 +192,12 @@ pub enum ZoningType { Transit, Recreational, Restricted, + #[default] Mixed, } /// Reservation function — what purpose a multi-block reservation serves. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum ReservationFunction { Skyscraper, Park, @@ -196,9 +210,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. @@ -212,7 +229,7 @@ pub enum AccessTier { } /// Vertical corridor type (how floors connect in a multi-level reservation). -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum VerticalCorridorType { Stairwell, Elevator, @@ -281,7 +298,7 @@ pub enum WallBackside { } /// Era cause — why a block has the era tag it has. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum EraCause { Original, CorporateMerger, @@ -333,7 +350,7 @@ pub enum PoliticalArchetype { /// Primary spatial axis of a city's original street grid. /// Derived from the matched attractor type (D-211). Controls district grid rotation. /// Source: D-213 -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum FoundingOrientation { /// Street grid perpendicular to coastline. `facing_degrees`: compass bearing toward water (0–359). Coastal { facing_degrees: u16 }, @@ -445,11 +462,473 @@ 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, PartialEq)] +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 building-relative voxel-z coordinate to the D-110 floor index that contains it. + /// + /// Returns the floor index whose voxel span covers `voxel_z`, or `None` if `voxel_z` + /// falls outside the building's extent (below base floor or above the topmost floor). + /// + /// `voxel_z` is building-relative: the base floor maps to z = 0. Callers working in + /// world space must subtract the building's world-base z offset before calling this method. + 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. + /// + /// The returned range is building-relative: the base floor maps to z = 0. Callers working + /// in world space must add the building's world-base z offset to the returned values. + 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, PartialEq)] +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, PartialEq, Eq)] +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, PartialEq, Eq)] +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, PartialEq)] +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, PartialEq)] +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, Default)] +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. + #[default] + 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, +} + +/// 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. +/// +/// D-230: `{ skeleton: DistrictSkeleton, block_tags: BTreeMap<(u8,u8), Vec> }` +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +pub struct DistrictWorldState { + /// Phase 1 skeleton produced by `GenerateSkeleton` (D-230). + pub skeleton: DistrictSkeleton, + /// 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 -#[derive(Serialize, Deserialize, Clone, Debug)] +/// +/// Amended by D-229/D-232/D-233: adds `morphology_zone`, `trait_selection`, +/// `dominant_bulk_class`, `dominant_production_ubiquity`. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct CityGenerationContext { /// Foreign key into atlas_city_names.id pub city_id: u64, @@ -463,6 +942,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, } // --------------------------------------------------------------------------- @@ -470,7 +961,7 @@ pub struct CityGenerationContext { // --------------------------------------------------------------------------- /// Organic layout placement for a single block. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] pub struct BlockPlacement { /// Offset from grid-aligned position (±16 sim tiles per axis max). pub offset: (i16, i16), @@ -483,7 +974,7 @@ pub struct BlockPlacement { /// Single block within a district's 4×4 block grid. /// Each block = 128×128 sim tiles = 2×2 chunks. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] pub struct BlockSkeleton { /// Grid position (0–3, 0–3). pub position: (u8, u8), @@ -506,36 +997,36 @@ pub struct BlockSkeleton { /// **D-110:** `z_level` is `i8` — negative values represent basements /// (e.g. `z_level = -1` for a sub-basement). This differs from `z_levels: u8` /// on the parent reservation which counts total floors (always ≥ 1). -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct FloorZone { /// Absolute z-level of this floor. Negative for sub-ground levels (D-110). 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. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct VerticalCorridorSpec { /// Which blocks this vertical corridor passes through. pub block_coords: Vec<(u8, u8)>, /// 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, } /// Visual palette for a zone — base material + contextual modifiers. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] pub struct ZonePalette { pub base: BasePalette, pub modifiers: Vec, } /// Contextual modifier applied on top of a base palette. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum PaletteModifier { EconomicFunction(EconomicModifier), Era(Era), @@ -545,20 +1036,20 @@ pub enum PaletteModifier { } /// Social site placement within a district. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct SocialSitePlacement { pub site_id: SocialSiteId, /// 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, } /// Triangle assignment within a social site. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct TriangleAssignment { pub template: TriangleTemplate, pub purposes: Vec, @@ -611,7 +1102,7 @@ pub struct StructuralChange { /// `base_z: -3, z_levels: 24` /// A deep mine shaft descending 30 floors below ground: /// `base_z: -30, z_levels: 30` -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct MultiBlockReservation { /// Which blocks (grid positions) are part of this reservation. pub blocks: Vec<(u8, u8)>, @@ -653,7 +1144,7 @@ pub struct MultiBlockReservation { /// /// All `Vec<_>` fields are stable-ordered at generation time (sorted by a /// deterministic key). Generation reproduces identical output for the same seed. -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] pub struct DistrictSkeleton { // ── Identity ────────────────────────────────────────── pub district_id: DistrictId, @@ -703,6 +1194,106 @@ pub struct DistrictSkeleton { mod tests { use super::*; + // ── FloorExtent: floor_at_voxel_z and voxel_range_for_floor ───────────── + + /// (a) Uniform building at ground level — round-trip voxel↔floor. + /// + /// Uniform(3), base_floor=0, floor_count=2 → floors 0,1; heights 3 each. + /// Floor 0 occupies voxels 0–2; floor 1 occupies voxels 3–5. + #[test] + fn floor_extent_uniform_ground_round_trip() { + let extent = FloorExtent { + base_floor: 0, + floor_count: 2, + heights: FloorHeightProfile::Uniform(3), + }; + // Range for each floor. + assert_eq!(extent.voxel_range_for_floor(0), Some((0, 2))); + assert_eq!(extent.voxel_range_for_floor(1), Some((3, 5))); + // Round-trip: voxel → floor → range contains voxel. + assert_eq!(extent.floor_at_voxel_z(0), Some(0)); + assert_eq!(extent.floor_at_voxel_z(2), Some(0)); + assert_eq!(extent.floor_at_voxel_z(3), Some(1)); + assert_eq!(extent.floor_at_voxel_z(5), Some(1)); + } + + /// (b) Basement building — base_floor=-1, floor_count=3 (floors -1, 0, 1). + /// + /// Uniform(3): floor -1 → voxels 0–2, floor 0 → 3–5, floor 1 → 6–8. + #[test] + fn floor_extent_basement_building() { + let extent = FloorExtent { + base_floor: -1, + floor_count: 3, + heights: FloorHeightProfile::Uniform(3), + }; + assert_eq!(extent.voxel_range_for_floor(-1), Some((0, 2))); + assert_eq!(extent.voxel_range_for_floor(0), Some((3, 5))); + assert_eq!(extent.voxel_range_for_floor(1), Some((6, 8))); + assert_eq!(extent.floor_at_voxel_z(0), Some(-1)); + assert_eq!(extent.floor_at_voxel_z(3), Some(0)); + assert_eq!(extent.floor_at_voxel_z(6), Some(1)); + assert_eq!(extent.floor_at_voxel_z(8), Some(1)); + } + + /// (c) Variable([5,3,3]) floor heights — confirm each floor's voxel range. + /// + /// base_floor=0, floor_count=3: floor 0 → 5 voxels (0–4), floor 1 → 3 voxels (5–7), + /// floor 2 → 3 voxels (8–10). + #[test] + fn floor_extent_variable_heights() { + let extent = FloorExtent { + base_floor: 0, + floor_count: 3, + heights: FloorHeightProfile::Variable(vec![5, 3, 3]), + }; + assert_eq!(extent.voxel_range_for_floor(0), Some((0, 4))); + assert_eq!(extent.voxel_range_for_floor(1), Some((5, 7))); + assert_eq!(extent.voxel_range_for_floor(2), Some((8, 10))); + assert_eq!(extent.floor_at_voxel_z(0), Some(0)); + assert_eq!(extent.floor_at_voxel_z(4), Some(0)); + assert_eq!(extent.floor_at_voxel_z(5), Some(1)); + assert_eq!(extent.floor_at_voxel_z(7), Some(1)); + assert_eq!(extent.floor_at_voxel_z(8), Some(2)); + assert_eq!(extent.floor_at_voxel_z(10), Some(2)); + } + + /// (d) Boundary — one voxel above the top floor returns None; one below the base returns None. + /// + /// Uniform(3), base_floor=0, floor_count=2: valid range [0, 5]. + /// voxel_z=6 is one above the top; voxel_z=-1 is one below the base. + #[test] + fn floor_extent_boundary_returns_none() { + let extent = FloorExtent { + base_floor: 0, + floor_count: 2, + heights: FloorHeightProfile::Uniform(3), + }; + // One voxel above the top floor (top floor ends at voxel 5). + assert_eq!( + extent.floor_at_voxel_z(6), + None, + "one voxel above top floor must return None" + ); + // One voxel below the base floor (base maps to voxel 0). + assert_eq!( + extent.floor_at_voxel_z(-1), + None, + "one voxel below base floor must return None" + ); + // Floor index outside extent also returns None from voxel_range_for_floor. + assert_eq!( + extent.voxel_range_for_floor(2), + None, + "floor index beyond floor_count must return None" + ); + assert_eq!( + extent.voxel_range_for_floor(-1), + None, + "floor index below base_floor must return None" + ); + } + /// D-110: FloorZone.z_level must be i8 (signed) to support negative sub-levels. #[test] fn floor_zone_z_level_is_signed() { @@ -713,7 +1304,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"); }