feat(citygen): cyberspace stage with grid, constructs, gates and hubs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 21:36:38 +02:00
parent a491841639
commit 5037719e95
4 changed files with 328 additions and 3 deletions

View File

@@ -0,0 +1,319 @@
//! 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};
use crate::ids::{BuildingId, DistrictId};
use crate::net::{NodeKind, SubnetOwner};
use crate::params::GenError;
use crate::rng::tile_hash;
use crate::tile::{Feature, Terrain, Tile};
use crate::world::{Stratum, StratumKind};
use std::collections::VecDeque;
const K: StratumKind = StratumKind::Net;
const GRID_LIGHT: u8 = 200;
const HUB_RADIUS: i32 = 2;
enum Work {
Begin,
Construct(usize),
Gate(usize),
Hub(usize),
}
#[derive(Default)]
pub(crate) struct CyberspaceStage {
queue: VecDeque<Work>,
total: usize,
/// Gate tile per building index.
pub(crate) gates: Vec<Pos>,
/// Backbone anchor per district index.
pub(crate) backbones: Vec<Option<Pos>>,
}
/// Sets terrain, owner and light of one Net tile; out-of-bounds is a no-op.
pub(crate) fn set(ctx: &mut GenCtx, p: Pos, t: Terrain, b: Option<BuildingId>, light: u8) {
if let Some(tile) = ctx.city.tile_mut(K, p) {
tile.terrain = t;
tile.building = b;
}
if let Some(st) = ctx.city.stratum_mut(K) {
st.light.set(p, light);
}
}
fn is_corner(r: Rect, p: Pos) -> bool {
(p.x == r.x || p.x == r.right() - 1) && (p.y == r.y || p.y == r.bottom() - 1)
}
/// The tile just outside a footprint border tile, away from the centre.
fn outward(r: Rect, p: Pos) -> Pos {
if p.y == r.y {
p.offset(0, -1)
} else if p.y == r.bottom() - 1 {
p.offset(0, 1)
} else if p.x == r.x {
p.offset(-1, 0)
} else {
p.offset(1, 0)
}
}
fn backbone_anchor(ctx: &GenCtx, d: DistrictId) -> Option<Pos> {
ctx.city
.net
.nodes
.iter()
.find(|n| {
n.kind == NodeKind::Backbone
&& ctx.city.net.subnet(n.subnet).owner == SubnetOwner::District(d)
})
.map(|n| n.anchor.1)
}
impl CyberspaceStage {
fn begin_net(ctx: &mut GenCtx) {
let size = ctx.size();
ctx.city.strata.retain(|s| s.kind != K);
let mut st = Stratum::new(K, size);
for y in 0..size.h as i32 {
for x in 0..size.w as i32 {
let p = Pos::new(x, y);
let district = ctx
.city
.tile(StratumKind::Street, p)
.and_then(|t| t.district);
let lattice = u8::from(x % 4 == 0 && y % 2 == 0);
let variant = (tile_hash(ctx.seed, K, p) & 0xFE) | lattice;
st.tiles.set(
p,
Tile {
terrain: Terrain::NetGrid,
feature: None,
district,
building: None,
variant,
},
);
st.light.set(p, GRID_LIGHT);
}
}
ctx.city.strata.push(st);
}
fn construct(&mut self, ctx: &mut GenCtx, i: usize) {
let b = ctx.city.buildings[i].clone();
let security = ctx
.city
.net
.subnets
.iter()
.find(|s| s.owner == SubnetOwner::Building(b.id))
.map(|s| s.security)
.unwrap_or(0);
let light = 96u8.saturating_add((security as u32 * 5 / 8) as u8);
for p in b.footprint.iter() {
set(ctx, p, Terrain::NetWall, Some(b.id), light);
}
for p in b.footprint.inset(1).iter() {
set(ctx, p, Terrain::NetFloor, Some(b.id), light);
}
}
/// Picks the gate once every construct stands, so it never opens onto another wall.
fn gate(&mut self, ctx: &mut GenCtx, i: usize) {
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(|| {
candidates
.iter()
.copied()
.min_by_key(|p| (p.dist2(target), p.y, p.x))
})
.unwrap_or(fp.center());
set(ctx, gate, Terrain::NetGate, Some(b.id), 255);
self.gates[i] = gate;
}
fn hub(&mut self, ctx: &mut GenCtx, d: usize) {
let Some(c) = self.backbones[d] else { return };
if ctx.city.tile(K, c).is_none_or(|t| t.building.is_some()) {
return;
}
let r = Rect::new(
c.x - HUB_RADIUS,
c.y - HUB_RADIUS,
2 * HUB_RADIUS + 1,
2 * HUB_RADIUS + 1,
);
let keep_open = |ctx: &GenCtx, p: Pos| {
// Never wall a port, and never seal a construct gate.
ctx.city
.tile(StratumKind::Street, p)
.is_some_and(|t| t.feature == Some(Feature::Terminal))
|| p.neighbors4()
.any(|q| ctx.terrain(K, q) == Terrain::NetGate)
};
for p in r.border() {
if ctx.terrain(K, p) == Terrain::NetGrid && !keep_open(ctx, p) {
set(ctx, p, Terrain::NetWall, None, 255);
}
}
for p in [
Pos::new(c.x, r.y),
Pos::new(r.right() - 1, c.y),
Pos::new(c.x, r.bottom() - 1),
Pos::new(r.x, c.y),
] {
if ctx.terrain(K, p) == Terrain::NetWall
&& ctx.city.tile(K, p).is_some_and(|t| t.building.is_none())
{
set(ctx, p, Terrain::NetGate, None, 255);
}
}
}
}
impl StageRunner for CyberspaceStage {
fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> {
let nb = ctx.city.buildings.len();
let nd = ctx.city.districts.len();
self.gates = vec![Pos::new(0, 0); nb];
self.backbones = (0..nd)
.map(|d| backbone_anchor(ctx, DistrictId(d as u32)))
.collect();
self.queue.clear();
self.queue.push_back(Work::Begin);
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.total = self.queue.len().max(1);
Ok(())
}
fn step(&mut self, ctx: &mut GenCtx) -> Result<StepOut, GenError> {
let Some(work) = self.queue.pop_front() else {
return Ok(StepOut::Finished);
};
match work {
Work::Begin => Self::begin_net(ctx),
Work::Construct(i) => self.construct(ctx, i),
Work::Gate(i) => self.gate(ctx, i),
Work::Hub(d) => self.hub(ctx, d),
}
let done = self.total - self.queue.len();
Ok(StepOut::Continue(done as f32 / self.total as f32))
}
}
#[cfg(test)]
mod tests {
use crate::generate::Generator;
use crate::geom::Pos;
use crate::params::{GenParams, SizePreset};
use crate::tile::Terrain;
use crate::world::StratumKind;
pub(crate) fn city() -> crate::world::City {
Generator::run(GenParams {
seed: 6,
size: SizePreset::Custom { w: 120, h: 60 },
districts: 5,
..Default::default()
})
.unwrap()
}
#[test]
fn net_stratum_is_a_grid_with_constructs_on_every_footprint() {
let c = city();
let net = c.stratum(StratumKind::Net).expect("fifth stratum");
assert_eq!(c.strata.len(), 5);
assert_eq!(net.tiles.size(), c.size);
// Grid tiles mirror the street's districts and carry the lattice bit.
let lattice = Pos::new(4, 2);
let t = net.tiles[lattice];
assert_eq!(t.terrain, Terrain::NetGrid);
assert_eq!(t.variant & 1, 1);
assert_eq!(net.tiles[Pos::new(5, 2)].variant & 1, 0);
assert_eq!(
t.district,
c.tile(StratumKind::Street, lattice).unwrap().district
);
for b in &c.buildings {
let fp = b.footprint;
let gates = fp
.border()
.filter(|p| net.tiles[*p].terrain == Terrain::NetGate)
.count();
assert_eq!(gates, 1, "building {:?} has {gates} gates", b.id);
for p in fp.border() {
let t = net.tiles[p];
assert!(
matches!(t.terrain, Terrain::NetWall | Terrain::NetGate),
"{:?} border {p:?} is {:?}",
b.id,
t.terrain
);
assert_eq!(t.building, Some(b.id));
}
for p in fp.inset(1).iter() {
assert_eq!(net.tiles[p].terrain, Terrain::NetFloor);
}
let light = net.light[fp.center()];
assert!(light >= 96, "construct light {light}");
}
assert!(net.tiles.count(|t| t.terrain == Terrain::NetGrid) > c.size.area() / 4);
}
#[test]
fn hubs_ring_every_backbone_that_stands_on_the_grid() {
let c = city();
let net = c.stratum(StratumKind::Net).unwrap();
let mut hubs = 0;
for n in c
.net
.nodes
.iter()
.filter(|n| n.kind == crate::net::NodeKind::Backbone)
{
let a = n.anchor.1;
if net.tiles[a].building.is_some() {
continue;
}
hubs += 1;
let ring = crate::geom::Rect::new(a.x - 2, a.y - 2, 5, 5);
let walls = ring
.border()
.filter(|p| {
net.tiles
.get(*p)
.is_some_and(|t| t.terrain == Terrain::NetWall)
})
.count();
let gates = ring
.border()
.filter(|p| {
net.tiles
.get(*p)
.is_some_and(|t| t.terrain == Terrain::NetGate)
})
.count();
assert!(walls >= 8, "hub at {a:?} has only {walls} wall tiles");
assert!(gates >= 2, "hub at {a:?} has only {gates} gates");
}
assert!(hubs >= 1);
}
}

View File

@@ -1,7 +1,8 @@
//! The resumable generator: eleven chunked stages over a growing `City`.
//! The resumable generator: twelve chunked stages over a growing `City`.
pub mod arterials;
pub mod connectivity;
pub mod cyberspace;
pub mod districts;
pub mod dressing;
pub mod interiors;
@@ -35,10 +36,11 @@ pub enum Stage {
Dressing,
Naming,
Net,
Cyberspace,
}
impl Stage {
pub const ALL: [Stage; 11] = [
pub const ALL: [Stage; 12] = [
Stage::Terrain,
Stage::Districts,
Stage::Arterials,
@@ -50,6 +52,7 @@ impl Stage {
Stage::Dressing,
Stage::Naming,
Stage::Net,
Stage::Cyberspace,
];
pub fn label(self) -> &'static str {
@@ -65,6 +68,7 @@ impl Stage {
Stage::Dressing => "Hanging neon",
Stage::Naming => "Naming places",
Stage::Net => "Wiring the Net",
Stage::Cyberspace => "Compiling cyberspace",
}
}
@@ -224,6 +228,7 @@ pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner> {
Stage::Dressing => Box::new(dressing::DressingStage::default()),
Stage::Naming => Box::new(naming::NamingStage::default()),
Stage::Net => Box::new(net::NetStage::default()),
Stage::Cyberspace => Box::new(cyberspace::CyberspaceStage::default()),
}
}

View File

@@ -94,7 +94,7 @@ pub fn load_city(path: &Path) -> Result<City> {
city.size.h
);
}
let strata_ok = city.strata.len() == citygen::StratumKind::PHYSICAL.len()
let strata_ok = city.strata.len() == citygen::StratumKind::ALL.len()
&& city
.strata
.iter()

View File

@@ -42,6 +42,7 @@ fn short(stage: Stage) -> &'static str {
Stage::Dressing => "Neon",
Stage::Naming => "Names",
Stage::Net => "Net",
Stage::Cyberspace => "Cyber",
}
}