Files
cyberpunk/docs/superpowers/plans/2026-10-12-city-generator.md
Jeffrey Wilcke 7b225cc5e8 docs: city generator implementation plan
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>
2026-10-12 16:37:18 +02:00

117 KiB
Raw Blame History

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.
  • citygen must never depend on ratatui, crossterm or any terminal crate.
  • Same GenParams (including seed) ⇒ byte-identical City. Iterate collections in index order only; never iterate a HashMap/HashSet to make decisions.
  • Every tile renders as exactly one terminal cell: every glyph in flavor/style.ron has 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, u16 version, 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

  1. Too many districts for a small map (Small + 12 districts, or Custom{w:80,h:40} + 8): GenParams::validate() must reject with InvalidParams { field: "districts" } instead of the generator panicking or looping forever in seed placement. Pinned in Task 4.
  2. WaterMode::None with the Docks rule: theme assignment must skip Docks cleanly when no seed touches water, and Industrial must still be placed. Pinned in Task 7.
  3. 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).
  4. Corrupt or truncated .city file (wrong magic, future version, truncated body): load_city returns a descriptive error, never panics. Pinned in Task 18.
  5. 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 (city depends on citygen).

  • 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 } with new, offset(dx,dy), step(Dir), manhattan, chebyshev, dist2 -> i64, neighbors4(), neighbors8().
    • Size { w: u32, h: u32 } with area() -> usize, contains(Pos), index(Pos) -> usize, center() -> Pos.
    • Rect { x, y, w, h } (w,h ≥ 0; right/bottom exclusive) with new, right, bottom, contains, intersects, intersection -> Option<Rect>, inset(n), center, area, iter(), border(), clamp_to(Rect) -> Option<Rect>, is_empty.
    • Dir 8 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 with index() -> 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 citygen PASS.
  • Step 5: Commit git commit -am "feat(citygen): geometry primitives and Grid" (after git 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; enums Copy, 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 } with contains(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 in serves).
    • City { seed, params: GenParams, name, size, strata: Vec<Stratum>, districts, buildings, connectors, arterials: Vec<Vec<Pos>>, net: Net } with empty(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: GenParams is defined in Task 4; City references it, so Task 3 includes a minimal params.rs with the struct and Default only (Task 4 adds validation and GenError).

  • 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 declare pub struct Violation(pub String) in validate.rs now, 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" is include_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) returns None for 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 a NoopStage that finishes immediately; each later task replaces its entry).
    • GenCtx helpers: 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(&params.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.districts filled (ids 0..n, bounds, center, theme, wealth, decay, empty name), tile.district set on all four strata for land tiles, Scratch.wealth/Scratch.decay per-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), Street Road 4 wide + Sidewalk ring, Scratch.arterial and Scratch.road masks.

  • 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/Sidewalk tiles per district style; Scratch.blocks: Vec<Block> (rects fully inside the district, not overlapping streets; at least 5×4); Barrier features in CombatZone at cut streets; Scratch.road updated.

  • 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 a width-wide perpendicular span; only on land tiles of this district whose terrain is Void (never overwrite arterial Road/Sidewalk); if sidewalk, add a Sidewalk on each side where Void.

  • carve_grid(rect, bw, bh, street_w, sidewalk): vertical lines every bw + street_w + 2*sidewalk and horizontal every bh + ... 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 of bounds: split the longer axis at 0.35..0.65 jitter while w > 2*max_w or h > 2*max_h with max = block + block/2, min leaf 8×6; each split paints a 1-wide Alley line (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 length w/2 from one edge.
    • Rows: horizontal Road lines (2 wide + sidewalk) every 14 rows; vertical access Road every 24 columns.
    • Shattered: carve_grid((16,10), 2, false), then for each horizontal/vertical street segment between intersections with probability 0.3: repaint it Alley and set Feature::Barrier on the two end tiles.
    • Strip: one Road 3 wide + sidewalks along the longer axis through center; perpendicular Alley 1 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), require w≥5,h≥4 and ≥ 85% of cells in this district and land; push Block.

  • Leftover Void land tiles inside the district bounds that are adjacent to a street stay Void for Task 10 to turn into Alley.

  • 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 painted Wall border + Floor interior, tile.building set on all strata in span... footprint painted on Street only here; Task 11 paints other strata), Plaza/Parking(as Plaza terrain with Parking lot use) tiles, leftover land Void → 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 + basement Wall/Floor; Skyways Wall/Floor footprints + Walkway; Heights Rooftop + Bridge + Helipad/AcUnit features 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): along arterials[i], for each point p: Sewage at p and p.offset(1,0) (2 wide), Tunnel at 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 to Grate.

  • Blocks(i): with p = 1/3 (stage rng), for every Street tile within block.rect grown by 2 that is Road|Alley → Undercity Tunnel (if Void).

  • Rail: y0 = h/2 + rng(-h/8..h/8); for x in 0..w: y = y0 + (noise(x*0.02)*4) as i32; set Rail at (x,y) and Tunnel at (x,y±1) where Void.

  • Buildings(i): for each stratum in span other than Street: paint Wall border / Floor interior (Skyways, Undercity); Heights → Rooftop whole 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-wide Walkway path over tiles that are Void at Skyways (if any tile on the L is not Void/Walkway, try the other L orientation; else skip). In CorpCore/NeonStrip districts, for every Street Road tile that has Road in ≥ 3 cardinal directions and (x+y)%7==0, stamp a 5×5 Walkway platform 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-wide Bridge line 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 (Wall partitions with Door gaps), exterior Door on Street facing outdoor ground, Windows on street-facing walls, internal Stair/Lift with Connector { public: false } for spans ≥ 2, doors at Skyways/Undercity where adjacent to Walkway/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):

  1. bsp(inner = footprint.inset(1)): if inner.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 line Wall; put one Door in the partition at a random position not adjacent to the inner border; recurse on both sides with depth ≤ 4.
  2. 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 to Alley. Record the door's inside neighbour as lobby (used by Dressing/Net).
  3. Windows (Street): border tiles (not corners/doors) adjacent to outdoor ground, every 2nd tile → Window.
  4. Other strata doors: at Skyways/Undercity, border tiles with a Walkway/Tunnel/Bridge 4-neighbour outside: first one → Door.
  5. Shaft (span.len() >= 2): choose pos = 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 at pos on every stratum in span: Stair if span.len()==2 else Lift. Push Connector { kind: Stair|Lift, pos, serves: span.iter().collect(), public: false }. Store lobby positions in Scratch.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::Unconnected after 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 by validate (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):

  1. join_street_components(city): components of walkable Street tiles; for each non-largest component, bfs_nearest from its first tile through tiles that are Wall|Void|Window (carvable) or walkable, to any tile of the main component, max 40; carve the path: Wall/Window→Door if tile.building.is_some() else Alley; Void→Alley. If no path, and component < 1% → set component tiles to Void (only if not inside a building).
  2. reach = reachable_from_street(city). For each other stratum k, components of (walkable && !reach): for each: try bfs_nearest through 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 has building.is_some() → Void.
  3. 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|Alley whose Undercity tile is Tunnel → pick the first by index; set Ladder on both strata; push Connector { Manhole, serves: [Undercity, Street], public: true }. If none: pick a Sidewalk tile, carve bfs_nearest on Undercity from below it through Void to a Tunnel (max 30) painting Tunnel, then place as above.
    • Skyways: candidate Street Sidewalk|Plaza tile whose Skyways tile is Walkway → Stair both; else pick a Sidewalk/Plaza tile, carve Walkway path at Skyways to nearest Skyways walkable (max 30); push Connector { 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 Ladder at Skyways (keep walkable) and at Heights set Ladder (the tile was Void); push Connector { Ladder, serves: [Skyways, Heights], public: true }. If no Tall building or no candidate, skip. Check: reach again; if any stratum has walkables and < 95% reached → if rounds remain, continue, else Err(GenError::Unconnected { stratum }).

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.variant on 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 are Wall with an outdoor 4-neighbour: chance(spec.neon * (0.5 + wealth)) → Neon(pick hues); else chance(spec.holo) → HoloAd; else chance(wealth * 0.3) → Camera. Wall tile 4-adjacent to the exterior door (same facade) → Sign(flavor.sign_glyph) for non-corp kinds. Lobby tile → Terminal only 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: if w≥9 && h≥7 set Helipad on the centre 3×3; AcUnit with p 0.08 on rooftop tiles with ≥ 6 walkable 8-neighbours.

  • Streets(d): for Street tiles in bounds with district==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-neighbours chance(0.03) → Barrier; NightMarket Plaza tiles with ≥ 6 walkable 8-neighbours chance(0.3) → Stall; Sidewalk chance(0.01) → Terminal only 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 on Door|Stair|Lift|Ladder tiles 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. Then variant = 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.name for 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 step per district: name the district; for its buildings in index order: corp kinds → corp_name; others → shop_name(kind) when Some; then choose up to 2 unnamed-or-corp buildings (largest footprints first) and set landmark_name(corp, district). First step also sets city.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.net per spec §5.11. Node anchors: Terminal → the Feature::Terminal tile; Camera → Feature::Camera tile; DoorLock → exterior Door tile (Street); LiftCtrl → Lift connector pos on Street; AdBoard → HoloAd tile; 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 when security ≥ 160; Backbone → the district's first Terminal (or Server if none). City core: Subnet { owner: City, security: 230 } with a Server + Ice in 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; one step per 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 placeholder Violation), crates/citygen/tests/seeds.rs
  • Modify: gen/mod.rs (finish already 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) } with Display; 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 validate per spec §5 using reachable_from_street (≥ 95% rule for StratumUnreachable), and run the sweep. Fix whatever stage breaks a seed; keep each fix in its stage file. If the timing test fails, profile with cargo test --release first 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>>)>; Drop restores; 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, postcard Header { name, seed, w, h }, postcard City.
    • 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::Blank screen that draws nothing (used until Task 24 provides Title).
  • Note: Player (Task 26) and StylePack (Task 20) don't exist yet: in this task declare AppState.player: Option<()>-free by defining pub type Player = crate::walker::Player; later; for now omit player and style fields 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.city prints 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 } with apply(t: f32) -> f32.
    • Tween { start: u64, dur: u64, easing } with progress(tick) -> f32 (clamped 0..1), done(tick), active(tick).
    • Timeline = Vec<(label: &'static str, Tween)> with progress(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}; else 0.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 (add style: StylePack to AppState)

Interfaces:

  • Produces:

    • Rgb(pub u8, pub u8, pub u8) with to_color() -> ratatui Color::Rgb, scale(f32), mix(other, t), tint(dr, dg, db).
    • Cell { ch: char, fg: Rgb, bg: Rgb, bold: bool, dim: bool } with write(&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> from include_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_mask bits 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 } with snap(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> } implementing ratatui::widgets::Widget; MapView::screen_to_tile(area, origin, (sx, sy)) -> Pos and tile_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) implementing Overlay: rain density from style.stratum(k).rain (0 none, 1 light=1/40 cells, 2 medium=1/20, 3 heavy=1/10); cell (sx,sy) has a drop when hash64(tick/1 + sy as u64, (sx as i64 - (tick/2) as i64 % 3) as u64) % density_div == 0; drop glyph from rain set; fg steel blue; only over tiles whose terrain is not Wall/Floor/Door (rain falls outdoors). Fog when fog flag: cells with light < 40 get ░ in dim green with p = hash%6==0.
    • traffic::Traffic { cars: Vec<Car { pos: Pos, dir: Dir, hue: Hue, age: u32 }> } with update(&mut self, city, stratum, origin, area, tick) (only on Street; every 2 ticks move each car 1 tile along Road keeping 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) and impl Overlay drawing ▬ with headlight tint on the tile ahead.
    • Minimap<'a> { city, stratum, view: Rect (in tiles), player: Option<Pos>, style } implementing Widget: 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; build RenderCtx (theme from tile.district, light from stratum.light, connector from connectors_at, kind from building, wall_mask from 4 neighbours' Wall|Door|Window); vis from grid when present; write cell; then for o in overlays { o.draw(...) }; finally if cursor == 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> } implementing Overlay: for nodes anchored on the current stratum within the view, write glyph by kind (net glyph set index: Terminal 0 ◇, Server 1 ◆, Backbone 2 ⬡, DataVault 3 ⌗, Camera/DoorLock/LiftCtrl/AdBoard/LampGrid 4 ▪, Ice 5 ◈) fg signal cyan Rgb(0,255,200) pulsing with pulse(tick, 40); building subnets: footprint border tiles get bg tinted +0,+25,+25; when focus is a node: its subnet's nodes glow white and each Link touching them draws bresenham dots · 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 stub Transition)

Interfaces:

  • Produces:

    • logo::Logo { text: &str, tick, accent: Rgb } Widget: 5-row block font (glyphs █▀▄ ) for A–Z, 0–9, space; each letter 6 cols; neon flicker per 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 } with new(items, tick), up(), down(), Widget for MenuView<'a> { menu, tick, accent }: item i slides from −len to 0 over 8 ticks starting at opened + i*4 (OutCubic), typewriter in the same window, selected shows ▸ with fg accent.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 } with handle(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 drawing label …… value lines, 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 → for glitch_cells(area, tick, p) write chars from GLITCH_CHARS with cyan/magenta fg, and for p < 0.5 copy from cells for rows where hash(tick,row)%3==0; SlideUp → draw from shifted up by p*height rows 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 on Title), remove Blank

Interfaces:

  • Produces:

    • Title::new(tick) -> Title (Screen). Draw: Logo { text: "CYBERPUNK" } centred in the top third with accent magenta; subtitle "city generator" typewritten; MenuView centred 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 from hash64(tick, 0xC0FFEE); GENERATE builds GenParams, runs validate(); on error show it in red under the form (status for 90 ticks); on success st.params = params and Switch { Generating::new(params, tick)?, Glitch } (if Generator::new fails, show the error instead). Esc ⇒ Switch { Title, SlideUp }.
    • Files::load(tick) and Files::save(tick, city_name) (Screen). Load mode: list from save::list_cities(default_dir()) rendered as rows name · 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 single Field::Text prefilled with the city name, Enter ⇒ save_city(default_dir().join(format!("{name}.city"))) (create dir), status "saved " and Switch { 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> (holds Generator, 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, call gen.step() up to steps_per_frame times (Enter ⇒ loop until Done); Space toggles pause; +/- doubles/halves steps (1..=64); when Done: gen.finish() → st.city = Some(city); st.player = None; Switch { MapScreen::explore(tick), Glitch } or keep the error on screen with hint r reseed · esc back; r ⇒ new Generating with params.seed = hash64(seed, tick); Esc ⇒ Configure. Draw: left MapView of 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 per Stage::ALL: [✓] done (dim), [▸] active with a 16-wide progress bar ▓▓░░ 62%, [ ] pending; bottom hint space 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 (add player: Option<Player> to AppState)

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 between start_slope and end_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 } with spawn(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 up connectors_at(pos) serving the current stratum; picks the neighbour above/below in serves; 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 } with MapScreen::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, i m l d toggles, w ⇒ Walk (spawn Player via Player::spawn(city, hash64(seed, tick)) if st.player is 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_move then update_fov; </> ⇒ use_connector (status "no connector here" when None); . wait; Tab ⇒ Explore; toggles and s/?/q as above. Camera follow(player). Inspector shows the tile under @, plus the lift panel (serves list with ▸ on current) when standing on a connector.
    • Draw: MapView with vis: Some(player.vis()) in Walk mode, overlays [&Weather, &traffic, &Digital{..}], player @ written last in bright white with Door under player shown ▭; status_bar with hints per mode.
    • help::draw(f, mode): key list for the current mode.
    • FpsMeter { last: Instant, frames: u32, fps: f32 } updated in update.
  • 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 --json and cargo 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.