feat(citygen): cyberspace nodes, lanes and jack ports
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -3,22 +3,28 @@
|
||||
use super::{GenCtx, StageRunner, StepOut};
|
||||
use crate::geom::{Pos, Rect};
|
||||
use crate::ids::{BuildingId, DistrictId};
|
||||
use crate::net::{NodeKind, SubnetOwner};
|
||||
use crate::net::{Node, NodeKind, SubnetOwner};
|
||||
use crate::params::GenError;
|
||||
use crate::pathing::astar;
|
||||
use crate::rng::tile_hash;
|
||||
use crate::tile::{Feature, Terrain, Tile};
|
||||
use crate::world::{Stratum, StratumKind};
|
||||
use crate::world::{Connector, ConnectorKind, Stratum, StratumKind};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const K: StratumKind = StratumKind::Net;
|
||||
const GRID_LIGHT: u8 = 200;
|
||||
const HUB_RADIUS: i32 = 2;
|
||||
const NODE_SEARCH: i32 = 6;
|
||||
const LANE_LIGHT: u8 = 255;
|
||||
|
||||
enum Work {
|
||||
Begin,
|
||||
Construct(usize),
|
||||
Gate(usize),
|
||||
Hub(usize),
|
||||
Nodes,
|
||||
Lane(usize),
|
||||
Ports,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -184,6 +190,116 @@ impl CyberspaceStage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest walkable Net tile without a feature, scanning square rings in a fixed order.
|
||||
fn nearest_free(ctx: &GenCtx, c: Pos) -> Option<Pos> {
|
||||
for r in 1..=NODE_SEARCH {
|
||||
let ring = Rect::new(c.x - r, c.y - r, 2 * r + 1, 2 * r + 1);
|
||||
let hit = ring.border().find(|p| {
|
||||
ctx.city
|
||||
.tile(K, *p)
|
||||
.is_some_and(|t| t.is_walkable() && t.feature.is_none())
|
||||
});
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// ICE first so it always takes the gate; then everything else in id order.
|
||||
fn nodes(&mut self, ctx: &mut GenCtx) {
|
||||
let mut order: Vec<usize> = (0..ctx.city.net.nodes.len()).collect();
|
||||
order.sort_by_key(|i| (ctx.city.net.nodes[*i].kind != NodeKind::Ice, *i));
|
||||
for i in order {
|
||||
let node = ctx.city.net.nodes[i].clone();
|
||||
let mut t = node.anchor.1;
|
||||
if node.kind == NodeKind::Ice
|
||||
&& let Some(b) = node.building
|
||||
{
|
||||
t = self.gates[b.index()];
|
||||
}
|
||||
let taken = ctx
|
||||
.city
|
||||
.tile(K, t)
|
||||
.is_none_or(|x| matches!(x.feature, Some(Feature::Node(_))));
|
||||
if taken {
|
||||
t = Self::nearest_free(ctx, t).unwrap_or(t);
|
||||
}
|
||||
ctx.set_feature(K, t, Some(Feature::Node(node.kind)));
|
||||
ctx.city.net.nodes[i].cyber = t;
|
||||
}
|
||||
}
|
||||
|
||||
fn lane_end(&self, n: &Node) -> Pos {
|
||||
match n.building {
|
||||
Some(b) => self.gates[b.index()],
|
||||
None => n.cyber,
|
||||
}
|
||||
}
|
||||
|
||||
fn lane(&mut self, ctx: &mut GenCtx, li: usize) {
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
let (start, goal) = (self.lane_end(&a), self.lane_end(&b));
|
||||
if start == goal {
|
||||
return;
|
||||
}
|
||||
let size = ctx.size();
|
||||
let path = {
|
||||
let city = &ctx.city;
|
||||
astar(size, start, goal, |_, q| {
|
||||
let t = city.tile(K, q)?;
|
||||
if q == start || q == goal {
|
||||
return Some(1);
|
||||
}
|
||||
match t.terrain {
|
||||
Terrain::NetLane | Terrain::NetGate => Some(1),
|
||||
Terrain::NetGrid => Some(if matches!(t.feature, Some(Feature::Node(_))) {
|
||||
1
|
||||
} else {
|
||||
3
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
};
|
||||
let Some(path) = path else { return };
|
||||
for p in path {
|
||||
if ctx.terrain(K, p) == Terrain::NetGrid {
|
||||
ctx.set_terrain(K, p, Terrain::NetLane);
|
||||
if let Some(st) = ctx.city.stratum_mut(K) {
|
||||
st.light.set(p, LANE_LIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ports(ctx: &mut GenCtx) {
|
||||
let street = StratumKind::Street;
|
||||
let mut jacks = Vec::new();
|
||||
if let Some(st) = ctx.city.stratum(street) {
|
||||
for (p, t) in st.tiles.iter() {
|
||||
if t.feature == Some(Feature::Terminal) && ctx.city.net.node_at(street, p).is_some()
|
||||
{
|
||||
jacks.push(Connector {
|
||||
kind: ConnectorKind::Jack,
|
||||
pos: p,
|
||||
serves: vec![street, K],
|
||||
public: t.building.is_none(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.city
|
||||
.connectors
|
||||
.retain(|c| c.kind != ConnectorKind::Jack);
|
||||
ctx.city.connectors.extend(jacks);
|
||||
}
|
||||
}
|
||||
|
||||
impl StageRunner for CyberspaceStage {
|
||||
@@ -199,6 +315,10 @@ impl StageRunner for CyberspaceStage {
|
||||
self.queue.extend((0..nb).map(Work::Construct));
|
||||
self.queue.extend((0..nb).map(Work::Gate));
|
||||
self.queue.extend((0..nd).map(Work::Hub));
|
||||
self.queue.push_back(Work::Nodes);
|
||||
self.queue
|
||||
.extend((0..ctx.city.net.links.len()).map(Work::Lane));
|
||||
self.queue.push_back(Work::Ports);
|
||||
self.total = self.queue.len().max(1);
|
||||
Ok(())
|
||||
}
|
||||
@@ -212,6 +332,9 @@ impl StageRunner for CyberspaceStage {
|
||||
Work::Construct(i) => self.construct(ctx, i),
|
||||
Work::Gate(i) => self.gate(ctx, i),
|
||||
Work::Hub(d) => self.hub(ctx, d),
|
||||
Work::Nodes => self.nodes(ctx),
|
||||
Work::Lane(i) => self.lane(ctx, i),
|
||||
Work::Ports => Self::ports(ctx),
|
||||
}
|
||||
let done = self.total - self.queue.len();
|
||||
Ok(StepOut::Continue(done as f32 / self.total as f32))
|
||||
@@ -316,4 +439,164 @@ mod tests {
|
||||
}
|
||||
assert!(hubs >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_node_stands_on_its_feature_and_ice_guards_the_gate() {
|
||||
let c = city();
|
||||
let net = c.stratum(StratumKind::Net).unwrap();
|
||||
for n in &c.net.nodes {
|
||||
assert_eq!(
|
||||
net.tiles[n.cyber].feature,
|
||||
Some(crate::tile::Feature::Node(n.kind)),
|
||||
"node {:?} ({:?}) at {:?}",
|
||||
n.id,
|
||||
n.kind,
|
||||
n.cyber
|
||||
);
|
||||
}
|
||||
let mut ice = 0;
|
||||
for n in c
|
||||
.net
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.kind == crate::net::NodeKind::Ice)
|
||||
{
|
||||
let Some(b) = n.building else { continue };
|
||||
ice += 1;
|
||||
let fp = c.building(b).footprint;
|
||||
let gate = fp
|
||||
.border()
|
||||
.find(|p| net.tiles[*p].terrain == Terrain::NetGate)
|
||||
.unwrap();
|
||||
assert!(
|
||||
n.cyber.chebyshev(gate) <= 1,
|
||||
"ICE {:?} at {:?} is not at gate {gate:?}",
|
||||
n.id,
|
||||
n.cyber
|
||||
);
|
||||
}
|
||||
assert!(ice >= 1, "fixture has no ICE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lanes_link_every_construct_gate_to_its_backbone() {
|
||||
let c = city();
|
||||
let net = c.stratum(StratumKind::Net).unwrap();
|
||||
assert!(net.tiles.count(|t| t.terrain == Terrain::NetLane) > 20);
|
||||
for b in &c.buildings {
|
||||
let gate = b
|
||||
.footprint
|
||||
.border()
|
||||
.find(|p| net.tiles[*p].terrain == Terrain::NetGate)
|
||||
.unwrap();
|
||||
let backbone = c
|
||||
.net
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| {
|
||||
n.kind == crate::net::NodeKind::Backbone
|
||||
&& c.net.subnet(n.subnet).owner
|
||||
== crate::net::SubnetOwner::District(b.district)
|
||||
})
|
||||
.unwrap();
|
||||
let reach = crate::pathing::flood_fill(c.size, gate, |p| {
|
||||
net.tiles.get(p).is_some_and(|t| t.is_walkable())
|
||||
});
|
||||
assert!(
|
||||
reach.contains(&backbone.cyber),
|
||||
"gate {gate:?} of {:?} cannot reach backbone {:?}",
|
||||
b.id,
|
||||
backbone.cyber
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn street_terminals_with_nodes_become_jacks() {
|
||||
let c = city();
|
||||
let street = StratumKind::Street;
|
||||
let jacks: Vec<_> = c
|
||||
.connectors
|
||||
.iter()
|
||||
.filter(|k| k.kind == crate::world::ConnectorKind::Jack)
|
||||
.collect();
|
||||
assert!(jacks.len() >= 3, "only {} jacks", jacks.len());
|
||||
for j in &jacks {
|
||||
assert_eq!(j.serves, vec![street, StratumKind::Net]);
|
||||
let t = c.tile(street, j.pos).unwrap();
|
||||
assert_eq!(t.feature, Some(crate::tile::Feature::Terminal));
|
||||
assert_eq!(j.public, t.building.is_none());
|
||||
assert!(
|
||||
c.walkable(StratumKind::Net, j.pos),
|
||||
"port {:?} not walkable",
|
||||
j.pos
|
||||
);
|
||||
assert!(j.pos.neighbors8().any(|n| c.walkable(street, n)));
|
||||
}
|
||||
for (p, t) in c.stratum(street).unwrap().tiles.iter() {
|
||||
if t.feature == Some(crate::tile::Feature::Terminal)
|
||||
&& c.net.node_at(street, p).is_some()
|
||||
{
|
||||
assert!(
|
||||
jacks.iter().any(|j| j.pos == p),
|
||||
"terminal {p:?} has no jack"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
jacks.iter().any(|j| !j.public),
|
||||
"fixture has no private port"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_jack_reaches_the_open_grid() {
|
||||
let c = city();
|
||||
let net = c.stratum(StratumKind::Net).unwrap();
|
||||
for j in c
|
||||
.connectors
|
||||
.iter()
|
||||
.filter(|k| k.kind == crate::world::ConnectorKind::Jack)
|
||||
{
|
||||
let reach = crate::pathing::flood_fill(c.size, j.pos, |p| {
|
||||
net.tiles.get(p).is_some_and(|t| t.is_walkable())
|
||||
});
|
||||
assert!(
|
||||
reach
|
||||
.iter()
|
||||
.any(|p| net.tiles[*p].terrain == Terrain::NetGrid
|
||||
&& net.tiles[*p].building.is_none()),
|
||||
"port {:?} is sealed in",
|
||||
j.pos
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gates_open_onto_walkable_tiles() {
|
||||
for seed in [6u64, 11, 29] {
|
||||
let c = Generator::run(GenParams {
|
||||
seed,
|
||||
size: SizePreset::Custom { w: 80, h: 40 },
|
||||
districts: 4,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let net = c.stratum(StratumKind::Net).unwrap();
|
||||
for b in &c.buildings {
|
||||
let fp = b.footprint;
|
||||
let gate = fp
|
||||
.border()
|
||||
.find(|p| net.tiles[*p].terrain == Terrain::NetGate)
|
||||
.unwrap();
|
||||
assert!(
|
||||
gate.neighbors4().any(|q| {
|
||||
!fp.contains(q) && net.tiles.get(q).is_some_and(|t| t.is_walkable())
|
||||
}),
|
||||
"seed {seed}: gate {gate:?} of {:?} is sealed",
|
||||
b.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,11 @@ mod tests {
|
||||
.count()
|
||||
> 100
|
||||
);
|
||||
for k in &c.connectors {
|
||||
for k in c
|
||||
.connectors
|
||||
.iter()
|
||||
.filter(|k| k.kind != crate::world::ConnectorKind::Jack)
|
||||
{
|
||||
for s in &k.serves {
|
||||
let t = c.tile(*s, k.pos).unwrap();
|
||||
assert!(t.is_walkable(), "{k:?} blocked on {s:?}");
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::geom::Pos;
|
||||
use crate::ids::{BuildingId, DistrictId, NodeId};
|
||||
use crate::net::NodeKind;
|
||||
use crate::tile::{Feature, Terrain};
|
||||
use crate::world::{City, StratumKind};
|
||||
use crate::world::{City, ConnectorKind, StratumKind};
|
||||
use std::fmt;
|
||||
|
||||
/// Share of walkable tiles per stratum that must be reachable from the street.
|
||||
@@ -69,6 +69,10 @@ impl City {
|
||||
let mut out = Vec::new();
|
||||
|
||||
for c in &self.connectors {
|
||||
if c.kind == ConnectorKind::Jack {
|
||||
// A jack stands on a terminal tile and is used from beside it.
|
||||
continue;
|
||||
}
|
||||
for k in &c.serves {
|
||||
if *k != StratumKind::Net && !self.walkable(*k, c.pos) {
|
||||
out.push(Violation::ConnectorNotWalkable {
|
||||
|
||||
Reference in New Issue
Block a user