feat(citygen): validate cyberspace anchors, jacks and reachability

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 21:40:40 +02:00
parent 2d451f24d8
commit bbd2745f73

View File

@@ -1,11 +1,12 @@
//! City invariants.
use crate::generate::connectivity::reachable_from_street;
use crate::geom::Pos;
use crate::geom::{Grid, Pos};
use crate::ids::{BuildingId, DistrictId, NodeId};
use crate::net::NodeKind;
use crate::tile::{Feature, Terrain};
use crate::world::{City, ConnectorKind, StratumKind};
use std::collections::VecDeque;
use std::fmt;
/// Share of walkable tiles per stratum that must be reachable from the street.
@@ -31,6 +32,10 @@ pub enum Violation {
FootprintOverlap(BuildingId, BuildingId),
NetAnchor(NodeId),
DistrictEmpty(DistrictId),
CyberAnchor(NodeId),
JackUnreachable {
pos: Pos,
},
}
impl fmt::Display for Violation {
@@ -59,6 +64,15 @@ impl fmt::Display for Violation {
Violation::DistrictEmpty(d) => {
write!(f, "district {d:?} lacks a building or a public connector")
}
Violation::CyberAnchor(n) => {
write!(f, "net node {n:?} is not on its cyberspace tile")
}
Violation::JackUnreachable { pos } => {
write!(
f,
"jack at {pos:?} has no walkable neighbour or a dead port"
)
}
}
}
}
@@ -71,6 +85,16 @@ impl City {
for c in &self.connectors {
if c.kind == ConnectorKind::Jack {
// A jack stands on a terminal tile and is used from beside it.
let street_ok = self
.tile(StratumKind::Street, c.pos)
.is_some_and(|t| t.feature == Some(Feature::Terminal))
&& c.pos
.neighbors8()
.any(|n| self.walkable(StratumKind::Street, n));
let net_ok = self.walkable(StratumKind::Net, c.pos);
if !street_ok || !net_ok {
out.push(Violation::JackUnreachable { pos: c.pos });
}
continue;
}
for k in &c.serves {
@@ -110,6 +134,42 @@ impl City {
}
}
if let Some(net) = self.stratum(StratumKind::Net) {
let jacks: Vec<Pos> = self
.connectors
.iter()
.filter(|c| c.kind == ConnectorKind::Jack)
.map(|c| c.pos)
.collect();
let walkable = net.tiles.count(|t| t.is_walkable());
if !jacks.is_empty() && walkable > 0 {
let mut seen = Grid::filled(self.size, false);
let mut queue: VecDeque<Pos> = VecDeque::new();
for p in jacks {
if net.tiles.get(p).is_some_and(|t| t.is_walkable()) && !seen[p] {
seen.set(p, true);
queue.push_back(p);
}
}
while let Some(p) = queue.pop_front() {
for n in p.neighbors4() {
if net.tiles.get(n).is_some_and(|t| t.is_walkable()) && !seen[n] {
seen.set(n, true);
queue.push_back(n);
}
}
}
let reached = seen.count(|b| *b);
if (reached as f32) < REACH_TARGET * walkable as f32 {
out.push(Violation::StratumUnreachable {
stratum: StratumKind::Net,
reached,
walkable,
});
}
}
}
for st in &self.strata {
for (p, t) in st.tiles.iter() {
let bad_d = t
@@ -156,6 +216,17 @@ impl City {
}
}
if self.stratum(StratumKind::Net).is_some() {
for n in &self.net.nodes {
let ok = self
.tile(StratumKind::Net, n.cyber)
.is_some_and(|t| t.feature == Some(Feature::Node(n.kind)));
if !ok {
out.push(Violation::CyberAnchor(n.id));
}
}
}
for link in &self.net.links {
for id in [link.0, link.1] {
if id.index() >= self.net.nodes.len() {
@@ -168,6 +239,7 @@ impl City {
let has_building = self.buildings.iter().any(|b| b.district == d.id);
let has_public = self.connectors.iter().any(|c| {
c.public
&& c.kind != ConnectorKind::Jack
&& self
.tile(StratumKind::Street, c.pos)
.is_some_and(|t| t.district == Some(d.id))
@@ -282,4 +354,111 @@ mod tests {
);
assert!(format!("{}", errs[0]).len() > 5);
}
fn generated() -> City {
crate::generate::Generator::run(GenParams {
seed: 6,
size: SizePreset::Custom { w: 120, h: 60 },
districts: 5,
..Default::default()
})
.unwrap()
}
#[test]
fn detects_moved_cyber_nodes_and_walled_in_jacks() {
let mut c = generated();
assert!(c.validate().is_ok());
let moved = c.net.nodes[0].cyber;
c.net.nodes[0].cyber = Pos::new(moved.x, (moved.y + 1) % c.size.h as i32);
let v = c.validate().unwrap_err();
assert!(
v.iter()
.any(|v| matches!(v, Violation::CyberAnchor(NodeId(0)))),
"{v:?}"
);
c.net.nodes[0].cyber = moved;
let jack = c
.connectors
.iter()
.find(|k| k.kind == ConnectorKind::Jack)
.unwrap()
.pos;
for n in jack.neighbors8() {
if let Some(t) = c.tile_mut(StratumKind::Street, n) {
t.terrain = Terrain::Wall;
}
}
let v = c.validate().unwrap_err();
assert!(
v.iter()
.any(|v| matches!(v, Violation::JackUnreachable { pos } if *pos == jack)),
"{v:?}"
);
}
fn street_district(c: &City, p: Pos) -> Option<DistrictId> {
c.tile(StratumKind::Street, p).and_then(|t| t.district)
}
#[test]
fn jack_is_not_a_public_connector_for_districts() {
let mut c = generated();
// Strip every non-jack public connector from district 0: it must now be flagged.
let d = DistrictId(0);
let keep: Vec<bool> = c
.connectors
.iter()
.map(|k| {
k.kind == ConnectorKind::Jack || !k.public || street_district(&c, k.pos) != Some(d)
})
.collect();
let mut i = 0;
c.connectors.retain(|_| {
let k = keep[i];
i += 1;
k
});
let v = c.validate().unwrap_err();
assert!(
v.iter()
.any(|v| matches!(v, Violation::DistrictEmpty(x) if *x == d)),
"{v:?}"
);
}
#[test]
fn net_reachability_is_checked_from_the_jacks() {
let mut c = generated();
// Ring every port with wall: the grid stays walkable but nothing reaches it.
let jacks: Vec<Pos> = c
.connectors
.iter()
.filter(|k| k.kind == ConnectorKind::Jack)
.map(|k| k.pos)
.collect();
let net = c.stratum_mut(StratumKind::Net).unwrap();
for j in &jacks {
for n in j.neighbors8() {
if let Some(t) = net.tiles.get_mut(n)
&& !jacks.contains(&n)
{
t.terrain = Terrain::NetWall;
t.feature = None;
}
}
}
let v = c.validate().unwrap_err();
assert!(
v.iter().any(|v| matches!(
v,
Violation::StratumUnreachable {
stratum: StratumKind::Net,
..
}
)),
"{v:?}"
);
}
}