fix(citygen): robustness found by the extended seed sweep

- arterials merge onto existing roads and keep distance otherwise
- isolated strata components get a drop-in public connector
- blockless districts fall back to the largest clean rectangle
- sliver districts merge into neighbours (district count is a maximum)
- dressing removes blocking features that cut tiles off the street

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:30:54 +02:00
parent 9ac713bbe9
commit 0d459e7058
5 changed files with 199 additions and 9 deletions

View File

@@ -62,17 +62,24 @@ fn plan_edges(centers: &[Pos], extra: usize) -> Vec<(Pos, Pos)> {
}
impl ArterialsStage {
/// Routes one arterial. Existing arterial tiles are cheap so roads merge instead of
/// running in parallel; tiles beside an arterial are dear so new roads keep their
/// distance; water is crossable at bridge cost.
fn route(&self, ctx: &GenCtx, a: Pos, b: Pos) -> Option<Vec<Pos>> {
let land = &ctx.scratch.land;
let arterial = &ctx.scratch.arterial;
astar(ctx.size(), a, b, |_, to| {
let size = ctx.size();
astar(size, a, b, |_, to| {
if !land[to] {
Some(40)
} else if arterial[to] {
Some(10)
} else {
Some(11)
return Some(40);
}
if arterial[to] {
return Some(2);
}
let near = Rect::new(to.x - 3, to.y - 3, 7, 7)
.iter()
.any(|q| size.contains(q) && arterial[q]);
Some(if near { 16 } else { 10 })
})
}

View File

@@ -188,6 +188,12 @@ fn repair_stratum(city: &mut City, k: StratumKind, reach: &Grid<bool>) {
}
}
None => {
if drop_in_connector(city, k, &comp) {
for p in &comp {
reached.set(*p, true);
}
continue;
}
let in_building = comp
.iter()
.any(|p| city.tile(k, *p).is_some_and(|t| t.building.is_some()));
@@ -204,6 +210,49 @@ fn repair_stratum(city: &mut City, k: StratumKind, reach: &Grid<bool>) {
}
}
/// Gives an isolated component its own public connector from the street directly below
/// (or, for rooftops, a ladder from a walkway directly below). Returns true on success.
fn drop_in_connector(city: &mut City, k: StratumKind, comp: &[Pos]) -> bool {
let street_ok = |city: &City, p: Pos| {
city.tile(StratumKind::Street, p).is_some_and(|t| {
t.feature.is_none()
&& matches!(
t.terrain,
Terrain::Sidewalk | Terrain::Alley | Terrain::Plaza | Terrain::Road
)
}) && city.connectors_at(p).next().is_none()
};
let spot = comp.iter().copied().find(|p| {
city.tile(k, *p)
.is_some_and(|t| t.feature.is_none() && !t.is_connector())
&& match k {
StratumKind::Heights => {
city.walkable(StratumKind::Skyways, *p)
&& city.connectors_at(*p).next().is_none()
}
_ => street_ok(city, *p),
}
});
let Some(pos) = spot else { return false };
let (kind, serves) = match k {
StratumKind::Undercity => (
ConnectorKind::Manhole,
vec![StratumKind::Undercity, StratumKind::Street],
),
StratumKind::Skyways => (
ConnectorKind::Stair,
vec![StratumKind::Street, StratumKind::Skyways],
),
StratumKind::Heights => (
ConnectorKind::Ladder,
vec![StratumKind::Skyways, StratumKind::Heights],
),
_ => return false,
};
ConnectivityStage::add_connector(city, kind, pos, serves);
true
}
impl ConnectivityStage {
fn has_public(city: &City, d: usize, target: StratumKind) -> bool {
let id = city.districts[d].id;

View File

@@ -133,7 +133,60 @@ impl DistrictsStage {
.collect()
}
/// Assigns every land tile to its nearest seed on all physical strata.
fn assign_all(&self, ctx: &mut GenCtx) {
let size = ctx.size();
for y in 0..size.h as i32 {
for x in 0..size.w as i32 {
let p = Pos::new(x, y);
if !ctx.is_land(p) {
continue;
}
let d = self.nearest_seed(p);
for k in StratumKind::PHYSICAL {
if let Some(t) = ctx.city.tile_mut(k, p) {
t.district = Some(d);
}
}
}
}
}
/// Merges districts too small to hold a block into their neighbours, keeping at least
/// the minimum district count. Returns true if anything changed.
fn merge_slivers(&mut self, ctx: &mut GenCtx) -> bool {
let land = ctx.scratch.land.count(|&l| l);
let mut merged = false;
loop {
let n = self.seeds.len();
if n <= 4 {
break;
}
let min_tiles = (600usize).max(land / (3 * n));
let st = ctx.city.stratum(StratumKind::Street).expect("street");
let mut counts = vec![0usize; n];
for (_, t) in st.tiles.iter() {
if let Some(d) = t.district {
counts[d.index()] += 1;
}
}
let Some((smallest, _)) = counts
.iter()
.enumerate()
.filter(|(_, c)| **c < min_tiles)
.min_by_key(|(_, c)| **c)
else {
break;
};
self.seeds.remove(smallest);
self.assign_all(ctx);
merged = true;
}
merged
}
fn finalize(&mut self, ctx: &mut GenCtx) {
self.merge_slivers(ctx);
let n = self.seeds.len();
let size = ctx.size();
let noise = self.noise.as_ref().expect("begin sets noise");
@@ -274,7 +327,13 @@ mod tests {
#[test]
fn every_land_tile_has_a_district_and_counts_match() {
let c = small(5, WaterMode::Bay, 6);
assert_eq!(c.districts.len(), 6);
// The requested count is a maximum: slivers merge into neighbours.
assert!(
(4..=6).contains(&c.districts.len()),
"{}",
c.districts.len()
);
let n = c.districts.len();
let st = c.stratum(StratumKind::Street).unwrap();
for (_, t) in st.tiles.iter() {
if t.terrain != Terrain::Water {
@@ -285,7 +344,7 @@ mod tests {
}
let mut ids: Vec<_> = c.districts.iter().map(|d| d.id).collect();
ids.dedup();
assert_eq!(ids.len(), 6);
assert_eq!(ids.len(), n);
for (i, d) in c.districts.iter().enumerate() {
assert_eq!(d.id.index(), i);
assert!(d.bounds.contains(d.center));

View File

@@ -1,11 +1,12 @@
//! Stage 9: features, light maps and per-tile variants.
use super::{GenCtx, Stage, StageRunner, StepOut};
use crate::generate::connectivity::reachable_from_street;
use crate::geom::{Pos, Rect};
use crate::params::GenError;
use crate::pathing::components;
use crate::rng::{chance, tile_hash};
use crate::tile::{Feature, Terrain};
use crate::tile::{Feature, Terrain, Tile};
use crate::world::{StratumKind, Theme};
use rand::seq::IndexedRandom;
use rand_chacha::ChaCha8Rng;
@@ -14,6 +15,7 @@ use std::collections::VecDeque;
enum Work {
Facade(usize),
Streets(usize),
Verify,
Lights(StratumKind),
Variants(StratumKind),
}
@@ -238,6 +240,39 @@ impl DressingStage {
}
}
/// Blocking features must never cut tiles off from the street: any blocker beside an
/// unreached walkable tile is removed, repeatedly, until nothing is cut off.
fn verify(ctx: &mut GenCtx) {
for _ in 0..6 {
let reach = reachable_from_street(&ctx.city);
let mut to_clear: Vec<(StratumKind, Pos)> = Vec::new();
for k in StratumKind::PHYSICAL {
let st = ctx.city.stratum(k).expect("stratum");
for (p, t) in st.tiles.iter() {
if !t.is_walkable() || reach[k.index()][p] {
continue;
}
for n in p.neighbors4() {
let blocker = st.tiles.get(n).is_some_and(|u| {
u.feature.is_some()
&& !u.is_walkable()
&& Tile::new(u.terrain).is_walkable()
});
if blocker {
to_clear.push((k, n));
}
}
}
}
if to_clear.is_empty() {
return;
}
for (k, p) in to_clear {
ctx.set_feature(k, p, None);
}
}
}
fn lights(ctx: &mut GenCtx, k: StratumKind) {
let size = ctx.size();
let base = base_light(k);
@@ -286,6 +321,7 @@ impl StageRunner for DressingStage {
.extend((0..ctx.city.buildings.len()).map(Work::Facade));
self.queue
.extend((0..ctx.city.districts.len()).map(Work::Streets));
self.queue.push_back(Work::Verify);
self.queue
.extend(StratumKind::PHYSICAL.into_iter().map(Work::Lights));
self.queue
@@ -301,6 +337,7 @@ impl StageRunner for DressingStage {
match work {
Work::Facade(i) => self.facade(ctx, i),
Work::Streets(d) => self.streets(ctx, d),
Work::Verify => Self::verify(ctx),
Work::Lights(k) => Self::lights(ctx, k),
Work::Variants(k) => Self::variants(ctx, k),
}

View File

@@ -368,6 +368,36 @@ impl Carver<'_> {
None
}
/// Greedy largest clean rectangle in the district bounds (at least 5x4), for districts
/// whose street style produced no usable block.
fn largest_clean_rect(&self) -> Option<Rect> {
let b = self.bounds;
let mut best: Option<Rect> = None;
for start in b.iter() {
if self.is_bad(start) {
continue;
}
let mut w = 0;
while start.x + w < b.right() && w < 40 && !self.is_bad(Pos::new(start.x + w, start.y))
{
w += 1;
}
let mut h = 1;
while start.y + h < b.bottom() && h < 24 {
let row_clean = (0..w).all(|dx| !self.is_bad(Pos::new(start.x + dx, start.y + h)));
if !row_clean {
break;
}
h += 1;
}
let r = Rect::new(start.x, start.y, w, h);
if r.w >= 5 && r.h >= 4 && best.is_none_or(|o| r.area() > o.area()) {
best = Some(r);
}
}
best
}
/// Turns one candidate into clean blocks: trims dead edges, and when the rest is still
/// dirty, splits it along the longer axis (painting an alley on the cut) and recurses.
fn fit_blocks(&mut self, r: Rect, depth: u8, out: &mut Vec<Rect>) {
@@ -459,6 +489,14 @@ impl StreetsStage {
});
}
}
if blocks.is_empty()
&& let Some(rect) = carver.largest_clean_rect()
{
blocks.push(Block {
rect,
district: district.id,
});
}
// Every block gets a walkable ring so each perimeter lot can open a door.
for b in &blocks {
for p in b.rect.grow(1).border() {