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

1852 lines
117 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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`:
```toml
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
```
`crates/citygen/Cargo.toml`:
```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`:
```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**
```bash
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`)
```rust
#[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**
```rust
// 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`:
```rust
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**
```rust
// 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:
```rust
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:
```rust
#[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**
```rust
#[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".
```rust
// 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**
```rust
#[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):
```ron
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**
```rust
// 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:
```rust
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**
```rust
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:
```rust
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**
```rust
#[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**
```rust
#[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**
```rust
#[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**
```rust
#[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, `Window`s 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**
```rust
#[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**
```rust
#[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**
```rust
#[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**
```rust
#[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**
```rust
#[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` ± wealth*30) and the city subnet; one `step` per building: subnet (security = district security + wealth*40 − 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`)
```rust
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`)
```rust
#[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:
```rust
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**
```rust
#[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**
```rust
#[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|W → `═`, else `║`) |
| 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**
```rust
#[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**
```rust
#[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**
```rust
#[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 <path>" and `Switch { MapScreen::explore(tick), None }`; Esc ⇒ back to the map.
- [ ] **Step 1: Tests**
```rust
#[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 <seed>", 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**
```rust
#[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**
```rust
#[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**
```rust
#[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`.