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 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:21:08 +02:00
parent b781e60d73
commit 9ac713bbe9
5 changed files with 373 additions and 4 deletions

View File

@@ -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(

View File

@@ -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