diff --git a/crates/citygen/src/generate/mod.rs b/crates/citygen/src/generate/mod.rs index da0df04..39aa784 100644 --- a/crates/citygen/src/generate/mod.rs +++ b/crates/citygen/src/generate/mod.rs @@ -7,6 +7,7 @@ pub mod dressing; pub mod interiors; pub mod lots; pub mod naming; +pub mod net; pub mod streets; pub mod terrain; pub mod verticals; @@ -91,9 +92,7 @@ pub(crate) trait StageRunner { fn step(&mut self, ctx: &mut GenCtx) -> Result; } -// Scratch types are populated by stages that land in later tasks; the allows go when they do. /// A rectangular block between streets, owned by one district. -#[allow(dead_code)] #[derive(Clone, Copy, Debug)] pub(crate) struct Block { pub rect: Rect, @@ -121,7 +120,6 @@ pub(crate) struct Lot { } /// Intermediate state shared between stages; never serialized. -#[allow(dead_code)] pub(crate) struct Scratch { pub land: Grid, pub wealth: Grid, @@ -151,7 +149,6 @@ impl Scratch { } /// Everything a stage can see and mutate. -#[allow(dead_code)] pub(crate) struct GenCtx { pub city: City, pub flavor: FlavorPack, @@ -159,7 +156,6 @@ pub(crate) struct GenCtx { pub scratch: Scratch, } -#[allow(dead_code)] impl GenCtx { pub fn rng(&self, stage: Stage) -> ChaCha8Rng { stage_rng(self.seed, stage.index()) @@ -215,18 +211,6 @@ impl GenCtx { } } -/// A stage that does nothing; placeholder until the real stage lands. -struct NoopStage; - -impl StageRunner for NoopStage { - fn begin(&mut self, _ctx: &mut GenCtx) -> Result<(), GenError> { - Ok(()) - } - fn step(&mut self, _ctx: &mut GenCtx) -> Result { - Ok(StepOut::Finished) - } -} - pub(crate) fn runner_for(stage: Stage) -> Box { match stage { Stage::Terrain => Box::new(terrain::TerrainStage::default()), @@ -239,7 +223,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box { Stage::Connectivity => Box::new(connectivity::ConnectivityStage::default()), Stage::Dressing => Box::new(dressing::DressingStage::default()), Stage::Naming => Box::new(naming::NamingStage::default()), - _ => Box::new(NoopStage), + Stage::Net => Box::new(net::NetStage::default()), } } diff --git a/crates/citygen/src/generate/net.rs b/crates/citygen/src/generate/net.rs new file mode 100644 index 0000000..ff69e0d --- /dev/null +++ b/crates/citygen/src/generate/net.rs @@ -0,0 +1,353 @@ +//! Stage 11: the Net graph — subnets, nodes anchored to physical tiles, links. + +use super::{GenCtx, StageRunner, StepOut}; +use crate::geom::Pos; +use crate::ids::{BuildingId, DistrictId, NodeId, SubnetId}; +use crate::net::{Link, Node, NodeKind, Subnet, SubnetOwner}; +use crate::params::GenError; +use crate::tile::{Feature, Terrain}; +use crate::world::{BuildingKind, StratumKind, Theme}; +use std::collections::VecDeque; + +enum Work { + Building(usize), + District(usize), + Core, +} + +#[derive(Default)] +pub(crate) struct NetStage { + queue: VecDeque, + total: usize, + district_subnet: Vec, + city_subnet: Option, + /// Server node per building, by building index. + servers: Vec>, + backbones: Vec>, +} + +const ICE_THRESHOLD: u8 = 160; +const CORE_SECURITY: u8 = 230; + +impl NetStage { + fn add_subnet(ctx: &mut GenCtx, owner: SubnetOwner, security: u8) -> SubnetId { + let id = SubnetId(ctx.city.net.subnets.len() as u32); + ctx.city.net.subnets.push(Subnet { + id, + owner, + security, + }); + id + } + + fn add_node( + ctx: &mut GenCtx, + kind: NodeKind, + anchor: (StratumKind, Pos), + subnet: SubnetId, + building: Option, + ) -> NodeId { + let id = NodeId(ctx.city.net.nodes.len() as u32); + ctx.city.net.nodes.push(Node { + id, + kind, + anchor, + subnet, + building, + }); + id + } + + fn link(ctx: &mut GenCtx, a: NodeId, b: NodeId) { + if a != b { + ctx.city.net.links.push(Link(a, b)); + } + } + + fn building(&mut self, ctx: &mut GenCtx, i: usize) { + let b = ctx.city.buildings[i].clone(); + let district = ctx.city.district(b.district).clone(); + let base = ctx + .city + .net + .subnet(self.district_subnet[b.district.index()]) + .security as f32; + let security = + (base + district.wealth * 40.0 - district.decay * 20.0).clamp(0.0, 255.0) as u8; + let subnet = Self::add_subnet(ctx, SubnetOwner::Building(b.id), security); + let k = StratumKind::Street; + let fp = b.footprint; + + let lobby = ctx + .scratch + .lobbies + .iter() + .find(|(id, _)| *id == b.id) + .map(|(_, p)| *p) + .unwrap_or(fp.center()); + let mut floors: Vec = fp + .inset(1) + .iter() + .filter(|p| ctx.terrain(k, *p) == Terrain::Floor) + .collect(); + floors.sort_by_key(|p| std::cmp::Reverse(p.dist2(lobby))); + let server_pos = floors + .first() + .copied() + .or_else(|| fp.inset(1).iter().find(|p| ctx.city.walkable(k, *p))) + .unwrap_or(fp.center()); + let server = Self::add_node(ctx, NodeKind::Server, (k, server_pos), subnet, Some(b.id)); + self.servers[i] = Some(server); + + if matches!(b.kind, BuildingKind::CorpTower | BuildingKind::Clinic) { + let vault_pos = floors.get(1).copied().unwrap_or(server_pos); + let vault = + Self::add_node(ctx, NodeKind::DataVault, (k, vault_pos), subnet, Some(b.id)); + Self::link(ctx, vault, server); + } + if security >= ICE_THRESHOLD { + let ice = Self::add_node(ctx, NodeKind::Ice, (k, server_pos), subnet, Some(b.id)); + Self::link(ctx, ice, server); + } + + let mut peripherals: Vec<(NodeKind, Pos)> = Vec::new(); + for p in fp.iter() { + let Some(t) = ctx.tile(k, p) else { continue }; + let on_border = fp.border().any(|q| q == p); + match (t.terrain, t.feature) { + (_, Some(Feature::Terminal)) => peripherals.push((NodeKind::Terminal, p)), + (_, Some(Feature::Camera)) => peripherals.push((NodeKind::Camera, p)), + (_, Some(Feature::HoloAd)) => peripherals.push((NodeKind::AdBoard, p)), + (Terrain::Door, _) if on_border => peripherals.push((NodeKind::DoorLock, p)), + (Terrain::Lift, _) => peripherals.push((NodeKind::LiftCtrl, p)), + _ => {} + } + } + for (kind, p) in peripherals { + let n = Self::add_node(ctx, kind, (k, p), subnet, Some(b.id)); + Self::link(ctx, n, server); + } + } + + fn district(&mut self, ctx: &mut GenCtx, d: usize) { + let district = ctx.city.districts[d].clone(); + let subnet = self.district_subnet[d]; + let k = StratumKind::Street; + let in_district = |ctx: &GenCtx, p: Pos| ctx.in_district(p, district.id); + + // Street-side terminals (outside buildings) and the first lamp. + let mut street_terminals = Vec::new(); + let mut first_lamp = None; + for p in district.bounds.iter() { + if !in_district(ctx, p) { + continue; + } + let Some(t) = ctx.tile(k, p) else { continue }; + if t.building.is_none() && t.feature == Some(Feature::Terminal) { + street_terminals.push(p); + } + if first_lamp.is_none() && t.feature == Some(Feature::Lamp) { + first_lamp = Some(p); + } + } + + let backbone_anchor = street_terminals + .first() + .copied() + .or_else(|| { + ctx.city + .buildings + .iter() + .enumerate() + .find(|(_, b)| b.district == district.id) + .and_then(|(i, _)| self.servers[i]) + .map(|n| ctx.city.net.node(n).anchor.1) + }) + .unwrap_or(district.center); + let backbone = Self::add_node(ctx, NodeKind::Backbone, (k, backbone_anchor), subnet, None); + self.backbones[d] = Some(backbone); + + if let Some(p) = first_lamp { + let grid = Self::add_node(ctx, NodeKind::LampGrid, (k, p), subnet, None); + Self::link(ctx, grid, backbone); + } + for p in street_terminals { + if p == backbone_anchor { + continue; + } + let n = Self::add_node(ctx, NodeKind::Terminal, (k, p), subnet, None); + Self::link(ctx, n, backbone); + } + for i in 0..ctx.city.buildings.len() { + if ctx.city.buildings[i].district == district.id + && let Some(server) = self.servers[i] + { + Self::link(ctx, server, backbone); + } + } + } + + fn core(&mut self, ctx: &mut GenCtx) { + let subnet = Self::add_subnet(ctx, SubnetOwner::City, CORE_SECURITY); + self.city_subnet = Some(subnet); + let k = StratumKind::Street; + let host = ctx + .city + .buildings + .iter() + .enumerate() + .filter(|(_, b)| ctx.city.district(b.district).theme == Theme::CorpCore) + .max_by_key(|(_, b)| b.footprint.area()) + .or_else(|| { + ctx.city + .buildings + .iter() + .enumerate() + .max_by_key(|(_, b)| b.footprint.area()) + }) + .map(|(i, b)| (i, b.footprint)); + let Some((host_idx, fp)) = host else { return }; + let anchor = fp + .inset(1) + .iter() + .filter(|p| ctx.terrain(k, *p) == Terrain::Floor) + .min_by_key(|p| p.dist2(fp.center())) + .unwrap_or(fp.center()); + let building = Some(ctx.city.buildings[host_idx].id); + let server = Self::add_node(ctx, NodeKind::Server, (k, anchor), subnet, building); + let ice = Self::add_node(ctx, NodeKind::Ice, (k, anchor), subnet, building); + Self::link(ctx, ice, server); + for d in 0..self.backbones.len() { + if let Some(b) = self.backbones[d] { + Self::link(ctx, b, server); + } + } + } +} + +impl StageRunner for NetStage { + fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> { + ctx.city.net = Default::default(); + self.district_subnet.clear(); + for d in 0..ctx.city.districts.len() { + let district = &ctx.city.districts[d]; + let security = (district.theme.spec().security as f32 + district.wealth * 30.0) + .clamp(0.0, 255.0) as u8; + let id = DistrictId(d as u32); + let sn = Self::add_subnet(ctx, SubnetOwner::District(id), security); + self.district_subnet.push(sn); + } + self.servers = vec![None; ctx.city.buildings.len()]; + self.backbones = vec![None; ctx.city.districts.len()]; + self.queue.clear(); + self.queue + .extend((0..ctx.city.buildings.len()).map(Work::Building)); + self.queue + .extend((0..ctx.city.districts.len()).map(Work::District)); + self.queue.push_back(Work::Core); + self.total = self.queue.len().max(1); + Ok(()) + } + + fn step(&mut self, ctx: &mut GenCtx) -> Result { + let Some(work) = self.queue.pop_front() else { + return Ok(StepOut::Finished); + }; + match work { + Work::Building(i) => self.building(ctx, i), + Work::District(d) => self.district(ctx, d), + Work::Core => self.core(ctx), + } + let done = self.total - self.queue.len(); + Ok(StepOut::Continue(done as f32 / self.total as f32)) + } +} + +#[cfg(test)] +mod tests { + use crate::generate::testutil::small; + use crate::net::{Link, NodeKind, SubnetOwner}; + use crate::params::WaterMode; + use crate::tile::{Feature, Terrain}; + + #[test] + fn net_graph_is_consistent() { + let c = small(101, WaterMode::Bay, 7); + assert_eq!( + c.net + .subnets + .iter() + .filter(|s| matches!(s.owner, SubnetOwner::Building(_))) + .count(), + c.buildings.len() + ); + assert_eq!( + c.net + .subnets + .iter() + .filter(|s| matches!(s.owner, SubnetOwner::District(_))) + .count(), + c.districts.len() + ); + assert_eq!( + c.net + .subnets + .iter() + .filter(|s| s.owner == SubnetOwner::City) + .count(), + 1 + ); + assert!(!c.net.nodes.is_empty()); + for (i, n) in c.net.nodes.iter().enumerate() { + assert_eq!(n.id.index(), i); + let t = c.tile(n.anchor.0, n.anchor.1).expect("anchor in bounds"); + match n.kind { + NodeKind::Terminal => assert_eq!(t.feature, Some(Feature::Terminal)), + NodeKind::Camera => assert_eq!(t.feature, Some(Feature::Camera)), + NodeKind::DoorLock => assert_eq!(t.terrain, Terrain::Door), + NodeKind::LiftCtrl => assert_eq!(t.terrain, Terrain::Lift), + NodeKind::AdBoard => assert_eq!(t.feature, Some(Feature::HoloAd)), + NodeKind::Server | NodeKind::DataVault | NodeKind::Ice => { + assert!(matches!( + t.terrain, + Terrain::Floor | Terrain::Lift | Terrain::Stair + )) + } + NodeKind::LampGrid => assert_eq!(t.feature, Some(Feature::Lamp)), + NodeKind::Backbone => { + assert!(t.feature == Some(Feature::Terminal) || t.terrain == Terrain::Floor) + } + } + assert!(c.net.subnets.iter().any(|s| s.id == n.subnet)); + } + for Link(a, b) in &c.net.links { + assert!(a.index() < c.net.nodes.len() && b.index() < c.net.nodes.len()); + } + let backbones: Vec<_> = c + .net + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Backbone) + .map(|n| n.id) + .collect(); + assert_eq!(backbones.len(), c.districts.len()); + for s in c + .net + .nodes + .iter() + .filter(|n| n.kind == NodeKind::Server && n.building.is_some()) + { + assert!( + c.net + .links + .iter() + .any(|Link(a, b)| (*a == s.id && backbones.contains(b)) + || (*b == s.id && backbones.contains(a))), + "{:?} not linked to a backbone", + s.id + ); + } + assert!(c.net.nodes.iter().any(|n| n.kind == NodeKind::Ice)); + assert!(c.net.nodes.iter().any(|n| n.kind == NodeKind::DoorLock)); + } +}