feat(citygen): net graph stage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:15:13 +02:00
parent 90b626e034
commit b781e60d73
2 changed files with 355 additions and 18 deletions

View File

@@ -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<StepOut, GenError>;
}
// 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<bool>,
pub wealth: Grid<f32>,
@@ -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<StepOut, GenError> {
Ok(StepOut::Finished)
}
}
pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner> {
match stage {
Stage::Terrain => Box::new(terrain::TerrainStage::default()),
@@ -239,7 +223,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner> {
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()),
}
}

View File

@@ -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<Work>,
total: usize,
district_subnet: Vec<SubnetId>,
city_subnet: Option<SubnetId>,
/// Server node per building, by building index.
servers: Vec<Option<NodeId>>,
backbones: Vec<Option<NodeId>>,
}
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<BuildingId>,
) -> 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<Pos> = 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<StepOut, GenError> {
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));
}
}