From 9ac713bbe9a27a61ac9636fa7269f5f7bba28c09 Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 17:21:08 +0200 Subject: [PATCH] feat(citygen): City::validate and seed sweep tests Also guarantees one building per district and repairs interiors split by crossing partitions, both found by the new validation. Co-Authored-By: Claude Fable 5.1 --- crates/citygen/Cargo.toml | 3 + crates/citygen/src/generate/interiors.rs | 41 ++++ crates/citygen/src/generate/lots.rs | 4 +- crates/citygen/src/validate.rs | 265 ++++++++++++++++++++++- crates/citygen/tests/seeds.rs | 64 ++++++ 5 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 crates/citygen/tests/seeds.rs diff --git a/crates/citygen/Cargo.toml b/crates/citygen/Cargo.toml index 1ea5c0a..3eba25f 100644 --- a/crates/citygen/Cargo.toml +++ b/crates/citygen/Cargo.toml @@ -11,3 +11,6 @@ rand_chacha.workspace = true noise.workspace = true thiserror.workspace = true ron.workspace = true + +[dev-dependencies] +postcard.workspace = true diff --git a/crates/citygen/src/generate/interiors.rs b/crates/citygen/src/generate/interiors.rs index 32186cc..4af9ac6 100644 --- a/crates/citygen/src/generate/interiors.rs +++ b/crates/citygen/src/generate/interiors.rs @@ -3,6 +3,7 @@ use super::{GenCtx, Stage, StageRunner, StepOut}; use crate::geom::{Pos, Rect}; use crate::params::GenError; +use crate::pathing::{bfs_nearest, components}; use crate::tile::Terrain; use crate::world::{Building, Connector, ConnectorKind, StratumKind}; use rand::Rng; @@ -105,6 +106,45 @@ impl InteriorsStage { } } + /// Guarantees every walkable tile inside the footprint reaches every other by carving + /// doors through inner walls (never the perimeter). + fn ensure_connected(ctx: &mut GenCtx, k: StratumKind, fp: Rect) { + let size = ctx.size(); + let inner = fp.inset(1); + for _ in 0..8 { + let comps = components(size, |p| fp.contains(p) && ctx.city.walkable(k, p)); + if comps.len() <= 1 { + return; + } + let main = &comps[0]; + let mut in_main = vec![false; size.area()]; + for p in main { + in_main[size.index(*p)] = true; + } + let start = comps[1][0]; + let path = bfs_nearest( + size, + start, + |p| { + inner.contains(p) + && (ctx.city.walkable(k, p) || ctx.terrain(k, p) == Terrain::Wall) + }, + |p| in_main[size.index(p)], + 16, + ); + match path { + Some(path) => { + for p in path { + if ctx.terrain(k, p) == Terrain::Wall && inner.contains(p) { + ctx.set_terrain(k, p, Terrain::Door); + } + } + } + None => return, + } + } + } + /// Opens a door on a border wall facing `outside_ok` terrain; returns the lobby tile. fn exterior_door( ctx: &mut GenCtx, @@ -228,6 +268,7 @@ impl InteriorsStage { continue; } Self::partition(ctx, rng, k, fp.inset(1), 4); + Self::ensure_connected(ctx, k, fp); match k { StratumKind::Street => { let lobby = Self::exterior_door( diff --git a/crates/citygen/src/generate/lots.rs b/crates/citygen/src/generate/lots.rs index 72023ac..247f9b1 100644 --- a/crates/citygen/src/generate/lots.rs +++ b/crates/citygen/src/generate/lots.rs @@ -103,8 +103,10 @@ impl LotsStage { }); continue; } + // A district's first lot is always a building, so no district ends up empty. + let has_building = ctx.city.buildings.iter().any(|b| b.district == district.id); let plaza_p = spec.plaza * (1.3 - density); - if chance(rng, plaza_p) { + if has_building && chance(rng, plaza_p) { let parking = matches!( district.theme, crate::world::Theme::CorpCore | crate::world::Theme::Industrial diff --git a/crates/citygen/src/validate.rs b/crates/citygen/src/validate.rs index 3f70a45..ad67991 100644 --- a/crates/citygen/src/validate.rs +++ b/crates/citygen/src/validate.rs @@ -1,14 +1,273 @@ //! City invariants. -use crate::world::City; +use crate::generate::connectivity::reachable_from_street; +use crate::geom::Pos; +use crate::ids::{BuildingId, DistrictId, NodeId}; +use crate::net::NodeKind; +use crate::tile::{Feature, Terrain}; +use crate::world::{City, StratumKind}; +use std::fmt; + +/// Share of walkable tiles per stratum that must be reachable from the street. +pub const REACH_TARGET: f32 = 0.95; /// A broken invariant found by [`City::validate`]. #[derive(Clone, Debug, PartialEq)] -pub struct Violation(pub String); +pub enum Violation { + ConnectorNotWalkable { + pos: Pos, + stratum: StratumKind, + }, + BuildingWithoutDoor(BuildingId), + StratumUnreachable { + stratum: StratumKind, + reached: usize, + walkable: usize, + }, + DanglingId { + pos: Pos, + stratum: StratumKind, + }, + FootprintOverlap(BuildingId, BuildingId), + NetAnchor(NodeId), + DistrictEmpty(DistrictId), +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Violation::ConnectorNotWalkable { pos, stratum } => { + write!(f, "connector at {pos:?} is not walkable on {stratum:?}") + } + Violation::BuildingWithoutDoor(b) => write!(f, "building {b:?} has no street door"), + Violation::StratumUnreachable { + stratum, + reached, + walkable, + } => write!( + f, + "{stratum:?}: only {reached}/{walkable} walkable tiles reachable from the street" + ), + Violation::DanglingId { pos, stratum } => { + write!( + f, + "tile {pos:?} on {stratum:?} references a missing district or building" + ) + } + Violation::FootprintOverlap(a, b) => write!(f, "buildings {a:?} and {b:?} overlap"), + Violation::NetAnchor(n) => write!(f, "net node {n:?} is anchored to the wrong tile"), + Violation::DistrictEmpty(d) => { + write!(f, "district {d:?} lacks a building or a public connector") + } + } + } +} impl City { /// Checks the invariants every finished city must satisfy. pub fn validate(&self) -> Result<(), Vec> { - Ok(()) + let mut out = Vec::new(); + + for c in &self.connectors { + for k in &c.serves { + if *k != StratumKind::Net && !self.walkable(*k, c.pos) { + out.push(Violation::ConnectorNotWalkable { + pos: c.pos, + stratum: *k, + }); + } + } + } + + for b in &self.buildings { + let has_door = b.footprint.border().any(|p| { + self.tile(StratumKind::Street, p) + .is_some_and(|t| t.terrain == Terrain::Door) + }); + if !has_door { + out.push(Violation::BuildingWithoutDoor(b.id)); + } + } + + let reach = reachable_from_street(self); + for k in StratumKind::PHYSICAL { + let Some(st) = self.stratum(k) else { continue }; + let walkable = st.tiles.count(|t| t.is_walkable()); + if walkable == 0 { + continue; + } + let reached = reach[k.index()].count(|&b| b); + if (reached as f32) < REACH_TARGET * walkable as f32 { + out.push(Violation::StratumUnreachable { + stratum: k, + reached, + walkable, + }); + } + } + + for st in &self.strata { + for (p, t) in st.tiles.iter() { + let bad_d = t + .district + .is_some_and(|d| d.index() >= self.districts.len()); + let bad_b = t + .building + .is_some_and(|b| b.index() >= self.buildings.len()); + if bad_d || bad_b { + out.push(Violation::DanglingId { + pos: p, + stratum: st.kind, + }); + } + } + } + + for (i, a) in self.buildings.iter().enumerate() { + for b in &self.buildings[i + 1..] { + if a.footprint.intersects(b.footprint) { + out.push(Violation::FootprintOverlap(a.id, b.id)); + } + } + } + + for n in &self.net.nodes { + let ok = match self.tile(n.anchor.0, n.anchor.1) { + None => false, + Some(t) => match n.kind { + NodeKind::Terminal => t.feature == Some(Feature::Terminal), + NodeKind::Camera => t.feature == Some(Feature::Camera), + NodeKind::AdBoard => t.feature == Some(Feature::HoloAd), + NodeKind::LampGrid => t.feature == Some(Feature::Lamp), + NodeKind::DoorLock => t.terrain == Terrain::Door, + NodeKind::LiftCtrl => t.terrain == Terrain::Lift, + NodeKind::Server | NodeKind::DataVault | NodeKind::Ice => { + matches!(t.terrain, Terrain::Floor | Terrain::Lift | Terrain::Stair) + } + NodeKind::Backbone => true, + }, + }; + if !ok || n.subnet.index() >= self.net.subnets.len() { + out.push(Violation::NetAnchor(n.id)); + } + } + + for d in &self.districts { + let has_building = self.buildings.iter().any(|b| b.district == d.id); + let has_public = self.connectors.iter().any(|c| { + c.public + && self + .tile(StratumKind::Street, c.pos) + .is_some_and(|t| t.district == Some(d.id)) + }); + if !has_building || !has_public { + out.push(Violation::DistrictEmpty(d.id)); + } + } + + if out.is_empty() { Ok(()) } else { Err(out) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geom::{Pos, Rect}; + use crate::ids::{BuildingId, DistrictId}; + use crate::params::{GenParams, SizePreset}; + use crate::tile::{Terrain, Tile}; + use crate::world::{ + Building, BuildingKind, Connector, ConnectorKind, District, HeightClass, StrataSpan, + StratumKind, Theme, + }; + + fn bare_city() -> City { + let mut c = City::empty(GenParams { + size: SizePreset::Custom { w: 80, h: 40 }, + ..Default::default() + }); + c.districts.push(District { + id: DistrictId(0), + name: "x".into(), + theme: Theme::OldTown, + center: Pos::new(10, 10), + wealth: 0.5, + decay: 0.5, + bounds: Rect::new(0, 0, 80, 40), + }); + let st = c.stratum_mut(StratumKind::Street).unwrap(); + st.tiles + .fill_rect(Rect::new(0, 0, 80, 40), Tile::new(Terrain::Sidewalk)); + c + } + + #[test] + fn detects_building_without_door_and_blocked_connector() { + let mut c = bare_city(); + let fp = Rect::new(5, 5, 6, 5); + let st = c.stratum_mut(StratumKind::Street).unwrap(); + for p in fp.border() { + st.tiles.set(p, Tile::new(Terrain::Wall)); + } + c.buildings.push(Building { + id: BuildingId(0), + kind: BuildingKind::Shop, + district: DistrictId(0), + footprint: fp, + span: StrataSpan::from_class(HeightClass::Low, false), + name: None, + }); + c.connectors.push(Connector { + kind: ConnectorKind::Manhole, + pos: Pos::new(20, 20), + serves: vec![StratumKind::Undercity, StratumKind::Street], + public: true, + }); + let errs = c.validate().unwrap_err(); + assert!( + errs.iter() + .any(|v| matches!(v, Violation::BuildingWithoutDoor(BuildingId(0)))) + ); + assert!(errs.iter().any(|v| matches!( + v, + Violation::ConnectorNotWalkable { + stratum: StratumKind::Undercity, + .. + } + ))); + } + + #[test] + fn detects_overlap_and_empty_district() { + let mut c = bare_city(); + for (i, fp) in [Rect::new(5, 5, 6, 5), Rect::new(8, 7, 6, 5)] + .iter() + .enumerate() + { + let st = c.stratum_mut(StratumKind::Street).unwrap(); + for p in fp.border() { + st.tiles.set(p, Tile::new(Terrain::Wall)); + } + st.tiles + .set(Pos::new(fp.x + 2, fp.y), Tile::new(Terrain::Door)); + c.buildings.push(Building { + id: BuildingId(i as u32), + kind: BuildingKind::Shop, + district: DistrictId(0), + footprint: *fp, + span: StrataSpan::from_class(HeightClass::Low, false), + name: None, + }); + } + let errs = c.validate().unwrap_err(); + assert!( + errs.iter() + .any(|v| matches!(v, Violation::FootprintOverlap(..))) + ); + assert!( + errs.iter() + .any(|v| matches!(v, Violation::DistrictEmpty(DistrictId(0)))) + ); + assert!(format!("{}", errs[0]).len() > 5); } } diff --git a/crates/citygen/tests/seeds.rs b/crates/citygen/tests/seeds.rs new file mode 100644 index 0000000..35fc73b --- /dev/null +++ b/crates/citygen/tests/seeds.rs @@ -0,0 +1,64 @@ +//! Determinism and invariant sweeps over many seeds. + +use citygen::*; + +fn sweep(preset: SizePreset, seeds: std::ops::Range) { + for seed in seeds { + let p = GenParams { + seed, + size: preset, + water: match seed % 3 { + 0 => WaterMode::Bay, + 1 => WaterMode::River, + _ => WaterMode::None, + }, + districts: 4 + (seed % 9) as u8, + ..Default::default() + }; + if p.validate().is_err() { + continue; + } + let a = Generator::run(p.clone()).unwrap_or_else(|e| panic!("seed {seed}: {e}")); + let b = Generator::run(p).unwrap(); + assert_eq!( + postcard::to_allocvec(&a).unwrap(), + postcard::to_allocvec(&b).unwrap(), + "seed {seed} not deterministic" + ); + a.validate() + .unwrap_or_else(|v| panic!("seed {seed}: {v:?}")); + } +} + +#[test] +fn small_seeds() { + sweep(SizePreset::Small, 0..50); +} + +#[test] +fn medium_seeds() { + sweep(SizePreset::Medium, 1000..1010); +} + +#[test] +fn large_seeds() { + sweep(SizePreset::Large, 2000..2003); +} + +#[test] +#[ignore] +fn extended_seeds() { + sweep(SizePreset::Small, 100..250); + sweep(SizePreset::Medium, 3000..3050); +} + +#[test] +fn medium_generates_under_two_seconds() { + let t = std::time::Instant::now(); + Generator::run(GenParams { + seed: 77, + ..Default::default() + }) + .unwrap(); + assert!(t.elapsed().as_secs_f32() < 2.0, "took {:?}", t.elapsed()); +}