diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index a2ff7987a..bccdc61c9 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -26,11 +26,12 @@ use crate::seed::{SeedChain, SeedDomain}; use std::collections::BTreeMap; use crate::simulation::generator::{ - ArchitectureFlavorRef, BlockPlacement, BlockSkeleton, BuildingEntryClass, BuildingPropertyTag, - BulkClass, CityGenerationContext, ComplexityTier, ConstructionEra, DistrictLayoutMode, - DistrictType, EraCause, FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation, - PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, - SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType, + AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton, + BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext, + ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause, + FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation, PoliticalArchetype, + QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, SettingType, TileRect, + WorldTier, ZoneTypeId, ZoningType, }; // --------------------------------------------------------------------------- @@ -102,7 +103,14 @@ pub fn generate_quarter_skeleton( .first() .cloned() .unwrap_or(DistrictType::MixedUse); - let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type); + let mut blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type); + + // ── 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. @@ -120,10 +128,10 @@ pub fn generate_quarter_skeleton( layout_mode, blocks, reservations, - corridors: Vec::new(), + corridors, z_levels, social_sites: Vec::new(), - access_points: Vec::new(), + access_points, society_profile: String::new(), zone_palette: Vec::new(), boundaries: String::new(), @@ -329,7 +337,13 @@ fn build_block_grid( position: (row as u8, col as u8), zoning, reservation, - chunk_layout: String::new(), // stub + // 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_modifications: Vec::new(), era_cause: None, @@ -880,6 +894,230 @@ pub fn assign_all_block_tags( map } +// --------------------------------------------------------------------------- +// Street network — access points, arterial corridors, local lattice (D-234, #957) +// +// Arterials (`corridors`) = a least-cost graph over the quarter's access nodes, +// ±45°-snapped (D-234 refined; the node/edge graph the D-097 audit reads). Local +// streets (`chunk_layout`) = a ±45° grid lattice per block, modulated by the +// D-096 layout mode. Morphology gates the trunk topology (D-234a). The per-edge +// waterfront pier rule (D-234b) needs Layer-1 terrain water-adjacency threaded to +// the skeleton and is the remaining piece. +// --------------------------------------------------------------------------- + +/// Quarter edge length in tiles (4 blocks × 128). +const QUARTER_TILES: u16 = 512; + +/// Local-street lattice spacing (tiles) from build density — denser → tighter. +fn local_street_spacing(density_pct: u8) -> u8 { + match density_pct { + 0..=30 => 32, + 31..=60 => 24, + 61..=85 => 16, + _ => 12, + } +} + +/// Perimeter point for a compass octant (0=N…7=NW) on the 512-tile quarter. +fn octant_perimeter(octant: u8) -> (u16, u16) { + let max = QUARTER_TILES - 1; + let mid = QUARTER_TILES / 2; + match octant % 8 { + 0 => (mid, 0), + 1 => (max, 0), + 2 => (max, mid), + 3 => (max, max), + 4 => (mid, max), + 5 => (0, max), + 6 => (0, mid), + _ => (0, 0), + } +} + +/// Quarter access nodes (D-234): one per road-entry octant + one gate per +/// reservation. Always non-empty (a central junction if nothing else), so the +/// arterial graph is well-defined. +fn derive_access_points( + road_entry_directions: &[u8], + reservations: &[MultiBlockReservation], +) -> Vec { + let mut pts = Vec::new(); + for &octant in road_entry_directions { + pts.push(AccessPoint { + position: octant_perimeter(octant), + kind: AccessKind::QuarterEdge { octant }, + }); + } + for (i, res) in reservations.iter().enumerate() { + if let Some(&(r, c)) = res.blocks.first() { + pts.push(AccessPoint { + position: (c as u16 * 128 + 64, r as u16 * 128 + 64), + kind: AccessKind::ReservationGate { + reservation: i as ReservationId + 1, + }, + }); + } + } + if pts.is_empty() { + pts.push(AccessPoint { + position: (QUARTER_TILES / 2, QUARTER_TILES / 2), + kind: AccessKind::BlockJunction, + }); + } + pts +} + +/// Trunk-road topology permitted by the region morphology (D-234a). +enum Topology { + /// Linear spine along the terrain axis (fjord/canyon/mountain-pass). + Ribbon, + /// Star from a central hub (delta/island/enclosed water). + HubSpoke, + /// Least-cost mesh — any pattern (plains/meander/coastal-lowland). + Mesh, +} + +fn street_topology(m: &MorphologyZone) -> Topology { + match m { + MorphologyZone::Fjord | MorphologyZone::Canyon | MorphologyZone::MountainPass => { + Topology::Ribbon + } + MorphologyZone::Delta + | MorphologyZone::OpenOcean + | MorphologyZone::Lake + | MorphologyZone::Sea + | MorphologyZone::Island => Topology::HubSpoke, + MorphologyZone::AlluvialPlain + | MorphologyZone::MeanderReach + | MorphologyZone::CoastalLowland + | MorphologyZone::Unknown => Topology::Mesh, + } +} + +/// Chebyshev distance (diagonal-friendly, matches the ±45° path metric). +fn node_dist(a: (u16, u16), b: (u16, u16)) -> i32 { + let dx = (a.0 as i32 - b.0 as i32).abs(); + let dy = (a.1 as i32 - b.1 as i32).abs(); + dx.max(dy) +} + +/// A ±45°-snapped polyline from `a` to `b`: a 45° diagonal run, then an +/// axis-aligned run (D-096 ±45° cap — only 0°/45°/90° segments). +fn snap45_path(a: (u16, u16), b: (u16, u16)) -> Vec<(u16, u16)> { + let (ax, ay) = (a.0 as i32, a.1 as i32); + let (bx, by) = (b.0 as i32, b.1 as i32); + let diag = (bx - ax).abs().min((by - ay).abs()); + let corner = ( + (ax + (bx - ax).signum() * diag) as u16, + (ay + (by - ay).signum() * diag) as u16, + ); + if corner == a || corner == b { + vec![a, b] + } else { + vec![a, corner, b] + } +} + +fn spine(from: usize, to: usize, aps: &[AccessPoint]) -> CorridorSpine { + CorridorSpine { + from: from as u16, + to: to as u16, + path: snap45_path(aps[from].position, aps[to].position), + } +} + +/// Prim's minimum-spanning tree over node positions (O(n²); n is small). +fn prim_mst(aps: &[AccessPoint]) -> Vec<(usize, usize)> { + let n = aps.len(); + let mut in_tree = vec![false; n]; + let mut edges = Vec::new(); + in_tree[0] = true; + for _ in 1..n { + let mut best: Option<(usize, usize, i32)> = None; + for i in 0..n { + if !in_tree[i] { + continue; + } + for j in 0..n { + if in_tree[j] { + continue; + } + let d = node_dist(aps[i].position, aps[j].position); + if best.is_none_or(|(_, _, bd)| d < bd) { + best = Some((i, j, d)); + } + } + } + if let Some((i, j, _)) = best { + in_tree[j] = true; + edges.push((i, j)); + } + } + edges +} + +/// Node closest to the quarter centre — the hub for hub-and-spoke topology. +fn central_node(aps: &[AccessPoint]) -> usize { + let c = (QUARTER_TILES / 2, QUARTER_TILES / 2); + (0..aps.len()) + .min_by_key(|&i| node_dist(aps[i].position, c)) + .unwrap_or(0) +} + +/// Derive the quarter's arterial corridors over its access points, with topology +/// gated by morphology (D-234a). +fn derive_corridors( + access_points: &[AccessPoint], + morphology: &MorphologyZone, +) -> Vec { + let n = access_points.len(); + if n < 2 { + return Vec::new(); + } + match street_topology(morphology) { + Topology::HubSpoke => { + let hub = central_node(access_points); + (0..n) + .filter(|&i| i != hub) + .map(|i| spine(hub, i, access_points)) + .collect() + } + Topology::Ribbon => { + // Connect consecutive nodes ordered along the dominant terrain axis. + let mut order: Vec = (0..n).collect(); + order.sort_by_key(|&i| { + access_points[i].position.0 as i32 + access_points[i].position.1 as i32 + }); + order + .windows(2) + .map(|w| spine(w[0], w[1], access_points)) + .collect() + } + Topology::Mesh => prim_mst(access_points) + .into_iter() + .map(|(i, j)| spine(i, j, access_points)) + .collect(), + } +} + +/// Apply the D-096 layout mode to each block's local lattice (D-234): organic +/// settlements jitter the lattice offset + rotation from their `BlockPlacement`; +/// grid settlements keep the axis-aligned default. +fn apply_layout_to_chunks(blocks: &mut [[BlockSkeleton; 4]; 4], layout: &DistrictLayoutMode) { + if let DistrictLayoutMode::Organic { placements } = layout { + for r in 0..4 { + for c in 0..4 { + let p = &placements[r][c]; + blocks[r][c].chunk_layout.offset = ( + p.offset.0.unsigned_abs().min(255) as u8, + p.offset.1.unsigned_abs().min(255) as u8, + ); + blocks[r][c].chunk_layout.rotation_steps = p.rotation_steps; + } + } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1363,4 +1601,108 @@ mod tests { let b = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5)); assert_eq!(a, b); } + + // ── 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 hub_spoke_shares_a_common_node() { + let aps = derive_access_points(&[0, 2, 4, 6], &[]); + let corridors = derive_corridors(&aps, &MorphologyZone::Island); + // 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 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"); + } + } } diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 96aab313e..c12381ed6 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -50,10 +50,8 @@ pub type Era = String; pub type EraModification = String; /// Landmark slot description within a block. Stub. pub type LandmarkSlot = String; -/// Chunk layout specification within a block (how the 2×2 chunks are arranged). Stub. -pub type ChunkLayout = String; -/// Corridor spine connecting district access points. Stub. -pub type CorridorSpine = String; +// `ChunkLayout`, `CorridorSpine`, `AccessPoint` are typed street-network structs +// (D-234, #957) — see the "Street-network types" section below. /// District context — neighboring districts, world position, system. Stub. pub type QuarterContext = String; /// Society profile reference (Miri's cultural ingredients). Stub. @@ -64,8 +62,56 @@ pub type ZoneDefinition = String; pub type QuarterBoundaries = String; /// Guarantee audit result — tier-appropriate spatial invariant checks. Stub. pub type GuaranteeAuditResult = String; -/// District access point (entry/exit to neighboring district). Stub. -pub type AccessPoint = String; +// --------------------------------------------------------------------------- +// Street-network types (D-234, #957) +// --------------------------------------------------------------------------- + +/// Where a quarter's street network connects to the outside, to a reservation, +/// or to an interior junction (D-234). These are the nodes the arterial graph +/// spans and the guarantee audit (D-097) reads. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct AccessPoint { + /// Tile-space position within the 512-tile quarter (0–511). + pub position: (u16, u16), + pub kind: AccessKind, +} + +/// Kind of [`AccessPoint`] (D-234). +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum AccessKind { + /// Road enters the quarter from outside at a compass octant (0=N…7=NW), + /// from `CityGenerationContext.road_entry_directions`. + QuarterEdge { octant: u8 }, + /// Gate into a multi-block reservation. + ReservationGate { reservation: ReservationId }, + /// Internal junction between blocks. + BlockJunction, +} + +/// One arterial spine — a ±45°-snapped edge of the quarter's trunk-road graph +/// (D-234 refined: arterials = least-cost graph over the access nodes). `from`/`to` +/// index into the quarter's `access_points`; `path` is the tile-space polyline +/// (segments axis- or 45°-aligned, D-096 ±45° cap). This node/edge graph is what +/// the guarantee audit (D-097) reads as the trunk topology. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct CorridorSpine { + pub from: u16, + pub to: u16, + pub path: Vec<(u16, u16)>, +} + +/// Local street lattice descriptor for one block (D-234 refined: local streets = +/// a ±45° grid lattice within each block, modulated by the D-096 layout mode). +/// A compact parametric form the fill layer rasterizes — not the raster itself. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] +pub struct ChunkLayout { + /// Street spacing in tiles (the block is divided into spacing-sized cells). + pub spacing: u8, + /// Lattice origin offset in tiles (organic jitter; `(0,0)` for grid). + pub offset: (u8, u8), + /// Rotation in 15° steps (0–3 ⇒ 0–45°, the D-096 cap; 0 for grid). + pub rotation_steps: u8, +} /// Base visual palette for a zone. Stub. pub type BasePalette = String; /// Economic modifier on a zone palette. Stub.