diff --git a/crates/citygen/src/validate.rs b/crates/citygen/src/validate.rs index 53da481..2651745 100644 --- a/crates/citygen/src/validate.rs +++ b/crates/citygen/src/validate.rs @@ -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 = 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 = 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 { + 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 = 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 = 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:?}" + ); + } }