fix(citygen): lanes reach backbones hosted in constructs; gates open onto the main grid

Review findings: a district backbone anchored at a server inside a building
got no lanes because lane endpoints keyed on the node's own building; the
endpoint now resolves the construct the node stands in. Construct gates
preferred any open tile, including dead-end alleys; they now prefer the
largest open-grid component. City::validate gains GateSealed: every
construct gate must reach its district backbone over walkable Net tiles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 22:13:11 +02:00
parent 835a9eb763
commit 0122130f5b
2 changed files with 208 additions and 20 deletions

View File

@@ -1,11 +1,11 @@
//! Stage 12: cyberspace — the Net stratum as a grid with constructs, hubs, lanes and ports.
use super::{GenCtx, StageRunner, StepOut};
use crate::geom::{Pos, Rect, Size};
use crate::geom::{Grid, Pos, Rect, Size};
use crate::ids::{BuildingId, DistrictId};
use crate::net::{Node, NodeKind, SubnetOwner};
use crate::params::GenError;
use crate::pathing::astar;
use crate::pathing::{astar, components};
use crate::rng::tile_hash;
use crate::tile::{Feature, Terrain, Tile};
use crate::world::{Connector, ConnectorKind, Stratum, StratumKind};
@@ -37,6 +37,8 @@ pub(crate) struct CyberspaceStage {
pub(crate) gates: Vec<Pos>,
/// Backbone anchor per district index.
pub(crate) backbones: Vec<Option<Pos>>,
/// The main open-grid component once every construct stands; gates prefer to open onto it.
open: Option<Grid<bool>>,
}
/// Sets terrain, owner and light of one Net tile; out-of-bounds is a no-op.
@@ -157,29 +159,50 @@ impl CyberspaceStage {
}
}
/// Picks the gate once every construct stands, so it never opens onto another wall.
/// Marks the largest connected region of open grid; computed once, after the constructs.
fn ensure_open(&mut self, ctx: &GenCtx) {
if self.open.is_some() {
return;
}
let size = ctx.size();
let comps = components(size, |p| ctx.terrain(K, p) == Terrain::NetGrid);
let mut open = Grid::filled(size, false);
if let Some(main) = comps.iter().max_by_key(|c| c.len()) {
for p in main {
open.set(*p, true);
}
}
self.open = Some(open);
}
/// Picks the gate once every construct stands: onto the main grid if any border tile
/// allows it, else onto any open tile, else the nearest non-corner border tile.
fn gate(&mut self, ctx: &mut GenCtx, i: usize) {
self.ensure_open(ctx);
let b = ctx.city.buildings[i].clone();
let fp = b.footprint;
let target = self.backbones[b.district.index()].unwrap_or(fp.center());
let open = |ctx: &GenCtx, p: Pos| {
ctx.city
.tile(K, outward(fp, p))
.is_some_and(|t| t.terrain == Terrain::NetGrid)
};
let candidates: Vec<Pos> = fp.border().filter(|p| !is_corner(fp, *p)).collect();
let gate = candidates
.iter()
.copied()
.filter(|p| open(ctx, *p))
.min_by_key(|p| (p.dist2(target), p.y, p.x))
.or_else(|| {
let gate = {
let open = self.open.as_ref().expect("set above");
let on_main = |p: Pos| open.get(outward(fp, p)).copied().unwrap_or(false);
let on_grid = |p: Pos| {
ctx.city
.tile(K, outward(fp, p))
.is_some_and(|t| t.terrain == Terrain::NetGrid)
};
let pick = |keep: &dyn Fn(Pos) -> bool| {
candidates
.iter()
.copied()
.filter(|p| keep(*p))
.min_by_key(|p| (p.dist2(target), p.y, p.x))
})
.unwrap_or(fp.center());
};
pick(&on_main)
.or_else(|| pick(&on_grid))
.or_else(|| pick(&|_| true))
.unwrap_or(fp.center())
};
set(ctx, gate, Terrain::NetGate, Some(b.id), 255);
self.gates[i] = gate;
}
@@ -262,8 +285,15 @@ impl CyberspaceStage {
}
}
fn lane_end(&self, n: &Node) -> Pos {
match n.building {
/// The construct a node stands in: its own building, else the one its cyber tile lies in
/// (a district backbone anchored at a server has no building of its own).
fn host(ctx: &GenCtx, n: &Node) -> Option<BuildingId> {
n.building
.or_else(|| ctx.city.tile(K, n.cyber).and_then(|t| t.building))
}
fn lane_end(&self, ctx: &GenCtx, n: &Node) -> Pos {
match Self::host(ctx, n) {
Some(b) => self.gates[b.index()],
None => n.cyber,
}
@@ -273,10 +303,11 @@ impl CyberspaceStage {
let link = ctx.city.net.links[li];
let a = ctx.city.net.node(link.0).clone();
let b = ctx.city.net.node(link.1).clone();
if a.building.is_some() && a.building == b.building {
let (ha, hb) = (Self::host(ctx, &a), Self::host(ctx, &b));
if ha.is_some() && ha == hb {
return;
}
let (start, goal) = (self.lane_end(&a), self.lane_end(&b));
let (start, goal) = (self.lane_end(ctx, &a), self.lane_end(ctx, &b));
if start == goal {
return;
}
@@ -338,6 +369,7 @@ impl StageRunner for CyberspaceStage {
let nb = ctx.city.buildings.len();
let nd = ctx.city.districts.len();
self.gates = vec![Pos::new(0, 0); nb];
self.open = None;
self.backbones = (0..nd)
.map(|d| backbone_anchor(ctx, DistrictId(d as u32)))
.collect();
@@ -656,4 +688,85 @@ mod tests {
let path = super::bounded_astar(size, a, b, 16, free).unwrap();
assert_eq!((path[0], *path.last().unwrap()), (a, b));
}
fn gate_of(c: &crate::world::City, b: crate::ids::BuildingId) -> Pos {
let net = c.stratum(StratumKind::Net).unwrap();
c.building(b)
.footprint
.border()
.find(|p| net.tiles[*p].terrain == Terrain::NetGate)
.expect("every construct has a gate")
}
#[test]
fn every_district_hub_is_lit_by_a_lane() {
for (seed, size) in [
(1u64, SizePreset::Small),
(6, SizePreset::Custom { w: 120, h: 60 }),
] {
let c = Generator::run(GenParams {
seed,
size,
..Default::default()
})
.unwrap();
let net = c.stratum(StratumKind::Net).unwrap();
for n in c
.net
.nodes
.iter()
.filter(|n| n.kind == crate::net::NodeKind::Backbone)
{
// A backbone inside a construct is reached through that construct's gate.
let terminal = match net.tiles[n.cyber].building {
Some(b) => gate_of(&c, b),
None => n.cyber,
};
assert!(
terminal.neighbors4().any(|q| net
.tiles
.get(q)
.is_some_and(|t| t.terrain == Terrain::NetLane)),
"seed {seed}: backbone {:?} at {:?} (terminal {terminal:?}) has no lane",
n.id,
n.cyber
);
}
}
}
#[test]
fn gates_open_onto_the_main_grid() {
let c = Generator::run(GenParams {
seed: 3,
size: SizePreset::Medium,
..Default::default()
})
.unwrap();
let net = c.stratum(StratumKind::Net).unwrap();
let comps = crate::pathing::components(c.size, |p| {
net.tiles
.get(p)
.is_some_and(|t| t.building.is_none() && t.is_walkable())
});
let main: std::collections::HashSet<Pos> = comps
.iter()
.max_by_key(|c| c.len())
.unwrap()
.iter()
.copied()
.collect();
for b in &c.buildings {
let gate = gate_of(&c, b.id);
let out = gate
.neighbors4()
.find(|q| !b.footprint.contains(*q))
.unwrap();
assert!(
main.contains(&out),
"gate {gate:?} of {:?} opens into a pocket at {out:?}",
b.id
);
}
}
}

View File

@@ -4,6 +4,8 @@ use crate::generate::connectivity::reachable_from_street;
use crate::geom::{Grid, Pos};
use crate::ids::{BuildingId, DistrictId, NodeId};
use crate::net::NodeKind;
use crate::net::SubnetOwner;
use crate::pathing::flood_fill;
use crate::tile::{Feature, Terrain};
use crate::world::{City, ConnectorKind, StratumKind};
use std::collections::VecDeque;
@@ -36,6 +38,7 @@ pub enum Violation {
JackUnreachable {
pos: Pos,
},
GateSealed(BuildingId),
}
impl fmt::Display for Violation {
@@ -67,6 +70,10 @@ impl fmt::Display for Violation {
Violation::CyberAnchor(n) => {
write!(f, "net node {n:?} is not on its cyberspace tile")
}
Violation::GateSealed(b) => write!(
f,
"the construct gate of building {b:?} cannot be reached from its district backbone"
),
Violation::JackUnreachable { pos } => {
write!(
f,
@@ -216,6 +223,35 @@ impl City {
}
}
if let Some(net) = self.stratum(StratumKind::Net) {
for d in &self.districts {
let Some(backbone) = self.net.nodes.iter().find(|n| {
n.kind == NodeKind::Backbone
&& self.net.subnet(n.subnet).owner == SubnetOwner::District(d.id)
}) else {
continue;
};
let mut reach = Grid::filled(self.size, false);
for p in flood_fill(self.size, backbone.cyber, |p| {
net.tiles.get(p).is_some_and(|t| t.is_walkable())
}) {
reach.set(p, true);
}
for b in self.buildings.iter().filter(|b| b.district == d.id) {
let gate = b.footprint.border().find(|p| {
net.tiles
.get(*p)
.is_some_and(|t| t.terrain == Terrain::NetGate)
});
if let Some(gate) = gate
&& !reach[gate]
{
out.push(Violation::GateSealed(b.id));
}
}
}
}
if self.stratum(StratumKind::Net).is_some() {
for n in &self.net.nodes {
let ok = self
@@ -461,4 +497,43 @@ mod tests {
"{v:?}"
);
}
#[test]
fn detects_a_sealed_construct_gate() {
let mut c = generated();
assert!(c.validate().is_ok());
// A building that does not host its district's backbone; wall its gate's outward tile.
let hosts: Vec<BuildingId> = c
.net
.nodes
.iter()
.filter(|n| n.kind == NodeKind::Backbone)
.filter_map(|n| c.tile(StratumKind::Net, n.cyber).and_then(|t| t.building))
.collect();
let b = c
.buildings
.iter()
.find(|b| !hosts.contains(&b.id))
.unwrap()
.clone();
let gate = b
.footprint
.border()
.find(|p| {
c.tile(StratumKind::Net, *p)
.is_some_and(|t| t.terrain == Terrain::NetGate)
})
.unwrap();
let out = gate
.neighbors4()
.find(|q| !b.footprint.contains(*q))
.unwrap();
c.tile_mut(StratumKind::Net, out).unwrap().terrain = Terrain::NetWall;
let v = c.validate().unwrap_err();
assert!(
v.iter()
.any(|v| matches!(v, Violation::GateSealed(x) if *x == b.id)),
"{v:?}"
);
}
}