28 tasks covering the citygen crate (model, eleven generator stages, validation, seed sweeps) and the city crate (app loop, save format, animation, tileset, overlays, widgets, screens, walker). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
117 KiB
City Generator Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A seeded, deterministic multi-stratum cyberpunk city generator (citygen crate) and a ratatui app (city crate) that configures, animates generation, explores, walks, and saves the city, with a Net graph and digital overlay.
Architecture: Cargo workspace with two crates. citygen is pure data + algorithms: a resumable Generator runs eleven chunked stages over a City of Stratum tile grids plus districts, buildings, connectors and a Net graph. city renders tiles through a pure cell() tileset function with an overlay stack, drives screens from a 30 fps tick loop, and owns the walker, menus, animation primitives and persistence.
Tech Stack: Rust edition 2024 (stable), ratatui, crossterm, rand + rand_chacha, noise, serde, postcard, serde_json, ron, thiserror, anyhow, clap, unicode-width (dev).
Spec: docs/superpowers/specs/2026-10-12-city-generator-design.md
Global Constraints
- Rust edition 2024, stable toolchain pinned in
rust-toolchain.toml. citygenmust never depend on ratatui, crossterm or any terminal crate.- Same
GenParams(including seed) ⇒ byte-identicalCity. Iterate collections in index order only; never iterate aHashMap/HashSetto make decisions. - Every tile renders as exactly one terminal cell: every glyph in
flavor/style.ronhas display width 1 (no full-width CJK, no Hangul). - Fixed 30 frames per second; all animation is a function of
tick: u64, never wall-clock. - Minimum terminal 100×30; smaller shows a centered resize message, never panics.
- Size presets: Small 160×80, Medium 240×120, Large 320×160. Districts 4..=12, default 7.
- Save format: magic
CITY,u16version, postcard. Extension.city, directory./cities/. - Gate before every commit:
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace. - Commit messages end with
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>.
Review Focus
- Too many districts for a small map (Small + 12 districts, or
Custom{w:80,h:40}+ 8):GenParams::validate()must reject withInvalidParams { field: "districts" }instead of the generator panicking or looping forever in seed placement. Pinned in Task 4. WaterMode::Nonewith the Docks rule: theme assignment must skip Docks cleanly when no seed touches water, and Industrial must still be placed. Pinned in Task 7.- Tiny or shrinking terminal (1×1, 99×29, resize mid-session): every screen's draw must render the resize message and not index outside the buffer. Pinned in Task 18 (app) and Task 21 (viewport clipping at 1×1).
- Corrupt or truncated
.cityfile (wrong magic, future version, truncated body):load_cityreturns a descriptive error, never panics. Pinned in Task 18. - Double-width glyph sneaking into the style pack: a test asserts every glyph in every glyph set and every terrain/feature mapping has
UnicodeWidthStr::width == 1. Pinned in Task 20.
File structure
Cargo.toml workspace
rust-toolchain.toml
flavor/names.ron citygen flavor pack (names, signage glyphs)
flavor/style.ron city style pack (palettes, glyph sets)
crates/citygen/
Cargo.toml
src/lib.rs module tree + re-exports
src/geom.rs Pos, Size, Rect, Dir, Grid<T>
src/ids.rs DistrictId, BuildingId, SubnetId, NodeId
src/tile.rs Terrain, Feature, Hue, Tile (+ walkability)
src/world.rs StratumKind, Stratum, Theme, District, BuildingKind, StrataSpan, Building, ConnectorKind, Connector, City
src/net.rs Net, Subnet, SubnetOwner, Node, NodeKind, Link
src/params.rs SizePreset, WaterMode, GenParams, GenError
src/rng.rs stage_rng, tile_hash, hash64
src/flavor.rs FlavorPack (ron), name generators
src/theme.rs ThemeSpec table, StreetStyle, HeightClass rules
src/pathing.rs flood_fill, astar, bresenham (shared)
src/gen/mod.rs Stage, Progress, Generator, GenCtx, Scratch, StageRunner
src/gen/terrain.rs .. net.rs one file per stage
src/validate.rs Violation, City::validate
tests/seeds.rs determinism + invariants over seed sets + timing
crates/city/
Cargo.toml
src/main.rs clap entry
src/cli.rs `gen` subcommand
src/save.rs .city format, list_cities
src/term.rs TerminalGuard + panic hook
src/app.rs AppState, Screen trait, ScreenCmd, run loop
src/anim/mod.rs Easing, Tween, Timeline
src/anim/effects.rs glitch, typewriter, flicker, slide
src/render/mod.rs Rgb, Cell, RenderCtx, Visibility re-export
src/render/style.rs StylePack (ron)
src/render/tileset.rs cell()
src/render/lighting.rs lit(), flicker factor
src/render/weather.rs rain/fog overlay
src/render/traffic.rs Traffic state + overlay
src/render/overlay.rs digital (Net) overlay
src/render/viewport.rs Camera, MapView widget
src/render/minimap.rs Minimap widget
src/ui/mod.rs
src/ui/logo.rs block-letter font + Logo widget
src/ui/menu.rs animated Menu
src/ui/form.rs Form + Field
src/ui/panel.rs inspector, legend, strata selector, status bar
src/ui/transition.rs screen transitions
src/walker/mod.rs Player, movement, connectors
src/walker/fov.rs symmetric shadowcasting
src/screens/mod.rs
src/screens/title.rs
src/screens/configure.rs
src/screens/generating.rs
src/screens/explore.rs Explore + Walk modes over one MapScreen
src/screens/files.rs
src/screens/help.rs
Task 1: Workspace scaffold
Files:
- Create:
Cargo.toml,rust-toolchain.toml,crates/citygen/Cargo.toml,crates/citygen/src/lib.rs,crates/city/Cargo.toml,crates/city/src/main.rs
Interfaces:
-
Produces: workspace that builds, both crates empty but linked (
citydepends oncitygen). -
Step 1: Write workspace manifests
Cargo.toml:
[workspace]
resolver = "3"
members = ["crates/citygen", "crates/city"]
[workspace.package]
edition = "2024"
version = "0.1.0"
license = "MIT"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
rand = "0.9"
rand_chacha = "0.9"
noise = "0.9"
thiserror = "2"
anyhow = "1"
ron = "0.10"
postcard = { version = "1", features = ["use-std"] }
serde_json = "1"
ratatui = "0.29"
crossterm = "0.28"
clap = { version = "4", features = ["derive"] }
unicode-width = "0.2"
[profile.dev]
opt-level = 1
[profile.release]
lto = "thin"
rust-toolchain.toml:
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
crates/citygen/Cargo.toml:
[package]
name = "citygen"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
rand.workspace = true
rand_chacha.workspace = true
noise.workspace = true
thiserror.workspace = true
ron.workspace = true
crates/city/Cargo.toml:
[package]
name = "city"
edition.workspace = true
version.workspace = true
license.workspace = true
[[bin]]
name = "city"
path = "src/main.rs"
[dependencies]
citygen = { path = "../citygen" }
serde.workspace = true
postcard.workspace = true
serde_json.workspace = true
anyhow.workspace = true
ratatui.workspace = true
crossterm.workspace = true
clap.workspace = true
ron.workspace = true
[dev-dependencies]
unicode-width.workspace = true
- Step 2: Minimal sources
crates/citygen/src/lib.rs: //! World model and generator for the cyberpunk city. (empty otherwise).
crates/city/src/main.rs: fn main() { println!("city"); }
- Step 3: Build and run the gate
Run: cargo build --workspace && cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings
Expected: success. If a workspace.dependencies version does not resolve, bump to the latest published major and note it in the commit.
- Step 4: Commit
git add Cargo.toml Cargo.lock rust-toolchain.toml crates
git commit -m "chore: cargo workspace with citygen and city crates"
Task 2: Geometry and grid
Files:
- Create:
crates/citygen/src/geom.rs,crates/citygen/src/ids.rs - Modify:
crates/citygen/src/lib.rs
Interfaces:
-
Produces:
Pos { x: i32, y: i32 }withnew,offset(dx,dy),step(Dir),manhattan,chebyshev,dist2 -> i64,neighbors4(),neighbors8().Size { w: u32, h: u32 }witharea() -> usize,contains(Pos),index(Pos) -> usize,center() -> Pos.Rect { x, y, w, h }(w,h ≥ 0; right/bottom exclusive) withnew,right,bottom,contains,intersects,intersection -> Option<Rect>,inset(n),center,area,iter(),border(),clamp_to(Rect) -> Option<Rect>,is_empty.Dir8 variants,Dir::CARDINAL,Dir::ALL,delta(),opposite().Grid<T>:filled(size, v),size(),get,get_mut,set -> bool,in_bounds,iter() -> (Pos,&T),fill_rect,as_slice,Index<Pos>/IndexMut<Pos>,map(|&T| -> U) -> Grid<U>,count(pred).DistrictId(u32),BuildingId(u32),SubnetId(u32),NodeId(u32)each withindex() -> usize.
-
Step 1: Write failing tests (in
geom.rs#[cfg(test)] mod tests)
#[test]
fn rect_iter_and_border() {
let r = Rect::new(2, 3, 3, 2);
assert_eq!(r.area(), 6);
assert_eq!(r.iter().count(), 6);
assert_eq!(r.border().count(), 6); // 3x2: all cells are border
assert!(r.contains(Pos::new(4, 4)));
assert!(!r.contains(Pos::new(5, 4)));
assert_eq!(r.inset(1), Rect::new(3, 4, 1, 0));
}
#[test]
fn rect_intersection_and_clamp() {
let a = Rect::new(0, 0, 10, 10);
let b = Rect::new(5, 5, 10, 10);
assert_eq!(a.intersection(b), Some(Rect::new(5, 5, 5, 5)));
assert_eq!(a.intersection(Rect::new(20, 20, 1, 1)), None);
assert_eq!(b.clamp_to(a), Some(Rect::new(5, 5, 5, 5)));
}
#[test]
fn grid_set_get_oob() {
let mut g = Grid::filled(Size { w: 4, h: 3 }, 0u8);
assert!(g.set(Pos::new(3, 2), 7));
assert!(!g.set(Pos::new(4, 2), 7));
assert_eq!(g.get(Pos::new(3, 2)), Some(&7));
assert_eq!(g.get(Pos::new(-1, 0)), None);
assert_eq!(g.iter().count(), 12);
g.fill_rect(Rect::new(0, 0, 2, 2), 9);
assert_eq!(g.count(|&v| v == 9), 4);
}
#[test]
fn dir_delta_opposite() {
assert_eq!(Dir::N.delta(), (0, -1));
assert_eq!(Dir::SE.opposite(), Dir::NW);
assert_eq!(Pos::new(1, 1).step(Dir::W), Pos::new(0, 1));
assert_eq!(Pos::new(0, 0).chebyshev(Pos::new(3, -2)), 3);
}
- Step 2: Run to verify failure
Run: cargo test -p citygen → compile error (types missing).
- Step 3: Implement
// geom.rs
use serde::{Deserialize, Serialize};
use std::ops::{Index, IndexMut};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Pos { pub x: i32, pub y: i32 }
impl Pos {
pub const fn new(x: i32, y: i32) -> Self { Self { x, y } }
pub fn offset(self, dx: i32, dy: i32) -> Self { Self::new(self.x + dx, self.y + dy) }
pub fn step(self, d: Dir) -> Self { let (dx, dy) = d.delta(); self.offset(dx, dy) }
pub fn manhattan(self, o: Pos) -> i32 { (self.x - o.x).abs() + (self.y - o.y).abs() }
pub fn chebyshev(self, o: Pos) -> i32 { (self.x - o.x).abs().max((self.y - o.y).abs()) }
pub fn dist2(self, o: Pos) -> i64 { let dx = (self.x - o.x) as i64; let dy = (self.y - o.y) as i64; dx * dx + dy * dy }
pub fn neighbors4(self) -> impl Iterator<Item = Pos> { Dir::CARDINAL.into_iter().map(move |d| self.step(d)) }
pub fn neighbors8(self) -> impl Iterator<Item = Pos> { Dir::ALL.into_iter().map(move |d| self.step(d)) }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Size { pub w: u32, pub h: u32 }
impl Size {
pub fn area(self) -> usize { self.w as usize * self.h as usize }
pub fn contains(self, p: Pos) -> bool { p.x >= 0 && p.y >= 0 && (p.x as u32) < self.w && (p.y as u32) < self.h }
pub fn index(self, p: Pos) -> usize { p.y as usize * self.w as usize + p.x as usize }
pub fn center(self) -> Pos { Pos::new(self.w as i32 / 2, self.h as i32 / 2) }
pub fn rect(self) -> Rect { Rect::new(0, 0, self.w as i32, self.h as i32) }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Rect { pub x: i32, pub y: i32, pub w: i32, pub h: i32 }
impl Rect {
pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Self { Self { x, y, w: if w < 0 { 0 } else { w }, h: if h < 0 { 0 } else { h } } }
pub fn right(self) -> i32 { self.x + self.w }
pub fn bottom(self) -> i32 { self.y + self.h }
pub fn is_empty(self) -> bool { self.w == 0 || self.h == 0 }
pub fn area(self) -> usize { (self.w as usize) * (self.h as usize) }
pub fn contains(self, p: Pos) -> bool { p.x >= self.x && p.y >= self.y && p.x < self.right() && p.y < self.bottom() }
pub fn intersects(self, o: Rect) -> bool { self.x < o.right() && o.x < self.right() && self.y < o.bottom() && o.y < self.bottom() }
pub fn intersection(self, o: Rect) -> Option<Rect> {
let x = self.x.max(o.x); let y = self.y.max(o.y);
let r = self.right().min(o.right()); let b = self.bottom().min(o.bottom());
if r > x && b > y { Some(Rect::new(x, y, r - x, b - y)) } else { None }
}
pub fn clamp_to(self, bounds: Rect) -> Option<Rect> { self.intersection(bounds) }
pub fn inset(self, n: i32) -> Rect { Rect::new(self.x + n, self.y + n, self.w - 2 * n, self.h - 2 * n) }
pub fn center(self) -> Pos { Pos::new(self.x + self.w / 2, self.y + self.h / 2) }
pub fn iter(self) -> impl Iterator<Item = Pos> {
(self.y..self.bottom()).flat_map(move |y| (self.x..self.right()).map(move |x| Pos::new(x, y)))
}
pub fn border(self) -> impl Iterator<Item = Pos> {
self.iter().filter(move |p| p.x == self.x || p.y == self.y || p.x == self.right() - 1 || p.y == self.bottom() - 1)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Dir { N, NE, E, SE, S, SW, W, NW }
impl Dir {
pub const CARDINAL: [Dir; 4] = [Dir::N, Dir::E, Dir::S, Dir::W];
pub const ALL: [Dir; 8] = [Dir::N, Dir::NE, Dir::E, Dir::SE, Dir::S, Dir::SW, Dir::W, Dir::NW];
pub fn delta(self) -> (i32, i32) {
match self { Dir::N => (0, -1), Dir::NE => (1, -1), Dir::E => (1, 0), Dir::SE => (1, 1),
Dir::S => (0, 1), Dir::SW => (-1, 1), Dir::W => (-1, 0), Dir::NW => (-1, -1) }
}
pub fn opposite(self) -> Dir {
match self { Dir::N => Dir::S, Dir::NE => Dir::SW, Dir::E => Dir::W, Dir::SE => Dir::NW,
Dir::S => Dir::N, Dir::SW => Dir::NE, Dir::W => Dir::E, Dir::NW => Dir::SE }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grid<T> { size: Size, data: Vec<T> }
impl<T: Clone> Grid<T> {
pub fn filled(size: Size, v: T) -> Self { Self { size, data: vec![v; size.area()] } }
pub fn size(&self) -> Size { self.size }
pub fn in_bounds(&self, p: Pos) -> bool { self.size.contains(p) }
pub fn get(&self, p: Pos) -> Option<&T> { self.in_bounds(p).then(|| &self.data[self.size.index(p)]) }
pub fn get_mut(&mut self, p: Pos) -> Option<&mut T> { if self.in_bounds(p) { let i = self.size.index(p); Some(&mut self.data[i]) } else { None } }
pub fn set(&mut self, p: Pos, v: T) -> bool { match self.get_mut(p) { Some(slot) => { *slot = v; true } None => false } }
pub fn iter(&self) -> impl Iterator<Item = (Pos, &T)> {
let w = self.size.w as i32;
self.data.iter().enumerate().map(move |(i, t)| (Pos::new(i as i32 % w, i as i32 / w), t))
}
pub fn fill_rect(&mut self, r: Rect, v: T) { for p in r.iter() { self.set(p, v.clone()); } }
pub fn as_slice(&self) -> &[T] { &self.data }
pub fn map<U: Clone>(&self, f: impl Fn(&T) -> U) -> Grid<U> { Grid { size: self.size, data: self.data.iter().map(f).collect() } }
pub fn count(&self, pred: impl Fn(&T) -> bool) -> usize { self.data.iter().filter(|t| pred(t)).count() }
}
impl<T: Clone> Index<Pos> for Grid<T> { type Output = T; fn index(&self, p: Pos) -> &T { self.get(p).expect("grid index out of bounds") } }
impl<T: Clone> IndexMut<Pos> for Grid<T> { fn index_mut(&mut self, p: Pos) -> &mut T { self.get_mut(p).expect("grid index out of bounds") } }
ids.rs:
use serde::{Deserialize, Serialize};
macro_rules! id_type {
($name:ident) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct $name(pub u32);
impl $name { pub fn index(self) -> usize { self.0 as usize } }
};
}
id_type!(DistrictId); id_type!(BuildingId); id_type!(SubnetId); id_type!(NodeId);
lib.rs: pub mod geom; pub mod ids; pub use geom::{Dir, Grid, Pos, Rect, Size}; pub use ids::*;
- Step 4: Run tests →
cargo test -p citygenPASS. - Step 5: Commit
git commit -am "feat(citygen): geometry primitives and Grid"(aftergit add).
Task 3: Tiles, world and net types
Files:
- Create:
crates/citygen/src/tile.rs,crates/citygen/src/world.rs,crates/citygen/src/net.rs - Modify:
crates/citygen/src/lib.rs
Interfaces:
-
Produces (all
Serialize, Deserialize, Clone, Debug; enumsCopy, PartialEq, Eq, Hash):Terrain(20 variants per spec),Hue { Cyan, Magenta, Pink, Orange, Yellow, Green, Blue, Red, White },Feature(per spec,Sign(char)).Tile { terrain, feature: Option<Feature>, district: Option<DistrictId>, building: Option<BuildingId>, variant: u8 },Tile::VOID,Tile::new(Terrain),is_walkable(),blocks_sight(),is_outdoor_ground()(Road|Sidewalk|Alley|Plaza),is_building_part()(Wall|Floor|Door|Window).StratumKind { Undercity, Street, Skyways, Heights, Net },StratumKind::PHYSICAL: [StratumKind;4],label(),index() -> usize(physical order),from_index.Stratum { kind, tiles: Grid<Tile>, light: Grid<u8> },Stratum::new(kind, size).Theme(8),Theme::ALL,label().District { id, name, theme, center, wealth, decay, bounds }.BuildingKind(19 per spec),HeightClass { Low, Mid, Tall }.StrataSpan { lo, hi }withcontains(k),iter(),len(),StrataSpan::from_class(class, basement: bool).Building { id, kind, district, footprint, span, name: Option<String> }.ConnectorKind { Stair, Lift, Ladder, Manhole, Jack },Connector { kind, pos, serves: Vec<StratumKind>, public: bool },Connector::other(from) -> Option<StratumKind>(next/prev not needed;serves_from(k) -> Option<(Option<StratumKind>, Option<StratumKind>)>returns (below, above) neighbors inserves).City { seed, params: GenParams, name, size, strata: Vec<Stratum>, districts, buildings, connectors, arterials: Vec<Vec<Pos>>, net: Net }withempty(params),stratum(k) -> Option<&Stratum>,stratum_mut,tile(k, p) -> Option<&Tile>,tile_mut,connectors_at(p) -> impl Iterator<Item=&Connector>,district(id) -> &District,building(id) -> &Building,walkable(k, p) -> bool.Net { subnets, nodes, links: Vec<Link> },Link(pub NodeId, pub NodeId),Subnet { id, owner, security },SubnetOwner,Node { id, kind, anchor: (StratumKind, Pos), subnet, building },NodeKind(10),Net::nodes_on(k) -> impl Iterator<Item=&Node>,Net::node_at(k, p) -> Option<&Node>.
-
Note:
GenParamsis defined in Task 4;Cityreferences it, so Task 3 includes a minimalparams.rswith the struct andDefaultonly (Task 4 adds validation andGenError). -
Step 1: Tests
// tile.rs tests
#[test]
fn walkability_rules() {
assert!(Tile::new(Terrain::Sidewalk).is_walkable());
assert!(!Tile::new(Terrain::Wall).is_walkable());
assert!(!Tile::new(Terrain::Water).is_walkable());
let mut t = Tile::new(Terrain::Road);
t.feature = Some(Feature::Barrier);
assert!(!t.is_walkable());
t.feature = Some(Feature::Lamp);
assert!(t.is_walkable());
assert!(Tile::new(Terrain::Wall).blocks_sight());
assert!(Tile::new(Terrain::Door).blocks_sight());
assert!(!Tile::new(Terrain::Window).blocks_sight());
assert!(!Tile::new(Terrain::Window).is_walkable());
}
// world.rs tests
#[test]
fn span_iter_and_class() {
let s = StrataSpan::from_class(HeightClass::Tall, true);
assert_eq!(s.iter().collect::<Vec<_>>(), StratumKind::PHYSICAL.to_vec());
let s = StrataSpan::from_class(HeightClass::Mid, false);
assert_eq!(s.iter().collect::<Vec<_>>(), vec![StratumKind::Street, StratumKind::Skyways]);
assert!(s.contains(StratumKind::Skyways));
assert!(!s.contains(StratumKind::Heights));
}
#[test]
fn city_empty_has_four_void_strata() {
let c = City::empty(GenParams { size: SizePreset::Custom { w: 10, h: 5 }, ..GenParams::default() });
assert_eq!(c.strata.len(), 4);
assert_eq!(c.stratum(StratumKind::Heights).unwrap().tiles.size(), Size { w: 10, h: 5 });
assert_eq!(c.tile(StratumKind::Street, Pos::new(3, 3)).unwrap().terrain, Terrain::Void);
assert!(c.tile(StratumKind::Street, Pos::new(10, 3)).is_none());
}
#[test]
fn connector_neighbors() {
let c = Connector { kind: ConnectorKind::Lift, pos: Pos::new(0, 0), serves: vec![StratumKind::Street, StratumKind::Skyways, StratumKind::Heights], public: false };
assert_eq!(c.serves_from(StratumKind::Skyways), Some((Some(StratumKind::Street), Some(StratumKind::Heights))));
assert_eq!(c.serves_from(StratumKind::Heights), Some((Some(StratumKind::Skyways), None)));
assert_eq!(c.serves_from(StratumKind::Undercity), None);
}
-
Step 2: Run → compile failure.
-
Step 3: Implement
Walkability:
pub fn is_walkable(&self) -> bool {
use Terrain::*;
let terrain_ok = matches!(self.terrain, Road | Sidewalk | Alley | Plaza | Floor | Door | Rooftop | Walkway | Bridge | Tunnel | Rail | Stair | Lift | Ladder | Grate);
let feature_ok = !matches!(self.feature, Some(Feature::Barrier | Feature::Stall | Feature::AcUnit | Feature::Pipe | Feature::Terminal));
terrain_ok && feature_ok
}
pub fn blocks_sight(&self) -> bool { matches!(self.terrain, Terrain::Wall | Terrain::Door) }
StratumKind::index: Undercity 0, Street 1, Skyways 2, Heights 3, Net 4. StrataSpan::from_class: Low → Street..=Street, Mid → Street..=Skyways, Tall → Street..=Heights; basement lowers lo to Undercity. City::empty builds four Stratum::new for PHYSICAL, arterials: vec![], net: Net::default(), name: String::new(), seed: params.seed, size: params.size.size().
Minimal params.rs for this task:
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum SizePreset { Small, Medium, Large, Custom { w: u32, h: u32 } }
impl SizePreset {
pub fn size(self) -> Size { match self { Self::Small => Size { w: 160, h: 80 }, Self::Medium => Size { w: 240, h: 120 }, Self::Large => Size { w: 320, h: 160 }, Self::Custom { w, h } => Size { w, h } } }
pub fn label(self) -> &'static str { match self { Self::Small => "small", Self::Medium => "medium", Self::Large => "large", Self::Custom { .. } => "custom" } }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum WaterMode { None, Bay, River }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GenParams { pub seed: u64, pub size: SizePreset, pub districts: u8, pub density: f32, pub wealth_skew: f32, pub decay: f32, pub water: WaterMode, pub flavor: String }
impl Default for GenParams { fn default() -> Self { Self { seed: 0, size: SizePreset::Medium, districts: 7, density: 0.6, wealth_skew: 0.0, decay: 0.4, water: WaterMode::Bay, flavor: "default".into() } } }
- Step 4: Run tests → PASS. Run clippy gate.
- Step 5: Commit
feat(citygen): tile, world and net data model.
Task 4: Params validation, errors, RNG and hashing
Files:
- Modify:
crates/citygen/src/params.rs - Create:
crates/citygen/src/rng.rs - Modify:
crates/citygen/src/lib.rs
Interfaces:
-
Produces:
GenError(thiserror):InvalidParams { field: &'static str, reason: String },Unconnected { stratum: StratumKind },Flavor(String),Invalid(Vec<Violation>)(Violation defined in Task 17; until then declarepub struct Violation(pub String)invalidate.rsnow, Task 17 extends it to an enum).GenParams::validate(&self) -> Result<(), GenError>;GenParams::with_seed(self, u64) -> Self.rng::stage_rng(seed: u64, stage: u8) -> ChaCha8Rng,rng::hash64(a: u64, b: u64) -> u64(splitmix-style),rng::tile_hash(seed, k: StratumKind, p: Pos) -> u8,rng::chance(rng, p: f32) -> bool,rng::pick_weighted<'a, T>(rng, &'a [(T, u32)]) -> &'a T.
-
Step 1: Tests
#[test]
fn validate_rejects_bad_params() {
let bad = GenParams { districts: 13, ..Default::default() };
assert!(matches!(bad.validate(), Err(GenError::InvalidParams { field: "districts", .. })));
let bad = GenParams { size: SizePreset::Custom { w: 80, h: 40 }, districts: 8, ..Default::default() };
assert!(matches!(bad.validate(), Err(GenError::InvalidParams { field: "districts", .. })));
let bad = GenParams { size: SizePreset::Custom { w: 40, h: 40 }, ..Default::default() };
assert!(matches!(bad.validate(), Err(GenError::InvalidParams { field: "size", .. })));
let bad = GenParams { density: 1.5, ..Default::default() };
assert!(matches!(bad.validate(), Err(GenError::InvalidParams { field: "density", .. })));
assert!(GenParams::default().validate().is_ok());
assert!(GenParams { size: SizePreset::Small, districts: 12, ..Default::default() }.validate().is_ok());
}
#[test]
fn rng_is_deterministic_and_stage_isolated() {
use rand::Rng;
let a: u64 = stage_rng(42, 3).random(); let b: u64 = stage_rng(42, 3).random();
let c: u64 = stage_rng(42, 4).random();
assert_eq!(a, b); assert_ne!(a, c);
assert_eq!(tile_hash(1, StratumKind::Street, Pos::new(5, 6)), tile_hash(1, StratumKind::Street, Pos::new(5, 6)));
assert_ne!(tile_hash(1, StratumKind::Street, Pos::new(5, 6)), tile_hash(1, StratumKind::Skyways, Pos::new(5, 6)));
}
-
Step 2: Run → fail.
-
Step 3: Implement
Validation rules: w in 80..=1024 and h in 40..=512 else field "size"; districts 4..=12 else "districts"; w*h >= districts*1000 else "districts" with reason "too many districts for map size"; density 0..=1 "density"; wealth_skew -1..=1 "wealth_skew"; decay 0..=1 "decay"; flavor non-empty "flavor".
// rng.rs
pub fn hash64(mut a: u64, b: u64) -> u64 {
a ^= b.wrapping_mul(0x9E37_79B9_7F4A_7C15);
a = (a ^ (a >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
a = (a ^ (a >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
a ^ (a >> 31)
}
pub fn stage_rng(seed: u64, stage: u8) -> ChaCha8Rng { ChaCha8Rng::seed_from_u64(hash64(seed, 0xC17_u64 + stage as u64 * 0x1000)) }
pub fn tile_hash(seed: u64, k: StratumKind, p: Pos) -> u8 {
(hash64(hash64(seed, k.index() as u64), ((p.x as u32 as u64) << 32) | p.y as u32 as u64) >> 56) as u8
}
pub fn chance(rng: &mut impl Rng, p: f32) -> bool { p > 0.0 && rng.random::<f32>() < p }
pub fn pick_weighted<'a, T>(rng: &mut impl Rng, items: &'a [(T, u32)]) -> &'a T {
let total: u32 = items.iter().map(|(_, w)| *w).sum();
let mut roll = rng.random_range(0..total.max(1));
for (item, w) in items { if roll < *w { return item; } roll -= *w; }
&items[items.len() - 1].0
}
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): params validation, GenError, seeded rng helpers.
Task 5: Flavor pack and theme table
Files:
- Create:
flavor/names.ron,crates/citygen/src/flavor.rs,crates/citygen/src/theme.rs - Modify:
crates/citygen/src/lib.rs
Interfaces:
-
Produces:
FlavorPack(serde, from RON):city: NameParts { prefixes, suffixes },districts: Vec<(Theme, NameParts)>(vector, not map, for ordering),corp: CorpParts { first: Vec<String>, second: Vec<String>, suffix: Vec<String> },shops: Vec<(BuildingKind, Vec<String>)>templates with{syl}placeholder,landmarks: Vec<String>templates with{corp}and{district},signage: Vec<char>.FlavorPack::load(name: &str) -> Result<FlavorPack, GenError>("default" isinclude_str!("../../../flavor/names.ron"); anything else →GenError::Flavor).FlavorPack::city_name(&self, rng) -> String,district_name(theme, rng),corp_name(rng),shop_name(kind, rng) -> Option<String>,landmark_name(corp: &str, district: &str, rng),sign_glyph(rng) -> char.theme.rs:StreetStyle { Grid, Organic, Superblock, Rows, Shattered, Strip },ThemeSpec { base_wealth, base_decay, street_style, block: (i32,i32), min_lot: (i32,i32), mix: &'static [(BuildingKind, u32)], tall: f32, mid: f32, basement: f32, plaza: f32, neon: f32, holo: f32, lamp_spacing: i32, security: u8, hues: &'static [Hue] },Theme::spec(self) -> &'static ThemeSpec,BuildingKind::height_classes(self) -> &'static [HeightClass],BuildingKind::label,BuildingKind::is_corp(self) -> bool(CorpTower|Office|Megablock).
-
Step 1: Tests
#[test]
fn default_flavor_loads_and_names_are_deterministic() {
let f = FlavorPack::load("default").unwrap();
assert!(FlavorPack::load("nope").is_err());
let mut a = stage_rng(7, 9); let mut b = stage_rng(7, 9);
assert_eq!(f.city_name(&mut a), f.city_name(&mut b));
for t in Theme::ALL { assert!(!f.district_name(t, &mut a).is_empty()); }
assert!(f.shop_name(BuildingKind::NoodleBar, &mut a).is_some());
assert!(f.shop_name(BuildingKind::CorpTower, &mut a).is_none());
assert!(f.signage.iter().all(|c| unicode_single_width(*c)));
}
#[test]
fn theme_mix_weights_nonzero() {
for t in Theme::ALL { let s = t.spec(); assert!(s.mix.iter().map(|(_, w)| *w).sum::<u32>() > 0); assert!(s.min_lot.0 >= 4 && s.min_lot.1 >= 3); }
assert_eq!(BuildingKind::CorpTower.height_classes(), &[HeightClass::Tall]);
}
unicode_single_width in the test: c as u32 >= 0x20 && !(0x1100..=0x115F).contains(&(c as u32)) && !(0x2E80..=0xA4CF).contains(&(c as u32)) && !(0xAC00..=0xD7A3).contains(&(c as u32)) && !(0xF900..=0xFAFF).contains(&(c as u32)) && !(0xFE30..=0xFE4F).contains(&(c as u32)) && !(0xFF00..=0xFF60).contains(&(c as u32)) && !(0xFFE0..=0xFFE6).contains(&(c as u32)) (half-width katakana U+FF66..FF9D passes). The real width check with unicode-width lives in the city crate (Task 20).
-
Step 2: Run → fail.
-
Step 3: Implement
flavor/names.ron (abbreviated here; the real file has ≥ 12 entries per list):
FlavorPack(
city: NameParts(prefixes: ["Neo", "New", "Night", "Grey", "Ash", "Iron", "Hollow", "Saint", "Port", "North", "Low", "Vanta"],
suffixes: ["haven", "port", "gate", "spire", "fall", "reach", "burg", "side", "mouth", "wick", "crest", "shore"]),
districts: [
(CorpCore, NameParts(prefixes: ["Apex", "Meridian", "Zenith", "Crown", "Axis", "Prime", "Nexus", "Vector", "Summit", "Core", "Helix", "Aurum"], suffixes: [" Plaza", " Center", " Heights", " District", " Row", " Quarter", " Terrace", " Exchange", " Circle", " Mile", " Spire", " Square"])),
(NightMarket, NameParts(prefixes: ["Kowloon", "Lantern", "Ginza", "Saigon", "Jade", "Paper", "Smoke", "Bazaar", "Spice", "Dragon", "Lotus", "Silk"], suffixes: [" Market", " Alley", " Walk", " Ward", " Lanes", " Steps", " Street", " Yard", " Court", " Strip", " Row", " Block"])),
// ... Megablocks, CombatZone, Docks, Industrial, NeonStrip, OldTown
],
corp: CorpParts(first: ["Arasu", "Kiro", "Nov", "Hex", "Mira", "Tess", "Oro", "Vel", "Kai", "Zen", "Dai", "Sol"],
second: ["tek", "dyne", "corp", "soft", "gen", "ware", "sys", "line", "med", "sec", "net", "bio"],
suffix: ["", " Industries", " Group", " Holdings", " Systems", " Biotech", " Dynamics", " Securities", " Logistics", " Media", " Capital", " Labs"]),
shops: [
(NoodleBar, ["{syl} Noodles", "Ramen {syl}", "{syl} Kitchen", "Hot {syl}", "{syl} Bowl"]),
(Bar, ["The {syl}", "{syl} Lounge", "Bar {syl}", "{syl} & Static", "Dive {syl}"]),
(Club, ["Club {syl}", "{syl} Afterdark", "Velvet {syl}", "{syl} Pulse"]),
(Clinic, ["{syl} Ripperdoc", "Clinic {syl}", "{syl} Chrome", "Doc {syl}"]),
(Shop, ["{syl} Goods", "{syl} Mart", "24h {syl}", "{syl} Supply"]),
(Pawn, ["{syl} Pawn", "{syl} & Sons", "Cash {syl}"]),
(Arcade, ["{syl} Arcade", "Neon {syl}", "{syl} Braindance"]),
(Hotel, ["Hotel {syl}", "{syl} Capsule", "The {syl} Inn"]),
(Temple, ["{syl} Temple", "Shrine of {syl}", "{syl} Mission"]),
],
landmarks: ["{corp} Tower", "{district} Terminal", "The {district} Arcology", "{corp} Plaza", "Old {district} Station"],
signage: ['ヲ','ァ','ィ','ゥ','ェ','ォ','ャ','ュ','ョ','ッ','ー','ア','イ','ウ','エ','オ','カ','キ','ク','ケ','コ','サ','シ','ス','セ','ソ','タ','チ','ツ','テ','ト','ナ','ニ','ヌ','ネ','ノ','ハ','ヒ','フ','ヘ','ホ','マ','ミ','ム','メ','モ','ヤ','ユ','ヨ','ラ','リ','ル','レ','ロ','ワ','ン','Ж','Ф','Ц','Ш','Я','Δ','Σ','Ψ','Ω','¥','$','€','§','¤'],
)
Name generation: city_name = random prefix + random suffix; district_name = prefix + suffix from the theme entry; corp_name = first + second + suffix (capitalized); shop_name replaces {syl} with a two-syllable word built from corp.first lowercased + corp.second, then capitalizes; landmark_name substitutes placeholders.
theme.rs spec table (one static per theme; values below are the ones to use):
| Theme | wealth | decay | style | block | min_lot | tall | mid | basement | plaza | neon | holo | lamp | security | hues |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CorpCore | .9 | .1 | Grid | (24,12) | (10,7) | .7 | .3 | .6 | .25 | .3 | .5 | 8 | 200 | Cyan,White,Blue |
| NightMarket | .4 | .5 | Organic | (16,9) | (5,4) | .05 | .3 | .1 | .3 | .8 | .3 | 12 | 60 | Magenta,Orange,Yellow |
| Megablocks | .3 | .6 | Superblock | (40,20) | (18,10) | .9 | .1 | .5 | .1 | .2 | .2 | 10 | 110 | Yellow,White,Orange |
| CombatZone | .1 | .9 | Shattered | (16,10) | (5,4) | .05 | .2 | .1 | .15 | .3 | .05 | 16 | 20 | Red,Yellow,Orange |
| Docks | .3 | .6 | Rows | (30,14) | (12,8) | .0 | .2 | .2 | .1 | .2 | .05 | 14 | 70 | Cyan,Green,Orange |
| Industrial | .25 | .7 | Rows | (28,14) | (12,8) | .05 | .3 | .4 | .05 | .15 | .05 | 14 | 90 | Yellow,Green,White |
| NeonStrip | .6 | .3 | Strip | (18,10) | (6,4) | .2 | .5 | .2 | .2 | .95 | .6 | 6 | 120 | Pink,Blue,Magenta |
| OldTown | .5 | .4 | Organic | (14,9) | (5,4) | .0 | .25 | .15 | .25 | .4 | .1 | 12 | 80 | Orange,Green,White |
Mixes (kind, weight): CorpCore {CorpTower 5, Office 4, Hotel 1, Parking 1}; NightMarket {Shop 4, NoodleBar 4, Bar 2, Market 2, Clinic 1, Pawn 1, Apartment 2}; Megablocks {Megablock 6, Apartment 3, Shop 1}; CombatZone {Apartment 3, Shop 2, Bar 2, Pawn 2, Clinic 1, Garage 1}; Docks {Warehouse 6, Depot 2, Bar 1, Garage 1}; Industrial {Factory 5, Warehouse 3, Depot 2, Garage 1}; NeonStrip {Club 4, Bar 3, Arcade 3, Hotel 2, Shop 1}; OldTown {Apartment 4, Shop 2, Temple 1, NoodleBar 2, Bar 1, Clinic 1}.
Height classes: CorpTower [Tall]; Megablock [Tall]; Office [Mid, Tall]; Hotel [Mid, Tall]; Apartment [Mid, Low]; Factory [Low, Mid]; Parking [Low, Mid]; everything else [Low]. Selection: pick from allowed list using theme tall/mid chances where applicable, else Low.
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): flavor pack and theme table.
Task 6: Pathing helpers and generator skeleton with Terrain stage
Files:
- Create:
crates/citygen/src/pathing.rs,crates/citygen/src/gen/mod.rs,crates/citygen/src/gen/terrain.rs - Modify:
crates/citygen/src/lib.rs
Interfaces:
-
Produces:
pathing::flood_fill(size: Size, start: Pos, passable: impl Fn(Pos)->bool) -> Vec<Pos>(4-connected).pathing::components(size, passable) -> Vec<Vec<Pos>>(all 4-connected components, largest first, deterministic order by first cell index).pathing::astar(size, start, goal, cost: impl Fn(Pos, Pos)->Option<u32>) -> Option<Vec<Pos>>(4-neighbour;cost(from, to)returnsNonefor impassable; includes start and goal).pathing::bfs_nearest(size, start, passable: Fn(Pos)->bool, target: Fn(Pos)->bool, max: usize) -> Option<Vec<Pos>>(shortest 4-path through passable to the nearest target; path includes start and the target).pathing::bresenham(a: Pos, b: Pos) -> Vec<Pos>.gen::Stage(11 variants,Stage::ALL,label(),index() -> u8),gen::Progress { Running { stage: Stage, pct: f32 }, Done }.gen::Generator::{new(params) -> Result<Self, GenError>, step(&mut self) -> Progress, city(&self) -> &City, finish(self) -> Result<City, GenError>, run(params) -> Result<City, GenError>, error(&self) -> Option<&GenError>}.pub(crate) struct GenCtx { city: City, flavor: FlavorPack, seed: u64, scratch: Scratch },pub(crate) struct Scratch { land: Grid<bool>, wealth: Grid<f32>, decay: Grid<f32>, arterial: Grid<bool>, road: Grid<bool>, blocks: Vec<Block>, lots: Vec<Lot>, water_edge: Option<Dir> },Block { rect: Rect, district: DistrictId },Lot { rect, district, use_: LotUse },LotUse { Building { kind, class, basement }, Plaza, Parking }.pub(crate) trait StageRunner { fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError>; fn step(&mut self, ctx: &mut GenCtx) -> Result<StepOut, GenError>; },StepOut { Continue(f32), Finished }.pub(crate) fn runner_for(stage: Stage) -> Box<dyn StageRunner>(stages not yet implemented return aNoopStagethat finishes immediately; each later task replaces its entry).GenCtxhelpers:rng(&self, stage) -> ChaCha8Rng,size(),set_terrain(k, p, Terrain),terrain(k, p) -> Terrain(Void if OOB),set_feature,in_district(p, id) -> bool.
-
Step 1: Tests
// pathing.rs
#[test]
fn astar_routes_around_block() {
let size = Size { w: 5, h: 5 };
let wall = |p: Pos| p.x == 2 && p.y != 4;
let path = astar(size, Pos::new(0, 0), Pos::new(4, 0), |_, to| if wall(to) { None } else { Some(1) }).unwrap();
assert_eq!(path[0], Pos::new(0, 0)); assert_eq!(*path.last().unwrap(), Pos::new(4, 0));
assert!(path.iter().all(|p| !wall(*p)));
assert_eq!(path.len(), 13);
}
#[test]
fn components_are_largest_first() {
let size = Size { w: 6, h: 1 };
let pass = |p: Pos| p.x != 2; // {0,1} and {3,4,5}
let c = components(size, pass);
assert_eq!(c.len(), 2); assert_eq!(c[0].len(), 3); assert_eq!(c[1].len(), 2);
}
#[test]
fn bfs_nearest_finds_target() {
let size = Size { w: 10, h: 1 };
let p = bfs_nearest(size, Pos::new(0, 0), |_| true, |p| p.x == 7, 20).unwrap();
assert_eq!(p.len(), 8);
assert!(bfs_nearest(size, Pos::new(0, 0), |_| true, |p| p.x == 7, 3).is_none());
}
// gen/mod.rs
#[test]
fn generator_walks_all_stages_in_order() {
let mut g = Generator::new(GenParams { seed: 1, size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }).unwrap();
let mut seen = vec![];
loop { match g.step() { Progress::Running { stage, pct } => { assert!((0.0..=1.0).contains(&pct)); if seen.last() != Some(&stage) { seen.push(stage); } } Progress::Done => break } }
assert_eq!(seen, Stage::ALL.to_vec());
}
// gen/terrain.rs
#[test]
fn bay_covers_ten_to_twenty_percent_of_street() {
for seed in 0..5 {
let c = Generator::run(GenParams { seed, water: WaterMode::Bay, size: SizePreset::Custom { w: 120, h: 60 }, districts: 4, ..Default::default() }).unwrap();
let st = c.stratum(StratumKind::Street).unwrap();
let water = st.tiles.count(|t| t.terrain == Terrain::Water) as f32 / st.tiles.size().area() as f32;
assert!((0.08..=0.25).contains(&water), "seed {seed}: {water}");
}
let c = Generator::run(GenParams { seed: 3, water: WaterMode::None, size: SizePreset::Custom { w: 120, h: 60 }, districts: 4, ..Default::default() }).unwrap();
assert_eq!(c.stratum(StratumKind::Street).unwrap().tiles.count(|t| t.terrain == Terrain::Water), 0);
}
Until later stages exist, Generator::run passes because finish() only validates when all stages are real; implement finish() as: if error → Err; else City::validate (Task 17 adds it; until then validate returns Ok). Note the terrain test calls Generator::run with the Noop stages; it stays green through later tasks.
-
Step 2: Run → fail.
-
Step 3: Implement
A* uses BinaryHeap<Reverse<(u32, u32, Pos)>> keyed by f = g + manhattan, with a tie-break counter for determinism, came_from: Grid<Option<Pos>>, g: Grid<u32> initialised to u32::MAX. Flood fill and BFS use VecDeque and a Grid<bool> visited.
Generator step machinery:
pub struct Generator { ctx: GenCtx, stage_idx: usize, runner: Option<Box<dyn StageRunner>>, error: Option<GenError>, done: bool }
impl Generator {
pub fn new(params: GenParams) -> Result<Self, GenError> {
params.validate()?;
let flavor = FlavorPack::load(¶ms.flavor)?;
let city = City::empty(params.clone());
let size = city.size;
let scratch = Scratch::new(size);
Ok(Self { ctx: GenCtx { seed: params.seed, city, flavor, scratch }, stage_idx: 0, runner: None, error: None, done: false })
}
pub fn step(&mut self) -> Progress {
if self.done || self.error.is_some() { return Progress::Done; }
let stage = Stage::ALL[self.stage_idx];
if self.runner.is_none() {
let mut r = runner_for(stage);
if let Err(e) = r.begin(&mut self.ctx) { self.error = Some(e); return Progress::Done; }
self.runner = Some(r);
}
match self.runner.as_mut().unwrap().step(&mut self.ctx) {
Ok(StepOut::Continue(pct)) => Progress::Running { stage, pct: pct.clamp(0.0, 1.0) },
Ok(StepOut::Finished) => {
self.runner = None; self.stage_idx += 1;
if self.stage_idx == Stage::ALL.len() { self.done = true; Progress::Done } else { Progress::Running { stage: Stage::ALL[self.stage_idx], pct: 0.0 } }
}
Err(e) => { self.error = Some(e); Progress::Done }
}
}
pub fn finish(self) -> Result<City, GenError> {
if let Some(e) = self.error { return Err(e); }
self.ctx.city.validate().map_err(GenError::Invalid)?;
Ok(self.ctx.city)
}
pub fn run(params: GenParams) -> Result<City, GenError> { let mut g = Self::new(params)?; while let Progress::Running { .. } = g.step() {} g.finish() }
}
Terrain stage (terrain.rs): begin chooses the water edge with the stage RNG (Bay: random Dir::CARDINAL; River: random axis). Work is per row: step processes one row y, sets land[p] and Water on Street for water tiles, pct = y / h. Bay: depth at coordinate t along the edge = base + noise, with base = 0.15 * extent and noise from OpenSimplex at t * 0.04 scaled by 0.06 * extent; tiles within that depth from the edge are water. River: centre line c(t) = mid + noise(t*0.02) * 0.25 * extent, half-width 3..=5 from noise; tiles within half-width of c(t) are water. After the last row, if water fraction < 0.08 for Bay, widen by 1 tile along the whole edge until ≥ 0.10 (bounded loop of at most 10 widenings). Non-water Street tiles stay Void for now (later stages paint them); land is true for them.
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): generator state machine, pathing helpers, terrain stage.
Task 7: Districts stage
Files:
- Create:
crates/citygen/src/gen/districts.rs - Modify:
crates/citygen/src/gen/mod.rs(runner_for)
Interfaces:
-
Consumes:
Scratch.land,GenCtx.rng(Stage::Districts),Theme::spec. -
Produces:
City.districtsfilled (ids 0..n,bounds,center,theme,wealth,decay, emptyname),tile.districtset on all four strata for land tiles,Scratch.wealth/Scratch.decayper-tile fields. -
Step 1: Tests
fn small(seed: u64, water: WaterMode, districts: u8) -> City {
Generator::run(GenParams { seed, water, districts, size: SizePreset::Custom { w: 120, h: 60 }, ..Default::default() }).unwrap()
}
#[test]
fn every_land_tile_has_a_district_and_counts_match() {
let c = small(5, WaterMode::Bay, 6);
assert_eq!(c.districts.len(), 6);
let st = c.stratum(StratumKind::Street).unwrap();
for (_, t) in st.tiles.iter() { if t.terrain != Terrain::Water { assert!(t.district.is_some()); } else { assert!(t.district.is_none()); } }
let mut ids: Vec<_> = c.districts.iter().map(|d| d.id).collect(); ids.dedup(); assert_eq!(ids.len(), 6);
}
#[test]
fn theme_rules_hold() {
let c = small(11, WaterMode::Bay, 7);
let themes: Vec<_> = c.districts.iter().map(|d| d.theme).collect();
assert_eq!(themes.iter().filter(|t| **t == Theme::CorpCore).count(), 1);
assert_eq!(themes.iter().filter(|t| **t == Theme::Docks).count(), 1);
assert!(themes.contains(&Theme::Industrial) && themes.contains(&Theme::CombatZone));
let core = c.districts.iter().find(|d| d.theme == Theme::CorpCore).unwrap();
let cz = c.districts.iter().find(|d| d.theme == Theme::CombatZone).unwrap();
let far = c.districts.iter().filter(|d| d.theme != Theme::CorpCore).map(|d| d.center.dist2(core.center)).max().unwrap();
assert_eq!(cz.center.dist2(core.center), far);
}
#[test]
fn no_water_means_no_docks_but_industrial_present() {
let c = small(2, WaterMode::None, 5);
assert!(c.districts.iter().all(|d| d.theme != Theme::Docks));
assert!(c.districts.iter().any(|d| d.theme == Theme::Industrial));
}
#[test]
fn wealth_and_decay_are_clamped() {
let c = small(9, WaterMode::River, 8);
for d in &c.districts { assert!((0.0..=1.0).contains(&d.wealth)); assert!((0.0..=1.0).contains(&d.decay)); }
}
-
Step 2: Run → fail.
-
Step 3: Implement
begin: collect land positions in index order; n = params.districts; spacing = (land.len() as f32 / n as f32).sqrt() * 0.7; place seeds by rejection: up to 4000 attempts drawing a random land index, accept if chebyshev ≥ spacing to all accepted; if fewer than n after attempts, multiply spacing by 0.8 and continue (loop ends because spacing → 0 accepts everything). Queue rows for Voronoi assignment.
step (one row per step): for each land p in the row, nearest = argmin dist2(seed_i) (ties → lower index); write district_of. After last row: compute bounds (min/max per district), center = seed; assign themes:
let centroid = mean of land positions;
let core = argmin dist2(seed, centroid); // CorpCore
let touches_water = |i| district i has a land tile with a 4-neighbour that is Water;
let docks = seeds (≠core) with touches_water, pick the one with most water-adjacent tiles; // Option
let industrial = if let Some(d) = docks { argmin over others of bounds-centre distance to d } else { the seed nearest a map edge (≠core) };
let combat = argmax dist2(seed, core) among unassigned;
remaining in index order draw from [Megablocks, NightMarket, NeonStrip, OldTown] cycling.
Then wealth = clamp(spec.base_wealth + params.wealth_skew*0.3 + noise2d(center*0.01)*0.15), decay = clamp(spec.base_decay + (params.decay-0.4)*0.5 + noise*0.15). Per-tile fields: wealth[p] = district.wealth + noise(p*0.03)*0.1, same for decay. Write tile.district for all four strata (land tiles only).
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): districts stage with themed Voronoi regions.
Task 8: Arterials stage
Files:
- Create:
crates/citygen/src/gen/arterials.rs - Modify:
gen/mod.rs
Interfaces:
-
Consumes:
City.districts,Scratch.land,pathing::astar. -
Produces:
City.arterials: Vec<Vec<Pos>>(centre paths), StreetRoad4 wide +Sidewalkring,Scratch.arterialandScratch.roadmasks. -
Step 1: Tests
#[test]
fn arterials_connect_all_districts_and_avoid_water() {
let c = small(21, WaterMode::Bay, 7);
assert!(c.arterials.len() >= 6);
let st = c.stratum(StratumKind::Street).unwrap();
for path in &c.arterials { for p in path { assert_ne!(st.tiles[*p].terrain, Terrain::Water); } }
// every district centre is within 3 tiles of a road
for d in &c.districts {
let near = Rect::new(d.center.x - 3, d.center.y - 3, 7, 7).iter().any(|p| st.tiles.get(p).is_some_and(|t| t.terrain == Terrain::Road));
assert!(near, "district {:?} centre not on road", d.id);
}
// roads form one 4-connected component
let comps = components(st.tiles.size(), |p| st.tiles[p].terrain == Terrain::Road);
assert_eq!(comps.len(), 1);
}
#[test]
fn arterial_width_is_four_with_sidewalks() {
let c = small(21, WaterMode::None, 5);
let st = c.stratum(StratumKind::Street).unwrap();
let path = &c.arterials[0];
let mid = path[path.len() / 2];
let roads = Rect::new(mid.x - 3, mid.y - 3, 7, 7).iter().filter(|p| st.tiles[*p].terrain == Terrain::Road).count();
assert!(roads >= 16, "expected a 4-wide road around {mid:?}, got {roads}");
assert!(st.tiles.count(|t| t.terrain == Terrain::Sidewalk) > 0);
}
-
Step 2: Run → fail.
-
Step 3: Implement
begin: Prim's MST over district centres (dist2); extra edges = the max(2, n/3) shortest non-MST pairs. Queue edges in order. step: route one edge with astar where cost(from,to): None if !land[to]; base 10; +1 if !arterial[to] (prefer reuse) else 0; turn penalty: since cost only sees from,to, bias straightness by using the A* heuristic plus +3 when to changes axis relative to from - came_from — implement by routing on an expanded state is overkill; instead post-smooth: after A*, replace any 1-tile zigzag (x,y)->(x+1,y)->(x+1,y+1)->(x+2,y+1) by keeping as is (zigzags render fine as 4-wide roads). Paint: for each path point p, fill_rect(Rect::new(p.x-1, p.y-1, 4, 4), Road) on land only, arterial[p']=true, road[p']=true; then second pass per edge: ring Rect::new(p.x-2,p.y-2,6,6).border() → Sidewalk where terrain is Void and land. Push the path into city.arterials. pct = edge_idx / edges.
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): arterial road network.
Task 9: Streets stage
Files:
- Create:
crates/citygen/src/gen/streets.rs - Modify:
gen/mod.rs
Interfaces:
-
Consumes: districts,
Scratch.land/arterial/road,Theme::spec().street_style/block. -
Produces: Street
Road/Alley/Sidewalktiles per district style;Scratch.blocks: Vec<Block>(rects fully inside the district, not overlapping streets; at least 5×4);Barrierfeatures in CombatZone at cut streets;Scratch.roadupdated. -
Step 1: Tests
#[test]
fn blocks_are_inside_districts_and_off_roads() {
let c = small(31, WaterMode::Bay, 7);
let st = c.stratum(StratumKind::Street).unwrap();
let blocks = debug_blocks(&c); // test-only helper exported under #[cfg(test)]: re-run Generator to the Streets stage and read scratch
assert!(blocks.len() > 20);
for b in &blocks {
assert!(b.rect.w >= 5 && b.rect.h >= 4);
for p in b.rect.iter() { let t = &st.tiles[p]; assert_eq!(t.district, Some(b.district)); assert!(!matches!(t.terrain, Terrain::Road | Terrain::Alley | Terrain::Water)); }
}
}
#[test]
fn street_network_is_connected_per_street_level() {
let c = small(31, WaterMode::None, 6);
let st = c.stratum(StratumKind::Street).unwrap();
let comps = components(st.tiles.size(), |p| matches!(st.tiles[p].terrain, Terrain::Road | Terrain::Alley | Terrain::Sidewalk));
assert_eq!(comps[0].len() as f32 / comps.iter().map(|c| c.len()).sum::<usize>() as f32 > 0.97, true);
}
#[test]
fn combat_zone_has_barriers_and_grid_has_none() {
let c = small(31, WaterMode::None, 7);
let st = c.stratum(StratumKind::Street).unwrap();
let count = |theme: Theme| st.tiles.iter().filter(|(_, t)| t.feature == Some(Feature::Barrier) && t.district.map(|d| c.district(d).theme) == Some(theme)).count();
assert!(count(Theme::CombatZone) > 0);
assert_eq!(count(Theme::CorpCore), 0);
}
debug_blocks: in gen/mod.rs add #[cfg(test)] pub(crate) fn run_until(params, stage: Stage) -> GenCtx that steps until the given stage finishes and returns the ctx.
-
Step 2: Run → fail.
-
Step 3: Implement
Per district (one district per step), on bounds:
-
Helper
paint_line(ctx, a: Pos, b: Pos, width: i32, terrain: Terrain, sidewalk: bool, district): for each bresenham point stamp awidth-wide perpendicular span; only on land tiles of this district whose terrain isVoid(never overwrite arterial Road/Sidewalk); ifsidewalk, add aSidewalkon each side whereVoid. -
carve_grid(rect, bw, bh, street_w, sidewalk): vertical lines everybw + street_w + 2*sidewalkand horizontal everybh + ...across the district bounds; leaf blocks = cells between lines. -
Styles:
Grid(CorpCore):carve_grid(block=(24,12), street_w 2, sidewalk).Superblock:carve_grid((40,20), 2, sidewalk).Organic: BSP ofbounds: split the longer axis at0.35..0.65jitter whilew > 2*max_worh > 2*max_hwithmax = block + block/2, min leaf 8×6; each split paints a 1-wideAlleyline (no sidewalks); leaf blocks = leaf rect inset by 1 on the alley side(s). 20% of leaves additionally get a dead-end alley stub of lengthw/2from one edge.Rows: horizontalRoadlines (2 wide + sidewalk) every 14 rows; vertical accessRoadevery 24 columns.Shattered:carve_grid((16,10), 2, false), then for each horizontal/vertical street segment between intersections with probability 0.3: repaint itAlleyand setFeature::Barrieron the two end tiles.Strip: oneRoad3 wide + sidewalks along the longer axis throughcenter; perpendicularAlley1 wide every 10 tiles spanning the bounds.
-
Block extraction: for each candidate leaf rect,
clamp_to(bounds), shrink until no cell is road/water/outside district (shrink from the offending side, up to 4 times), requirew≥5,h≥4and≥ 85%of cells in this district and land; pushBlock. -
Leftover
Voidland tiles inside the district bounds that are adjacent to a street stayVoidfor Task 10 to turn intoAlley. -
Step 4: Run tests → PASS; gate.
-
Step 5: Commit
feat(citygen): per-theme street subdivision and blocks.
Task 10: Lots and buildings stage
Files:
- Create:
crates/citygen/src/gen/lots.rs - Modify:
gen/mod.rs
Interfaces:
-
Consumes:
Scratch.blocks,Theme::spec().{min_lot,mix,tall,mid,basement,plaza},BuildingKind::height_classes,params.density. -
Produces:
Scratch.lots,City.buildings(Street footprint paintedWallborder +Floorinterior,tile.buildingset on all strata in span... footprint painted on Street only here; Task 11 paints other strata),Plaza/Parking(asPlazaterrain withParkinglot use) tiles, leftover landVoid→Alley. -
Step 1: Tests
#[test]
fn buildings_fit_blocks_and_do_not_overlap() {
let c = small(41, WaterMode::Bay, 7);
assert!(c.buildings.len() > 40);
for (i, a) in c.buildings.iter().enumerate() {
assert!(a.footprint.w >= 4 && a.footprint.h >= 3);
for b in &c.buildings[i + 1..] { assert!(!a.footprint.intersects(b.footprint), "{:?} overlaps {:?}", a.id, b.id); }
let st = c.stratum(StratumKind::Street).unwrap();
for p in a.footprint.border() { assert_eq!(st.tiles[p].terrain, Terrain::Wall); assert_eq!(st.tiles[p].building, Some(a.id)); }
for p in a.footprint.inset(1).iter() { assert_eq!(st.tiles[p].terrain, Terrain::Floor); }
assert_eq!(st.tiles[a.footprint.center()].district, Some(a.district));
}
}
#[test]
fn corp_core_has_tall_buildings_and_no_void_left_on_land() {
let c = small(41, WaterMode::None, 7);
let core = c.districts.iter().find(|d| d.theme == Theme::CorpCore).unwrap().id;
assert!(c.buildings.iter().any(|b| b.district == core && b.span.hi == StratumKind::Heights));
let st = c.stratum(StratumKind::Street).unwrap();
assert_eq!(st.tiles.iter().filter(|(_, t)| t.terrain == Terrain::Void && t.district.is_some()).count(), 0);
}
#[test]
fn density_changes_plaza_share() {
let lo = Generator::run(GenParams { seed: 4, density: 0.2, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }).unwrap();
let hi = Generator::run(GenParams { seed: 4, density: 0.95, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }).unwrap();
assert!(lo.buildings.len() < hi.buildings.len());
}
-
Step 2: Run → fail.
-
Step 3: Implement
One block per step. BSP the block rect into lots: split the longer axis while w > 2*min_w or h > 2*min_h at a jittered midpoint (0.4..0.6), stop when a leaf is within [min, 2*min); lots with w<4||h<3 are merged into Plaza. For each lot: plaza_p = spec.plaza * (1.3 - density); if chance(plaza_p) → LotUse::Plaza (paint Plaza), with half of those Parking in CorpCore/Industrial (still Plaza terrain, tile.feature none). Else kind = pick_weighted(mix), class: allowed = kind.height_classes(); if allowed contains Tall and chance(spec.tall) → Tall; else if contains Mid and chance(spec.mid) → Mid; else first allowed with preference Low. basement = chance(spec.basement). Footprint = lot rect, but in Organic/Strip styles inset by 1 on a random side with p=0.3 to create gaps (gaps become Alley). Paint on Street: border Wall, inner Floor, set tile.building for all footprint tiles on every stratum in span (terrain on other strata painted in Task 11). Push Building { id, kind, district, footprint, span: StrataSpan::from_class(class, basement), name: None }. After the last block: every Street tile with district.is_some() and terrain Void → Alley.
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): lots and building placement.
Task 11: Verticals stage
Files:
- Create:
crates/citygen/src/gen/verticals.rs - Modify:
gen/mod.rs
Interfaces:
-
Consumes:
City.arterials,Scratch.blocks/road, buildings with spans. -
Produces: Undercity
Tunnel/Sewage/Grate/Rail+ basementWall/Floor; SkywaysWall/Floorfootprints +Walkway; HeightsRooftop+Bridge+Helipad/AcUnitfeatures placed later in Dressing (Task 14), so here only terrain. -
Step 1: Tests
#[test]
fn undercity_has_tunnels_under_arterials_and_a_rail() {
let c = small(51, WaterMode::Bay, 7);
let uc = c.stratum(StratumKind::Undercity).unwrap();
for path in &c.arterials { for p in path { assert!(matches!(uc.tiles[*p].terrain, Terrain::Tunnel | Terrain::Sewage | Terrain::Grate), "{p:?}"); } }
assert!(uc.tiles.count(|t| t.terrain == Terrain::Rail) >= 100);
assert!(uc.tiles.count(|t| t.terrain == Terrain::Sewage) > 0);
}
#[test]
fn skyways_and_heights_follow_spans() {
let c = small(51, WaterMode::None, 7);
let sk = c.stratum(StratumKind::Skyways).unwrap(); let hi = c.stratum(StratumKind::Heights).unwrap();
for b in &c.buildings {
let p = b.footprint.center();
if b.span.contains(StratumKind::Skyways) { assert_eq!(sk.tiles[p].terrain, Terrain::Floor); } else { assert_eq!(sk.tiles[p].terrain, Terrain::Void); }
if b.span.contains(StratumKind::Heights) { assert_eq!(hi.tiles[p].terrain, Terrain::Rooftop); } else { assert_eq!(hi.tiles[p].terrain, Terrain::Void); }
}
assert!(sk.tiles.count(|t| t.terrain == Terrain::Walkway) > 50);
}
-
Step 2: Run → fail.
-
Step 3: Implement
Work items in begin: [Arterials(i)...], [Blocks(i)...], [Rail], [Buildings(i)...], [Walkways(i)...], [Bridges(i)...]; one item per step.
-
Arterials(i): alongarterials[i], for each pointp:Sewageatpandp.offset(1,0)(2 wide),Tunnelat the 4×4 stamp (Rect(p.x-1,p.y-1,4,4)) where not Sewage; every 12th path point set the two Sewage tiles toGrate. -
Blocks(i): with p = 1/3 (stage rng), for every Street tile withinblock.rectgrown by 2 that isRoad|Alley→ UndercityTunnel(ifVoid). -
Rail: y0 =h/2 + rng(-h/8..h/8); for x in 0..w:y = y0 + (noise(x*0.02)*4) as i32; setRailat(x,y)andTunnelat(x,y±1)where Void. -
Buildings(i): for each stratum inspanother than Street: paintWallborder /Floorinterior (Skyways, Undercity); Heights →Rooftopwhole footprint. -
Walkways(i)for building i with span ∋ Skyways: find up to 2 other such buildings with chebyshev edge distance ≤ 12 (index order, j > i); connect nearest border tiles with an L-shaped 2-wideWalkwaypath over tiles that areVoidat Skyways (if any tile on the L is not Void/Walkway, try the other L orientation; else skip). In CorpCore/NeonStrip districts, for every StreetRoadtile that has Road in ≥ 3 cardinal directions and(x+y)%7==0, stamp a 5×5Walkwayplatform at Skyways where Void. -
Bridges(i): for Tall building i, nearest Tall building j (j>i) within 8 tiles with overlapping row range (or column range): paint a 1-wideBridgeline at Heights across the gap on the middle overlapping row/column where Void. -
Step 4: Run tests → PASS; gate.
-
Step 5: Commit
feat(citygen): undercity, skyways and heights verticals.
Task 12: Interiors stage
Files:
- Create:
crates/citygen/src/gen/interiors.rs - Modify:
gen/mod.rs
Interfaces:
-
Consumes: buildings, strata grids.
-
Produces: BSP rooms (
Wallpartitions withDoorgaps), exteriorDooron Street facing outdoor ground,Windows on street-facing walls, internalStair/LiftwithConnector { public: false }for spans ≥ 2, doors at Skyways/Undercity where adjacent toWalkway/Tunnel. -
Step 1: Tests
#[test]
fn every_building_has_a_street_door_and_internal_connectivity() {
let c = small(61, WaterMode::Bay, 7);
let st = c.stratum(StratumKind::Street).unwrap();
for b in &c.buildings {
let doors: Vec<_> = b.footprint.border().filter(|p| st.tiles[*p].terrain == Terrain::Door).collect();
assert!(!doors.is_empty(), "{:?} has no door", b.id);
assert!(doors.iter().any(|d| d.neighbors4().any(|n| st.tiles.get(n).is_some_and(|t| t.is_outdoor_ground()))));
// all floor tiles inside reachable from the door
let inside = b.footprint;
let comps = components(st.tiles.size(), |p| inside.contains(p) && st.tiles[p].is_walkable());
assert_eq!(comps.len(), 1, "{:?} interior split", b.id);
if b.span.len() >= 2 {
let conn = c.connectors.iter().find(|k| inside.contains(k.pos) && !k.public).expect("internal connector");
for s in b.span.iter() { assert!(conn.serves.contains(&s)); assert!(c.walkable(s, conn.pos)); }
}
}
}
#[test]
fn rooms_respect_minimum_size() {
let c = small(61, WaterMode::None, 6);
let st = c.stratum(StratumKind::Street).unwrap();
let big = c.buildings.iter().find(|b| b.footprint.w >= 12 && b.footprint.h >= 9).unwrap();
let walls_inside = big.footprint.inset(1).iter().filter(|p| st.tiles[*p].terrain == Terrain::Wall).count();
assert!(walls_inside > 0, "big building should have internal walls");
}
-
Step 2: Run → fail.
-
Step 3: Implement
One building per step. For each stratum s in span with terrain painted as Wall/Floor (skip Heights rooftops):
bsp(inner = footprint.inset(1)): ifinner.w >= 7 || inner.h >= 7(room ≥ 3 + wall + 3): split the longer axis at a position leaving ≥ 3 on each side (jittered); paint the partition lineWall; put oneDoorin the partition at a random position not adjacent to the inner border; recurse on both sides with depth ≤ 4.- Exterior door (Street only): candidates = border tiles (not corners) with an outdoor-ground 4-neighbour outside the footprint; prefer Sidewalk; pick with rng; set
Door. If no candidate, carve: pick the border tile nearest any outdoor ground within 3 and set the intervening tiles toAlley. Record the door's inside neighbour aslobby(used by Dressing/Net). - Windows (Street): border tiles (not corners/doors) adjacent to outdoor ground, every 2nd tile →
Window. - Other strata doors: at Skyways/Undercity, border tiles with a
Walkway/Tunnel/Bridge4-neighbour outside: first one →Door. - Shaft (
span.len() >= 2): choosepos= the Floor tile closest to footprint centre that is Floor on every stratum in span (Heights counts Rooftop); if none, choose the inner-rect centre and set it to Floor on all strata and its 4 inner neighbours to Floor where they are internal walls. Terrain atposon every stratum in span:Stairifspan.len()==2elseLift. PushConnector { kind: Stair|Lift, pos, serves: span.iter().collect(), public: false }. Storelobbypositions inScratch.lobbies: Vec<(BuildingId, Pos)>(add the field).
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): building interiors, doors, windows, shafts.
Task 13: Connectivity stage
Files:
- Create:
crates/citygen/src/gen/connectivity.rs - Modify:
gen/mod.rs
Interfaces:
-
Consumes: all strata, connectors, districts.
-
Produces: repaired strata (carved
Door/Tunnel/Walkway/Bridge, voided slivers), public connectors (Manhole,Stair,Ladder),GenError::Unconnectedafter 3 failed rounds. -
Exposes
pub(crate) fn reachable_from_street(city: &City) -> Vec<Grid<bool>>(one grid per physical stratum; BFS across 4-adjacency and connectors from the largest Street walkable component) — reused byvalidate(Task 17). -
Step 1: Tests
#[test]
fn every_stratum_is_reachable_from_street() {
for seed in [71, 72, 73] {
let c = small(seed, WaterMode::Bay, 7);
let reach = reachable_from_street(&c);
for (i, k) in StratumKind::PHYSICAL.iter().enumerate() {
let st = c.stratum(*k).unwrap();
let walk = st.tiles.count(|t| t.is_walkable()); let got = reach[i].count(|&b| b);
assert!(got as f32 >= 0.95 * walk as f32, "seed {seed} {k:?}: {got}/{walk} reachable");
}
}
}
#[test]
fn every_district_has_a_public_connector() {
let c = small(74, WaterMode::River, 8);
for d in &c.districts {
assert!(c.connectors.iter().any(|k| k.public && d.bounds.contains(k.pos) && c.tile(StratumKind::Street, k.pos).unwrap().district == Some(d.id)), "{:?}", d.id);
}
}
#[test]
fn repair_joins_two_halves() {
// hand-built: 20x5 Street with a wall column at x=10; a 'building' owning the wall
let mut c = City::empty(GenParams { size: SizePreset::Custom { w: 80, h: 40 }, ..Default::default() });
let st = c.stratum_mut(StratumKind::Street).unwrap();
st.tiles.fill_rect(Rect::new(0, 0, 20, 5), Tile::new(Terrain::Sidewalk));
for y in 0..5 { st.tiles.set(Pos::new(10, y), Tile::new(Terrain::Wall)); }
join_street_components(&mut c);
let st = c.stratum(StratumKind::Street).unwrap();
assert_eq!(components(st.tiles.size(), |p| st.tiles[p].is_walkable()).len(), 1);
}
-
Step 2: Run → fail.
-
Step 3: Implement
begin queues rounds [Round(0), Round(1), Round(2), Check].
Round(r):
join_street_components(city): components of walkable Street tiles; for each non-largest component,bfs_nearestfrom its first tile through tiles that areWall|Void|Window(carvable) or walkable, to any tile of the main component, max 40; carve the path: Wall/Window→Door iftile.building.is_some()else Alley; Void→Alley. If no path, and component < 1% → set component tiles to Void (only if not inside a building).reach = reachable_from_street(city). For each other stratumk, components of (walkable && !reach): for each: trybfs_nearestthrough carvable tiles (Undercity: Void→Tunnel; Skyways: Void→Walkway; Heights: Void→Bridge; Wall→Door when in a building) to a reached tile on the same stratum, max 30; else if component < 1% and no tile hasbuilding.is_some()→ Void.- Public connectors per district, in district index order, for each target stratum lacking a public connector in this district:
- Undercity: candidate Street tiles in district with terrain
Sidewalk|Alleywhose Undercity tile isTunnel→ pick the first by index; setLadderon both strata; pushConnector { Manhole, serves: [Undercity, Street], public: true }. If none: pick a Sidewalk tile, carvebfs_neareston Undercity from below it through Void to a Tunnel (max 30) paintingTunnel, then place as above. - Skyways: candidate Street
Sidewalk|Plazatile whose Skyways tile isWalkway→Stairboth; else pick a Sidewalk/Plaza tile, carve Walkway path at Skyways to nearest Skyways walkable (max 30); pushConnector { Stair, serves: [Street, Skyways], public: true }. - Heights: find a Tall building in the district; candidate = a Skyways walkable tile 4-adjacent to its footprint; set
Ladderat Skyways (keep walkable) and at Heights setLadder(the tile was Void); pushConnector { Ladder, serves: [Skyways, Heights], public: true }. If no Tall building or no candidate, skip.Check:reachagain; if any stratum has walkables and < 95% reached → if rounds remain, continue, elseErr(GenError::Unconnected { stratum }).
- Undercity: candidate Street tiles in district with terrain
reachable_from_street: start = largest walkable Street component; BFS over (stratum, pos); neighbours = 4-adjacent walkables on the same stratum + for every connector at pos whose serves contains the current stratum, the other served strata where walkable.
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): connectivity repair and public connectors.
Task 14: Dressing stage
Files:
- Create:
crates/citygen/src/gen/dressing.rs - Modify:
gen/mod.rs
Interfaces:
-
Consumes: buildings, lobbies, districts (theme spec, wealth, decay),
Scratch.decay/wealth,FlavorPack::sign_glyph. -
Produces: features per spec §5.9,
Stratum.light,tile.varianton all strata. -
Step 1: Tests
#[test]
fn dressing_places_features_and_lights() {
let c = small(81, WaterMode::Bay, 7);
let st = c.stratum(StratumKind::Street).unwrap();
let count = |f: fn(&Feature) -> bool| st.tiles.iter().filter(|(_, t)| t.feature.as_ref().is_some_and(f)).count();
assert!(count(|f| matches!(f, Feature::Neon(_))) > 30);
assert!(count(|f| matches!(f, Feature::Lamp)) > 30);
assert!(count(|f| matches!(f, Feature::Sign(_))) > 5);
assert!(count(|f| matches!(f, Feature::Terminal)) >= c.buildings.len() / 2);
assert!(st.light.as_slice().iter().any(|&l| l > 150));
assert!(c.stratum(StratumKind::Undercity).unwrap().light.as_slice().iter().filter(|&&l| l < 40).count() > 100);
// blocking features never sit on connectors or doors
for k in &c.connectors { for s in &k.serves { let t = c.tile(*s, k.pos).unwrap(); assert!(t.is_walkable(), "{k:?} blocked on {s:?}"); } }
assert!(st.tiles.iter().all(|(_, t)| t.terrain != Terrain::Door || t.feature.is_none()));
}
#[test]
fn variants_are_set_everywhere() {
let c = small(81, WaterMode::None, 5);
let st = c.stratum(StratumKind::Street).unwrap();
let distinct: std::collections::HashSet<u8> = st.tiles.as_slice().iter().map(|t| t.variant).collect();
assert!(distinct.len() > 100);
}
-
Step 2: Run → fail.
-
Step 3: Implement
Work items: Facades(building), Streets(district), Strata(k) for lights/variants.
-
Facades(b): theme = district theme; for Street border tiles that areWallwith an outdoor 4-neighbour:chance(spec.neon * (0.5 + wealth))→Neon(pick hues); elsechance(spec.holo)→HoloAd; elsechance(wealth * 0.3)→Camera. Wall tile 4-adjacent to the exterior door (same facade) →Sign(flavor.sign_glyph)for non-corp kinds. Lobby tile →Terminalonly if the lobby has ≥ 2 walkable 4-neighbours after placing (never block the door: require lobby ≠ only path; implement as: place Terminal at the lobby tile's inner neighbour away from the door if that tile is Floor and has ≥ 3 walkable neighbours; else skip). Heights rooftop: ifw≥9 && h≥7setHelipadon the centre 3×3;AcUnitwith p 0.08 on rooftop tiles with ≥ 6 walkable 8-neighbours. -
Streets(d): for Street tiles inboundswithdistrict==d: Sidewalk with(x*7+y*13) % spec.lamp_spacing == 0→Lamp; Road|Sidewalk|Alley:chance(decay*0.08)→Puddle; Alley|Sidewalk:chance(decay*0.1)→Trash; Floor (building)chance(decay*0.03)→Vent; CombatZone Road tiles with ≥ 6 walkable 8-neighbourschance(0.03)→Barrier; NightMarket Plaza tiles with ≥ 6 walkable 8-neighbourschance(0.3)→Stall; Sidewalkchance(0.01)→Terminalonly with ≥ 6 walkable 8-neighbours. Undercity Tunnel tiles with a Void 4-neighbour:chance(0.15)→Pipe. Skyways Walkway:chance(0.1)→Cable. Never place a feature onDoor|Stair|Lift|Laddertiles or on connector positions. -
Strata(k):light = base[k](Undercity 20, Street 60, Skyways 80, Heights 110) then additive falloff: Lamp r6 +120, Neon r4 +90, HoloAd r5 +100, Window(Street) r2 +30;add = strength * (1 - d/r), clamp 255. Thenvariant = tile_hash(seed, k, p)for every tile. -
Step 4: Run tests → PASS; gate.
-
Step 5: Commit
feat(citygen): dressing, light maps, variants.
Task 15: Naming stage
Files:
- Create:
crates/citygen/src/gen/naming.rs - Modify:
gen/mod.rs
Interfaces:
-
Produces:
City.name,District.name,Building.namefor corp kinds, shops with templates, and 2 landmarks per district. -
Step 1: Tests
#[test]
fn names_are_filled_and_deterministic() {
let a = small(91, WaterMode::Bay, 7); let b = small(91, WaterMode::Bay, 7);
assert!(!a.name.is_empty()); assert_eq!(a.name, b.name);
assert!(a.districts.iter().all(|d| !d.name.is_empty()));
let named = a.buildings.iter().filter(|b| b.name.is_some()).count();
assert!(named >= a.buildings.iter().filter(|b| b.kind.is_corp()).count());
assert!(a.buildings.iter().filter(|b| b.name.as_deref().is_some_and(|n| n.contains("Tower") || n.contains("Terminal") || n.contains("Arcology") || n.contains("Plaza") || n.contains("Station"))).count() >= a.districts.len());
}
- Step 2–3: one
stepper district: name the district; for its buildings in index order: corp kinds →corp_name; others →shop_name(kind)whenSome; then choose up to 2 unnamed-or-corp buildings (largest footprints first) and setlandmark_name(corp, district). First step also setscity.name. - Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): naming stage.
Task 16: Net stage
Files:
- Create:
crates/citygen/src/gen/net.rs - Modify:
gen/mod.rs
Interfaces:
-
Produces:
City.netper spec §5.11. Node anchors: Terminal → theFeature::Terminaltile; Camera →Feature::Cameratile; DoorLock → exteriorDoortile (Street); LiftCtrl →Liftconnector pos on Street; AdBoard →HoloAdtile; LampGrid → district's first Lamp tile; Server → a Floor tile in the building farthest from the door (Street); DataVault → another Floor tile (CorpTower/Clinic only); Ice → same tile as Server whensecurity ≥ 160; Backbone → the district's first Terminal (or Server if none). City core:Subnet { owner: City, security: 230 }with aServer+Icein the largest CorpCore building. -
Step 1: Tests
#[test]
fn net_graph_is_consistent() {
let c = small(101, WaterMode::Bay, 7);
assert_eq!(c.net.subnets.iter().filter(|s| matches!(s.owner, SubnetOwner::Building(_))).count(), c.buildings.len());
assert_eq!(c.net.subnets.iter().filter(|s| matches!(s.owner, SubnetOwner::District(_))).count(), c.districts.len());
assert_eq!(c.net.subnets.iter().filter(|s| s.owner == SubnetOwner::City).count(), 1);
for n in &c.net.nodes {
let t = c.tile(n.anchor.0, n.anchor.1).expect("anchor in bounds");
match n.kind {
NodeKind::Terminal => assert_eq!(t.feature, Some(Feature::Terminal)),
NodeKind::Camera => assert_eq!(t.feature, Some(Feature::Camera)),
NodeKind::DoorLock => assert_eq!(t.terrain, Terrain::Door),
NodeKind::LiftCtrl => assert_eq!(t.terrain, Terrain::Lift),
NodeKind::AdBoard => assert_eq!(t.feature, Some(Feature::HoloAd)),
NodeKind::Server | NodeKind::DataVault | NodeKind::Ice => assert!(matches!(t.terrain, Terrain::Floor | Terrain::Lift | Terrain::Stair)),
NodeKind::LampGrid => assert_eq!(t.feature, Some(Feature::Lamp)),
NodeKind::Backbone => assert!(t.feature == Some(Feature::Terminal) || t.terrain == Terrain::Floor),
}
assert!(c.net.subnets.iter().any(|s| s.id == n.subnet));
}
for Link(a, b) in &c.net.links { assert!(a.index() < c.net.nodes.len() && b.index() < c.net.nodes.len()); }
// every building server links to its district backbone (path length 1)
let backbones: Vec<_> = c.net.nodes.iter().filter(|n| n.kind == NodeKind::Backbone).map(|n| n.id).collect();
for s in c.net.nodes.iter().filter(|n| n.kind == NodeKind::Server && n.building.is_some()) {
assert!(c.net.links.iter().any(|Link(a, b)| (*a == s.id && backbones.contains(b)) || (*b == s.id && backbones.contains(a))));
}
}
- Step 2–3:
begin: create district subnets (security =spec.security± wealth30) and the city subnet; onestepper building: subnet (security = district security + wealth40 − decay*20, clamp), nodes from features within the footprint and its exterior door/lift, links node→Server, Server→district Backbone (create the Backbone node lazily at the district's first Terminal/Server). Last step: city core and links Backbone→core Server. - Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(citygen): net graph stage.
Task 17: Validation and seed-sweep integration tests
Files:
- Create:
crates/citygen/src/validate.rs(replace the placeholderViolation),crates/citygen/tests/seeds.rs - Modify:
gen/mod.rs(finishalready calls validate),lib.rs
Interfaces:
-
Produces:
pub enum Violation { ConnectorNotWalkable { pos, stratum }, BuildingWithoutDoor(BuildingId), StratumUnreachable { stratum, reached, walkable }, DanglingId { pos, stratum }, FootprintOverlap(BuildingId, BuildingId), NetAnchor(NodeId), DistrictEmpty(DistrictId) }withDisplay;City::validate(&self) -> Result<(), Vec<Violation>>. -
Step 1: Tests (
tests/seeds.rs)
use citygen::*;
fn sweep(preset: SizePreset, seeds: std::ops::Range<u64>) {
for seed in seeds {
let p = GenParams { seed, size: preset, water: match seed % 3 { 0 => WaterMode::Bay, 1 => WaterMode::River, _ => WaterMode::None }, districts: 4 + (seed % 9) as u8, ..Default::default() };
if p.validate().is_err() { continue; }
let a = Generator::run(p.clone()).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
let b = Generator::run(p).unwrap();
assert_eq!(postcard::to_allocvec(&a).unwrap(), postcard::to_allocvec(&b).unwrap(), "seed {seed} not deterministic");
a.validate().unwrap_or_else(|v| panic!("seed {seed}: {v:?}"));
}
}
#[test] fn small_seeds() { sweep(SizePreset::Small, 0..50); }
#[test] fn medium_seeds() { sweep(SizePreset::Medium, 1000..1010); }
#[test] fn large_seeds() { sweep(SizePreset::Large, 2000..2003); }
#[test] #[ignore] fn extended_seeds() { sweep(SizePreset::Small, 100..250); sweep(SizePreset::Medium, 3000..3050); }
#[test] fn medium_generates_under_two_seconds() {
let t = std::time::Instant::now();
Generator::run(GenParams { seed: 77, ..Default::default() }).unwrap();
assert!(t.elapsed().as_secs_f32() < 2.0, "took {:?}", t.elapsed());
}
Add postcard to citygen dev-dependencies.
- Step 2–3: implement
validateper spec §5 usingreachable_from_street(≥ 95% rule forStratumUnreachable), and run the sweep. Fix whatever stage breaks a seed; keep each fix in its stage file. If the timing test fails, profile withcargo test --releasefirst and optimise the hot stage (likely light-map falloff: restrict to a bounding box per light). - Step 4: Run
cargo test -p citygen→ PASS; gate. - Step 5: Commit
feat(citygen): City::validate and seed sweep tests.
Task 18: App skeleton, terminal guard, save format, CLI
Files:
- Create:
crates/city/src/term.rs,crates/city/src/save.rs,crates/city/src/cli.rs,crates/city/src/app.rs,crates/city/src/screens/mod.rs - Modify:
crates/city/src/main.rs
Interfaces:
-
Produces:
term::TerminalGuard::enter() -> anyhow::Result<(Self, Terminal<CrosstermBackend<Stdout>>)>;Droprestores;term::install_panic_hook().save::{FORMAT_VERSION: u16 = 1, save_city(path, &City) -> Result<()>, load_city(path) -> Result<City>, list_cities(dir) -> Result<Vec<CityFileInfo>>, CityFileInfo { path: PathBuf, name: String, seed: u64, size: Size, modified: SystemTime }, default_dir() -> PathBuf ("./cities")}. Layout:b"CITY",u16 LE version,u32 LE header_len, postcardHeader { name, seed, w, h }, postcardCity.app::{AppState { params: GenParams, city: Option<City>, player: Option<Player>, style: StylePack, tick: u64, status: Option<(String, u64)>, pending_save: Option<PathBuf> }, Screen trait { fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd; fn draw(&self, st: &AppState, f: &mut Frame) }, ScreenCmd { Stay, Switch { to: Box<dyn Screen>, fx: TransitionKind }, Quit }, run(initial: Box<dyn Screen>) -> Result<()>, MIN_W = 100, MIN_H = 30, draw_too_small(f) }.cli::Cli(clap): no subcommand → interactive;Gen { seed: Option<u64>, size: String ("small"|"medium"|"large"|"WxH"), districts: Option<u8>, water: Option<String>, out: PathBuf, json: bool }.screens::placeholder::Blankscreen that draws nothing (used until Task 24 provides Title).
-
Note:
Player(Task 26) andStylePack(Task 20) don't exist yet: in this task declareAppState.player: Option<()>-free by definingpub type Player = crate::walker::Player;later; for now omitplayerandstylefields and add them in Tasks 20/26 (both tasks list the modification). -
Step 1: Tests (
save.rs)
#[test]
fn roundtrip_and_header() {
let c = Generator::run(GenParams { seed: 5, size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }).unwrap();
let dir = std::env::temp_dir().join(format!("city-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.city");
save_city(&path, &c).unwrap();
let back = load_city(&path).unwrap();
assert_eq!(postcard::to_allocvec(&c).unwrap(), postcard::to_allocvec(&back).unwrap());
let list = list_cities(&dir).unwrap();
assert_eq!(list.len(), 1); assert_eq!(list[0].seed, 5); assert_eq!(list[0].name, c.name);
}
#[test]
fn rejects_bad_magic_version_and_truncation() {
let dir = std::env::temp_dir().join(format!("city-bad-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("x.city"); std::fs::write(&p, b"NOPE\x01\x00").unwrap();
assert!(load_city(&p).unwrap_err().to_string().contains("not a .city file"));
let mut bytes = b"CITY".to_vec(); bytes.extend_from_slice(&99u16.to_le_bytes()); std::fs::write(&p, &bytes).unwrap();
assert!(load_city(&p).unwrap_err().to_string().contains("version 99"));
let c = Generator::run(GenParams { seed: 5, size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }).unwrap();
save_city(&p, &c).unwrap();
let full = std::fs::read(&p).unwrap(); std::fs::write(&p, &full[..full.len() / 2]).unwrap();
assert!(load_city(&p).is_err());
}
app.rs test: draw_too_small on a TestBackend::new(1,1) and (99,29) does not panic and the 99×29 buffer contains "100".
-
Step 2: Run → fail.
-
Step 3: Implement
run loop:
pub fn run(mut screen: Box<dyn Screen>, mut st: AppState) -> Result<()> {
install_panic_hook();
let (_guard, mut term) = TerminalGuard::enter()?;
let frame = Duration::from_micros(33_333);
let mut transition: Option<Transition> = None; // Task 23 provides ui::transition
loop {
let start = Instant::now();
let mut keys = Vec::new();
while event::poll(Duration::ZERO)? { if let Event::Key(k) = event::read()? { if k.kind == KeyEventKind::Press { keys.push(k); } } }
st.tick += 1;
if transition.as_ref().is_some_and(|t| t.active(st.tick)) && !keys.is_empty() { transition = None; }
match screen.update(&mut st, &keys) {
ScreenCmd::Stay => {}
ScreenCmd::Switch { to, fx } => { let snap = term.current_buffer_mut().clone(); screen = to; transition = Some(Transition::new(fx, snap, st.tick)); }
ScreenCmd::Quit => break,
}
term.draw(|f| {
if f.area().width < MIN_W || f.area().height < MIN_H { draw_too_small(f); return; }
screen.draw(&st, f);
if let Some(t) = &transition { t.apply(f, st.tick); }
})?;
if let Some(rem) = frame.checked_sub(start.elapsed()) { std::thread::sleep(rem); }
}
Ok(())
}
Until Task 23 exists, define Transition as a stub in app.rs with active() false and apply() no-op; Task 23 replaces it with ui::transition::Transition and TransitionKind { None, Glitch, SlideUp }.
main.rs: parse Cli; Gen → cli::gen(args) builds GenParams, runs Generator::run, writes with save_city or serde_json::to_writer_pretty, prints name seed WxH buildings districts. No subcommand → app::run(Box::new(Blank), AppState::new()).
- Step 4: Run tests → PASS;
cargo run -p city -- gen --seed 1 --size small --out /tmp/x.cityprints a summary; gate. - Step 5: Commit
feat(city): app loop, terminal guard, save format, gen CLI.
Task 19: Animation primitives
Files:
- Create:
crates/city/src/anim/mod.rs,crates/city/src/anim/effects.rs
Interfaces:
-
Produces:
Easing { Linear, OutCubic, InOutCubic, OutBack }withapply(t: f32) -> f32.Tween { start: u64, dur: u64, easing }withprogress(tick) -> f32(clamped 0..1),done(tick),active(tick).Timeline=Vec<(label: &'static str, Tween)>withprogress(label, tick) -> f32,all_done(tick).effects::typewriter(text: &str, p: f32) -> &str(prefix by char count),effects::slide_x(width: u16, p: f32) -> i32(offset from −width to 0),effects::flicker(tick, variant: u8, broken: bool) -> f32(0..1 brightness),effects::pulse(tick, period: u64) -> f32,effects::glitch_cells(area: Rect, tick: u64, p: f32) -> impl Iterator<Item=(u16,u16,char)>(deterministic set of cells to corrupt; count ∝1 − p),effects::GLITCH_CHARS: &[char].
-
Step 1: Tests
#[test] fn tween_clamps_and_eases() {
let t = Tween { start: 10, dur: 10, easing: Easing::Linear };
assert_eq!(t.progress(5), 0.0); assert_eq!(t.progress(15), 0.5); assert_eq!(t.progress(30), 1.0); assert!(t.done(20));
assert!(Easing::OutCubic.apply(0.5) > 0.5);
assert_eq!(Easing::OutBack.apply(1.0), 1.0);
}
#[test] fn typewriter_is_char_safe() {
assert_eq!(typewriter("ヲァィ", 0.34), "ヲ"); assert_eq!(typewriter("abc", 1.0), "abc"); assert_eq!(typewriter("abc", 0.0), "");
}
#[test] fn flicker_is_deterministic_and_bounded() {
for tick in 0..200 { let v = flicker(tick, 7, true); assert!((0.0..=1.0).contains(&v)); assert_eq!(v, flicker(tick, 7, true)); }
assert!(flicker(3, 0, false) > 0.6);
}
#[test] fn glitch_density_shrinks_with_progress() {
let a = glitch_cells(Rect::new(0, 0, 50, 10), 1, 0.1).count(); let b = glitch_cells(Rect::new(0, 0, 50, 10), 1, 0.9).count();
assert!(a > b);
}
- Step 2–3: implement.
flicker:broken→ off (0.05) when((tick/3 + variant as u64*7) % 64)∈ {10,11,12,13,20,21,40}; else0.8 + 0.2*sin((tick as f32)/9.0 + variant as f32).glitch_cells:n = (area.area() as f32 * 0.25 * (1-p)) as usize; for i in 0..n:h = hash64(tick, i),x = h % w,y = (h>>16) % h_area,ch = GLITCH_CHARS[(h>>32) % len]. - Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(city): animation primitives and effects.
Task 20: Style pack, tileset and lighting
Files:
- Create:
flavor/style.ron,crates/city/src/render/mod.rs,render/style.rs,render/tileset.rs,render/lighting.rs - Modify:
crates/city/src/app.rs(addstyle: StylePacktoAppState)
Interfaces:
-
Produces:
Rgb(pub u8, pub u8, pub u8)withto_color() -> ratatui Color::Rgb,scale(f32),mix(other, t),tint(dr, dg, db).Cell { ch: char, fg: Rgb, bg: Rgb, bold: bool, dim: bool }withwrite(&self, buf: &mut Buffer, x: u16, y: u16).Visibility { Unseen, Remembered, Visible }.StylePack(ron):background: Rgb,themes: Vec<(Theme, ThemeStyle { accent, accent2, wall, floor, road, sidewalk, roof })>,strata: Vec<(StratumKind, StratumStyle { tint: (i16,i16,i16), fog: bool, rain: u8 })>,hues: Vec<(Hue, Rgb)>,glyphs: GlyphSets { holo: Vec<char>, rain: Vec<char>, water: Vec<char>, steam: Vec<char>, mass: Vec<char>, net: Vec<char> },StylePack::load() -> anyhow::Result<Self>frominclude_str!("../../../../flavor/style.ron"),theme(Theme) -> &ThemeStyle,stratum(StratumKind) -> &StratumStyle,hue(Hue) -> Rgb.RenderCtx<'a> { style: &'a StylePack, theme: Option<Theme>, stratum: StratumKind, light: u8, tick: u64, pos: Pos, vis: Visibility, connector: Option<ConnectorKind>, kind: Option<BuildingKind>, wall_mask: u8 }.tileset::cell(tile: &Tile, ctx: &RenderCtx) -> Cell;tileset::glyph_for(terrain, feature, connector, kind, variant, tick, wall_mask: u8, style) -> char(the static part, exposed for tests and the legend;wall_maskbits N=1,E=2,S=4,W=8 mark neighbouring Wall|Door|Window tiles);tileset::legend_entries(stratum) -> Vec<(char, &'static str)>.lighting::lit(c: Rgb, light: u8) -> Rgb(scale(0.25 + 0.75*light/255)),lighting::apply_vis(cell, vis) -> Cell(Remembered: scale 0.35, no animation; Unseen: all background).
-
Step 1: Tests
#[test] fn style_glyphs_are_single_width() {
use unicode_width::UnicodeWidthChar;
let s = StylePack::load().unwrap();
for set in [&s.glyphs.holo, &s.glyphs.rain, &s.glyphs.water, &s.glyphs.steam, &s.glyphs.mass, &s.glyphs.net] { for c in set { assert_eq!(c.width(), Some(1), "{c:?}"); } }
for terrain in ALL_TERRAIN { for v in [0u8, 77, 200] { let c = glyph_for(terrain, None, None, None, v, 0, 0, &s); assert_eq!(c.width(), Some(1), "{terrain:?}"); } }
for f in ALL_FEATURES { let c = glyph_for(Terrain::Floor, Some(f), None, None, 1, 0, 0, &s); assert_eq!(c.width(), Some(1), "{f:?}"); }
}
#[test] fn cell_mapping_is_pure_and_lit() {
let s = StylePack::load().unwrap();
let ctx = |light, tick| RenderCtx { style: &s, theme: Some(Theme::CorpCore), stratum: StratumKind::Street, light, tick, pos: Pos::new(1, 1), vis: Visibility::Visible, connector: None, kind: None, wall_mask: 0 };
let wall = Tile::new(Terrain::Wall);
assert_eq!(cell(&wall, &ctx(255, 0)), cell(&wall, &ctx(255, 0)));
assert!(cell(&wall, &ctx(255, 0)).fg.0 > cell(&wall, &ctx(0, 0)).fg.0);
let mut neon = Tile::new(Terrain::Wall); neon.feature = Some(Feature::Neon(Hue::Magenta)); neon.variant = 5; // broken sign
let on = cell(&neon, &ctx(200, 0)); let off = cell(&neon, &ctx(200, 30));
assert_ne!(on.fg, off.fg);
let mut water = Tile::new(Terrain::Water);
assert_ne!(cell(&water, &ctx(100, 0)).ch, cell(&water, &ctx(100, 6)).ch);
water.variant = 1; let _ = water;
let unseen = apply_vis(cell(&wall, &ctx(255, 0)), Visibility::Unseen);
assert_eq!(unseen.ch, ' ');
}
Define ALL_TERRAIN: [Terrain; 20] and ALL_FEATURES: [Feature; 15] consts in citygen::tile (public, used by the legend too).
-
Step 2: Run → fail.
-
Step 3: Implement
Glyph table (terrain → char; kind picks wall weight: corp kinds ═║╔╗╚╝╠╣╦╩╬, Megablock/Apartment ━┃┏┓┗┛┣┫┳┻╋, else ─│┌┐└┘├┤┬┴┼; wall glyph chosen from the 4-neighbour wall mask, which the viewport computes into RenderCtx.wall_mask):
| Terrain | glyph |
|---|---|
| Void | ' ' (Heights/Skyways: ' ' with dark bg; Undercity: ' ') |
| Water | style.glyphs.water[(tick/6 + x + variant) % len] |
| Sewage | ≈ with green fg |
| Road | ' ' (dark asphalt bg); variant%16==0 → · lane mark |
| Sidewalk | ░ |
| Alley | ' ' slightly lighter than Road |
| Plaza | ▫ every (x+y)%3==0 else ' ' |
| Floor | ' ' floor bg; variant%23==0 → · |
| Wall | box drawing by mask |
| Door | ▯ (occupied → ▭, handled by walker overlay) |
| Window | ▫ lit by light |
| Rooftop | style.glyphs.mass[variant%len] dim |
| Walkway | ═ / ║ by neighbour axis (mask has E |
| Bridge | ╍ / ╏ by axis |
| Tunnel | ' ' dark bg; variant%19==0 → . |
| Rail | ╬ blink every 20 ticks → ╫ |
| Stair | ▲ if connector serves a higher stratum than current, ▼ if lower, both → ⇅ |
| Lift | ≡ with indicator ⇅ when (tick/10)%2==0 |
| Ladder | ╫ |
| Grate | ▦ |
Features override the glyph (and fg): Neon(h) → ▪ fg hue(h) * flicker; HoloAd → holo[(tick/8+variant)%len] fg accent2; Lamp → ● fg yellow-white; Vent → steam if (tick/4+variant)%10<3 → steam[..] else ☰; Puddle → ~ fg bluish; Trash → %; Barrier → ▬ fg red; Stall → ▤ fg accent; Sign(c) → c fg accent2; Terminal → ▣ fg cyan; Camera → ◉ fg red (blink (tick/15)%4==0 → ○); AcUnit → ▩; Helipad → H; Pipe → ╌; Cable → ╌ dim.
Colors: base fg/bg from ThemeStyle (fallback neutral when theme is None), stratum tint added, then lit(light). Remembered → apply_vis scales to 0.35 and replaces animated glyphs with their static variant (pass tick = 0).
- Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(city): style pack, tileset and lighting.
Task 21: Viewport, camera, minimap, weather, traffic
Files:
- Create:
render/viewport.rs,render/minimap.rs,render/weather.rs,render/traffic.rs
Interfaces:
-
Produces:
Camera { x: f32, y: f32, tx: i32, ty: i32 }withsnap(p: Pos),target(p: Pos),follow(p: Pos, view_w, view_h)(deadzone ⅓),update()(ease 0.25 toward target per tick),origin(view_w, view_h) -> Pos(top-left tile),clamp(size, view_w, view_h).MapView<'a> { city: &'a City, stratum: StratumKind, origin: Pos, tick: u64, style: &'a StylePack, vis: Option<&'a Grid<Visibility>>, overlays: &'a [&'a dyn Overlay], cursor: Option<Pos> }implementingratatui::widgets::Widget;MapView::screen_to_tile(area, origin, (sx, sy)) -> Posandtile_to_screen.trait Overlay { fn draw(&self, city: &City, stratum: StratumKind, origin: Pos, area: Rect, tick: u64, buf: &mut Buffer, style: &StylePack); }.weather::Weather(unit struct) implementingOverlay: rain density fromstyle.stratum(k).rain(0 none, 1 light=1/40 cells, 2 medium=1/20, 3 heavy=1/10); cell(sx,sy)has a drop whenhash64(tick/1 + sy as u64, (sx as i64 - (tick/2) as i64 % 3) as u64) % density_div == 0; drop glyph fromrainset; fg steel blue; only over tiles whose terrain is not Wall/Floor/Door (rain falls outdoors). Fog whenfogflag: cells withlight < 40get░in dim green with p = hash%6==0.traffic::Traffic { cars: Vec<Car { pos: Pos, dir: Dir, hue: Hue, age: u32 }> }withupdate(&mut self, city, stratum, origin, area, tick)(only on Street; every 2 ticks move each car 1 tile alongRoadkeeping direction, turning to a random Road neighbour at dead ends, despawn after 600 ticks or off-road; spawn up to 1 car per 10 ticks at a random arterial Road tile on the viewport edge, max 24 cars) andimpl Overlaydrawing▬with headlight tint on the tile ahead.Minimap<'a> { city, stratum, view: Rect (in tiles), player: Option<Pos>, style }implementingWidget: 1 cell per 4×4 tiles, bg = district accent scaled by walkable fraction,┌┐└┘│─for the viewport rect,@for player.
-
Step 1: Tests
#[test] fn mapview_clips_at_all_edges_and_tiny_areas() {
let c = Generator::run(GenParams { seed: 3, size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }).unwrap();
let s = StylePack::load().unwrap();
for origin in [Pos::new(-50, -50), Pos::new(70, 30), Pos::new(0, 0), Pos::new(200, 200)] {
for (w, h) in [(1, 1), (120, 40), (10, 3)] {
let backend = TestBackend::new(w, h); let mut t = Terminal::new(backend).unwrap();
t.draw(|f| MapView { city: &c, stratum: StratumKind::Street, origin, tick: 5, style: &s, vis: None, overlays: &[&Weather], cursor: None }.render(f.area(), f.buffer_mut())).unwrap();
}
}
}
#[test] fn camera_follow_has_deadzone_and_eases() {
let mut cam = Camera::default(); cam.snap(Pos::new(50, 50));
cam.follow(Pos::new(52, 50), 60, 30); assert_eq!((cam.tx, cam.ty), (50, 50)); // inside deadzone
cam.follow(Pos::new(90, 50), 60, 30); assert!(cam.tx > 50);
let before = cam.x; cam.update(); assert!(cam.x > before && cam.x < cam.tx as f32);
}
#[test] fn traffic_stays_on_roads() {
let c = Generator::run(GenParams { seed: 3, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }).unwrap();
let mut tr = Traffic::default();
for tick in 0..400 { tr.update(&c, StratumKind::Street, Pos::new(0, 0), Rect::new(0, 0, 120, 60), tick); }
assert!(!tr.cars.is_empty());
for car in &tr.cars { assert_eq!(c.tile(StratumKind::Street, car.pos).unwrap().terrain, Terrain::Road); }
}
- Step 2–3: implement.
MapView::render: for each(sx, sy)in area:p = origin + (sx - area.x, sy - area.y); tile =city.tile(stratum, p)else draw background; buildRenderCtx(theme from tile.district, light from stratum.light, connector fromconnectors_at, kind from building, wall_mask from 4 neighbours'Wall|Door|Window);visfrom grid when present; write cell; thenfor o in overlays { o.draw(...) }; finally ifcursor == Some(p)invert fg/bg. - Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(city): map viewport, camera, minimap, weather and traffic overlays.
Task 22: Digital (Net) overlay
Files:
- Create:
render/overlay.rs
Interfaces:
-
Produces:
Digital<'a> { enabled: bool, focus: Option<NodeId> }implementingOverlay: for nodes anchored on the current stratum within the view, write glyph by kind (netglyph set index: Terminal 0◇, Server 1◆, Backbone 2⬡, DataVault 3⌗, Camera/DoorLock/LiftCtrl/AdBoard/LampGrid 4▪, Ice 5◈) fg signal cyanRgb(0,255,200)pulsing withpulse(tick, 40); building subnets: footprint border tiles get bg tinted+0,+25,+25; whenfocusis a node: its subnet's nodes glow white and eachLinktouching them drawsbresenhamdots·in cyan between anchors (skipping cells that hold a node glyph). -
Also
Digital::node_under(city, p: Pos, stratum) -> Option<NodeId>for the inspector. -
Step 1: Test
#[test] fn overlay_draws_nodes_on_current_stratum_only() {
let c = Generator::run(GenParams { seed: 8, size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }).unwrap();
let s = StylePack::load().unwrap();
let node = c.net.nodes.iter().find(|n| n.kind == NodeKind::Server).unwrap();
let (k, p) = node.anchor;
let mut t = Terminal::new(TestBackend::new(80, 40)).unwrap();
t.draw(|f| MapView { city: &c, stratum: k, origin: Pos::new(0, 0), tick: 0, style: &s, vis: None, overlays: &[&Digital { enabled: true, focus: None }], cursor: None }.render(f.area(), f.buffer_mut())).unwrap();
assert_eq!(t.backend().buffer()[(p.x as u16, p.y as u16)].symbol(), "◆");
let other = StratumKind::PHYSICAL.iter().copied().find(|s| *s != k).unwrap();
t.draw(|f| MapView { city: &c, stratum: other, origin: Pos::new(0, 0), tick: 0, style: &s, vis: None, overlays: &[&Digital { enabled: true, focus: None }], cursor: None }.render(f.area(), f.buffer_mut())).unwrap();
assert_ne!(t.backend().buffer()[(p.x as u16, p.y as u16)].symbol(), "◆");
}
- Step 2–3: implement; Step 4: PASS + gate; Step 5: commit
feat(city): digital net overlay.
Task 23: UI widgets: logo, menu, form, panels, transitions
Files:
- Create:
ui/mod.rs,ui/logo.rs,ui/menu.rs,ui/form.rs,ui/panel.rs,ui/transition.rs - Modify:
app.rs(replace stubTransition)
Interfaces:
-
Produces:
logo::Logo { text: &str, tick, accent: Rgb }Widget: 5-row block font (glyphs█▀▄) forA–Z,0–9, space; each letter 6 cols; neonflickerper letter (variant = letter index), a scanline row (tick/2 % (rows+10)) brightened.logo::width(text) -> u16.menu::Menu { items: Vec<String>, selected: usize, opened: u64 }withnew(items, tick),up(),down(),Widget for MenuView<'a> { menu, tick, accent }: item i slides from−lento 0 over 8 ticks starting atopened + i*4(OutCubic), typewriter in the same window, selected shows▸with fgaccent.scale(0.6 + 0.4*pulse(tick, 30)).form::Field { Number { label, value: i64, min, max, step }, Slider { label, value: f32, min, max, step }, Choice { label, options: Vec<String>, idx }, Text { label, value: String, max }, Button { label } },Form { fields, selected }withhandle(key) -> Option<usize /*button idx pressed*/>(Up/Down move; Left/Right adjust Number/Slider/Choice; chars/Backspace edit Text; Enter on Button returns its index),FormView<'a> { form, tick, accent }Widget drawinglabel …… valuelines, sliders as━━━◉━━━20 wide, selected line bright with▸.panel::{inspector_lines(city, stratum, p, net_node: Option<&Node>) -> Vec<Line>, legend(stratum, style) -> Vec<Line>, strata_selector(current, tick) -> Vec<Line>, status_bar(city: Option<&City>, stratum, fps: f32, hints: &str) -> Line, boxed(title) -> Block}.transition::{TransitionKind { None, Glitch, SlideUp }, Transition { kind, from: Buffer, start: u64, dur: u64 (Glitch 6 ticks, SlideUp 8) }, new(kind, from, start), active(tick), apply(&self, f: &mut Frame, tick)}: Glitch → forglitch_cells(area, tick, p)write chars fromGLITCH_CHARSwith cyan/magenta fg, and forp < 0.5copyfromcells for rows wherehash(tick,row)%3==0; SlideUp → drawfromshifted up byp*heightrows over the new frame for rows above the shift line.
-
Step 1: Tests
#[test] fn menu_slides_in_and_selects() {
let m = Menu::new(vec!["NEW CITY".into(), "LOAD CITY".into(), "QUIT".into()], 0);
let mut t = Terminal::new(TestBackend::new(40, 10)).unwrap();
t.draw(|f| MenuView { menu: &m, tick: 0, accent: Rgb(0, 255, 255) }.render(Rect::new(2, 2, 30, 6), f.buffer_mut())).unwrap();
let row = (0..40).map(|x| t.backend().buffer()[(x, 2)].symbol().to_string()).collect::<String>();
assert!(!row.contains("NEW CITY"));
t.draw(|f| MenuView { menu: &m, tick: 40, accent: Rgb(0, 255, 255) }.render(Rect::new(2, 2, 30, 6), f.buffer_mut())).unwrap();
let rows: Vec<String> = (2..5).map(|y| (0..40).map(|x| t.backend().buffer()[(x, y)].symbol().to_string()).collect()).collect();
assert!(rows[0].contains("▸ NEW CITY")); assert!(rows[1].contains("LOAD CITY")); assert!(rows[2].contains("QUIT"));
}
#[test] fn form_adjusts_and_fires_button() {
let mut f = Form { fields: vec![Field::Number { label: "Districts".into(), value: 7, min: 4, max: 12, step: 1 }, Field::Button { label: "GENERATE".into() }], selected: 0 };
assert_eq!(f.handle(KeyCode::Right), None); assert!(matches!(f.fields[0], Field::Number { value: 8, .. }));
for _ in 0..10 { f.handle(KeyCode::Right); } assert!(matches!(f.fields[0], Field::Number { value: 12, .. }));
f.handle(KeyCode::Down); assert_eq!(f.handle(KeyCode::Enter), Some(1));
}
#[test] fn logo_width_and_render() {
assert_eq!(logo::width("AB"), 12);
let mut t = Terminal::new(TestBackend::new(60, 7)).unwrap();
t.draw(|f| Logo { text: "CITY 9", tick: 3, accent: Rgb(255, 0, 200) }.render(f.area(), f.buffer_mut())).unwrap();
assert!((0..60).any(|x| t.backend().buffer()[(x, 0)].symbol() == "█"));
}
#[test] fn glitch_transition_ends() {
let from = Buffer::empty(Rect::new(0, 0, 20, 5));
let tr = Transition::new(TransitionKind::Glitch, from, 10);
assert!(tr.active(12)); assert!(!tr.active(17));
}
handle takes KeyCode (crossterm) for testability.
- Step 2–3: implement; the block font is a
const FONT: [(char, [&str; 5]); 37]table (write all 37; e.g.('A', ["▄▀▀▄ ", "█ █ ", "█▀▀█ ", "█ █ ", "▀ ▀ "]), each row exactly 5 chars plus a 1-col gap = 6 cols). - Step 4: Run tests → PASS; gate.
- Step 5: Commit
feat(city): logo, menu, form, panels and transitions.
Task 24: Title, Configure and Files screens
Files:
- Create:
screens/title.rs,screens/configure.rs,screens/files.rs - Modify:
screens/mod.rs,main.rs(start onTitle), removeBlank
Interfaces:
-
Produces:
Title::new(tick) -> Title(Screen). Draw:Logo { text: "CYBERPUNK" }centred in the top third with accent magenta; subtitle "city generator" typewritten;MenuViewcentred below; bottom hint "↑↓ select · enter confirm · q quit". Update: Up/Down/j/k move; Enter →NEW CITY⇒Switch { Configure::new(st.params.clone(), tick), Glitch },LOAD CITY⇒Switch { Files::load(tick), SlideUp },QUIT⇒Quit;q/Esc ⇒ Quit.Configure::new(params, tick)(Screen). Form fields in order: Text seed (digits only, max 20; empty ⇒ random), Choice size [small, medium, large], Number districts 4..12, Slider density 0..1 step .05, Slider wealth skew −1..1 step .1, Slider decay 0..1 step .05, Choice water [none, bay, river], Choice flavor [default], Button RANDOM SEED, Button GENERATE. Right side shows a live "preview card": chosen size in tiles, estimated buildings (area/900 * density), current seed. Update:handle(key); RANDOM SEED fills the seed fromhash64(tick, 0xC0FFEE); GENERATE buildsGenParams, runsvalidate(); on error show it in red under the form (status for 90 ticks); on successst.params = paramsandSwitch { Generating::new(params, tick)?, Glitch }(ifGenerator::newfails, show the error instead). Esc ⇒Switch { Title, SlideUp }.Files::load(tick)andFiles::save(tick, city_name)(Screen). Load mode: list fromsave::list_cities(default_dir())rendered as rowsname · seed · WxH · modified, Up/Down select, Enter ⇒load_city→st.city = Some(city); st.player = None; Switch { MapScreen::explore(tick), Glitch }, errors shown in status; Esc ⇒ Title. Save mode: a singleField::Textprefilled with the city name, Enter ⇒save_city(default_dir().join(format!("{name}.city")))(create dir), status "saved " andSwitch { MapScreen::explore(tick), None }; Esc ⇒ back to the map.
-
Step 1: Tests
#[test] fn title_renders_logo_and_menu_and_quits() {
let mut st = AppState::new();
let mut title = Title::new(0);
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
st.tick = 60;
t.draw(|f| title.draw(&st, f)).unwrap();
let text = buffer_text(t.backend().buffer());
assert!(text.contains("NEW CITY") && text.contains("LOAD CITY") && text.contains("QUIT"));
assert!(matches!(title.update(&mut st, &[key(KeyCode::Char('q'))]), ScreenCmd::Quit));
title.update(&mut st, &[key(KeyCode::Down), key(KeyCode::Down)]);
assert!(matches!(title.update(&mut st, &[key(KeyCode::Enter)]), ScreenCmd::Quit));
}
#[test] fn configure_generate_switches_screens() {
let mut st = AppState::new();
let mut cfg = Configure::new(GenParams { size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }, 0);
for _ in 0..9 { cfg.update(&mut st, &[key(KeyCode::Down)]); } // to GENERATE
assert!(matches!(cfg.update(&mut st, &[key(KeyCode::Enter)]), ScreenCmd::Switch { .. }));
}
Test helpers in screens/mod.rs under #[cfg(test)]: key(code) -> KeyEvent, buffer_text(&Buffer) -> String.
- Step 2–3: implement; Step 4: PASS + gate; Step 5: commit
feat(city): title, configure and files screens.
Task 25: Generating screen
Files:
- Create:
screens/generating.rs
Interfaces:
-
Produces:
Generating::new(params, tick) -> anyhow::Result<Generating>(holdsGenerator,steps_per_frame: u32 = 8,paused: bool,stage_done_tick: [Option<u64>; 11],cam: Camera,error: Option<GenError>). Update: if not paused and not finished, callgen.step()up tosteps_per_frametimes (Enter ⇒ loop untilDone); Space toggles pause;+/-doubles/halves steps (1..=64); whenDone:gen.finish()→st.city = Some(city); st.player = None; Switch { MapScreen::explore(tick), Glitch }or keep the error on screen with hintr reseed · esc back;r⇒ newGeneratingwithparams.seed = hash64(seed, tick); Esc ⇒ Configure. Draw: leftMapViewof Street (origin = camera centred on map centre, camera slowly drifts:tx = cx + (sin(tick/90)*w/6)), right panel 34 wide: title "GENERATING ", one row perStage::ALL:[✓]done (dim),[▸]active with a 16-wide progress bar▓▓░░ 62%,[ ]pending; bottom hintspace pause · +/- speed · enter skip. -
Step 1: Test
#[test] fn generating_reaches_explore() {
let mut st = AppState::new();
let mut g = Generating::new(GenParams { seed: 2, size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }, 0).unwrap();
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
t.draw(|f| g.draw(&st, f)).unwrap();
assert!(buffer_text(t.backend().buffer()).contains("Terrain"));
let mut cmd = ScreenCmd::Stay; for _ in 0..100_000 { st.tick += 1; cmd = g.update(&mut st, &[key(KeyCode::Enter)]); if !matches!(cmd, ScreenCmd::Stay) { break; } }
assert!(matches!(cmd, ScreenCmd::Switch { .. })); assert!(st.city.is_some());
}
- Step 2–3: implement; Step 4: PASS + gate; Step 5: commit
feat(city): animated generating screen.
Task 26: Walker: player, movement, connectors, FOV
Files:
- Create:
walker/mod.rs,walker/fov.rs - Modify:
app.rs(addplayer: Option<Player>toAppState)
Interfaces:
-
Produces:
fov::compute(origin: Pos, radius: i32, blocks: impl Fn(Pos) -> bool, in_bounds: impl Fn(Pos) -> bool, mut mark: impl FnMut(Pos))— symmetric shadowcasting over 4 quadrants with rational slopes (Albert Ford's algorithm): rows expand from the origin; each row scans tiles betweenstart_slopeandend_slope; a wall tile ends the current view segment, a floor after a wall starts a new one; symmetric rule: a floor tile is visible only if its centre slope lies within[start, end].Player { pos: Pos, stratum: StratumKind, memory: Vec<Grid<Visibility>> (one per physical stratum), radius_street: i32 = 14, radius_under: i32 = 8 }withspawn(city, rng_seed: u64) -> Player(random public connector on Street in a non-CorpCore district; fallback: first walkable Street tile),try_move(&mut self, city, dir) -> bool,use_connector(&mut self, city, up: bool) -> Option<StratumKind>(looks upconnectors_at(pos)serving the current stratum; picks the neighbour above/below inserves; requires destination walkable),update_fov(&mut self, city)(sets previously Visible → Remembered on the current stratum, then marks Visible),vis(&self) -> &Grid<Visibility>.
-
Step 1: Tests
#[test] fn fov_symmetric_and_walls_block() {
// 9x9 room, wall column at x=4 rows 0..=6, opening at (4,7),(4,8)
let wall = |p: Pos| p.x == 4 && p.y <= 6;
let bounds = |p: Pos| (0..9).contains(&p.x) && (0..9).contains(&p.y);
let see = |from: Pos| { let mut v = std::collections::HashSet::new(); compute(from, 12, wall, bounds, |p| { v.insert(p); }); v };
let a = Pos::new(1, 1); let b = Pos::new(7, 1);
assert_eq!(see(a).contains(&b), see(b).contains(&a)); assert!(!see(a).contains(&b));
assert!(see(a).contains(&Pos::new(4, 1))); // the wall itself is visible
assert!(see(Pos::new(1, 8)).contains(&Pos::new(7, 8))); // through the opening
assert!(!see(a).contains(&Pos::new(1, 1 + 13))); // radius
}
#[test] fn player_moves_and_uses_connectors() {
let c = Generator::run(GenParams { seed: 6, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }).unwrap();
let mut p = Player::spawn(&c, 1);
assert!(c.walkable(p.stratum, p.pos));
let conn = c.connectors.iter().find(|k| k.public && k.serves.contains(&StratumKind::Undercity)).unwrap();
p.pos = conn.pos; p.stratum = StratumKind::Street;
assert_eq!(p.use_connector(&c, false), Some(StratumKind::Undercity));
assert_eq!(p.use_connector(&c, false), None);
assert_eq!(p.use_connector(&c, true), Some(StratumKind::Street));
p.update_fov(&c); assert_eq!(p.vis()[p.pos], Visibility::Visible);
let moved = Dir::ALL.iter().any(|d| { let mut q = p.clone(); q.try_move(&c, *d) });
assert!(moved);
let mut q = p.clone(); q.pos = Pos::new(-1, -1); assert!(!q.try_move(&c, Dir::N) || c.walkable(q.stratum, q.pos));
}
- Step 2–3: implement; Step 4: PASS + gate; Step 5: commit
feat(city): walker with symmetric shadowcasting FOV.
Task 27: Explore and Walk screen, help overlay, save wiring
Files:
- Create:
screens/explore.rs,screens/help.rs - Modify:
screens/mod.rs
Interfaces:
-
Produces:
MapScreen { mode: Mode { Explore, Walk }, stratum, cam: Camera, cursor: Option<Pos>, show_inspector: bool (default true), show_minimap: bool (true), show_legend: bool (false), digital: bool (false), help: bool, traffic: Traffic, fps: FpsMeter }withMapScreen::explore(tick); Screen impl:- Layout: map area = full width minus 34 when inspector shown; status bar bottom row; minimap 24×12 bottom-right over the map; legend replaces inspector content when toggled; help overlay centred 60×20 boxed.
- Explore keys: arrows/hjkl pan 1 (Shift ×8),
[/]stratum down/up,imldtoggles,w⇒ Walk (spawnPlayerviaPlayer::spawn(city, hash64(seed, tick))ifst.playeris None; set stratum to player's),s⇒Switch { Files::save(tick, name), None },r⇒Generating::new(params.with_seed(hash64(seed, tick))),g⇒ Configure,?help,q/Esc ⇒ Title. Cursor in Explore = map centre tile (inspector describes it). - Walk keys: hjkl/yubn/arrows ⇒
try_movethenupdate_fov;</>⇒use_connector(status "no connector here" when None);.wait; Tab ⇒ Explore; toggles ands/?/qas above. Camerafollow(player). Inspector shows the tile under@, plus the lift panel (serveslist with▸on current) when standing on a connector. - Draw:
MapViewwithvis: Some(player.vis())in Walk mode, overlays[&Weather, &traffic, &Digital{..}], player@written last in bright white withDoorunder player shown▭;status_barwith hints per mode. help::draw(f, mode): key list for the current mode.FpsMeter { last: Instant, frames: u32, fps: f32 }updated inupdate.
-
Step 1: Tests
#[test] fn explore_and_walk_round_trip() {
let mut st = AppState::new();
st.city = Some(Generator::run(GenParams { seed: 6, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }).unwrap());
let mut s = MapScreen::explore(0);
let mut t = Terminal::new(TestBackend::new(120, 40)).unwrap();
t.draw(|f| s.draw(&st, f)).unwrap();
assert!(buffer_text(t.backend().buffer()).contains("Street"));
s.update(&mut st, &[key(KeyCode::Char(']'))]); assert_eq!(s.stratum, StratumKind::Skyways);
s.update(&mut st, &[key(KeyCode::Char('w'))]); assert!(st.player.is_some()); assert_eq!(s.mode, Mode::Walk);
let before = st.player.as_ref().unwrap().pos;
for d in ['h', 'j', 'k', 'l'] { s.update(&mut st, &[key(KeyCode::Char(d))]); }
let _ = before;
t.draw(|f| s.draw(&st, f)).unwrap();
assert!(buffer_text(t.backend().buffer()).contains('@'));
s.update(&mut st, &[key(KeyCode::Tab)]); assert_eq!(s.mode, Mode::Explore);
assert!(matches!(s.update(&mut st, &[key(KeyCode::Char('q'))]), ScreenCmd::Switch { .. }));
}
- Step 2–3: implement; Step 4: PASS + gate; Step 5: commit
feat(city): explore and walk screens with panels and help.
Task 28: README, smoke run, final gate
Files:
-
Create:
README.md -
Modify: none
-
Step 1:
README.md: what it is,cargo run -p city,cargo run -p city -- gen --seed 42 --size medium --out cities/demo.city, key reference per screen, the four strata, and a pointer to the spec and plan. -
Step 2: Headless smoke:
cargo run --release -p city -- gen --seed 42 --size large --out /tmp/smoke.city --jsonandcargo run --release -p city -- gen --seed 42 --size large --out /tmp/smoke.city; both succeed; file sizes printed. -
Step 3: Full gate:
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace. -
Step 4: Interactive smoke (manual, by a person in a real terminal): launch, generate medium, pan all strata, toggle
d, enter Walk, use a manhole and a lift, save, quit, load. Record results in the final report. -
Step 5: Commit
docs: README and smoke-tested release candidate.