# 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 `. ## 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 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`, `inset(n)`, `center`, `area`, `iter()`, `border()`, `clamp_to(Rect) -> Option`, `is_empty`. - `Dir` 8 variants, `Dir::CARDINAL`, `Dir::ALL`, `delta()`, `opposite()`. - `Grid`: `filled(size, v)`, `size()`, `get`, `get_mut`, `set -> bool`, `in_bounds`, `iter() -> (Pos,&T)`, `fill_rect`, `as_slice`, `Index`/`IndexMut`, `map(|&T| -> U) -> Grid`, `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 { Dir::CARDINAL.into_iter().map(move |d| self.step(d)) } pub fn neighbors8(self) -> impl Iterator { 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 { 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 { 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 { (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 { 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 { size: Size, data: Vec } impl Grid { 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 { 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(&self, f: impl Fn(&T) -> U) -> Grid { 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 Index for Grid { type Output = T; fn index(&self, p: Pos) -> &T { self.get(p).expect("grid index out of bounds") } } impl IndexMut for Grid { 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, district: Option, building: Option, 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, light: Grid }`, `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 }`. - `ConnectorKind { Stair, Lift, Ladder, Manhole, Jack }`, `Connector { kind, pos, serves: Vec, public: bool }`, `Connector::other(from) -> Option` (next/prev not needed; `serves_from(k) -> Option<(Option, Option)>` returns (below, above) neighbors in `serves`). - `City { seed, params: GenParams, name, size, strata: Vec, districts, buildings, connectors, arterials: Vec>, net: Net }` with `empty(params)`, `stratum(k) -> Option<&Stratum>`, `stratum_mut`, `tile(k, p) -> Option<&Tile>`, `tile_mut`, `connectors_at(p) -> impl Iterator`, `district(id) -> &District`, `building(id) -> &Building`, `walkable(k, p) -> bool`. - `Net { subnets, nodes, links: Vec }`, `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`, `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::>(), StratumKind::PHYSICAL.to_vec()); let s = StrataSpan::from_class(HeightClass::Mid, false); assert_eq!(s.iter().collect::>(), 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 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::() < 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, second: Vec, suffix: Vec }`, `shops: Vec<(BuildingKind, Vec)>` templates with `{syl}` placeholder, `landmarks: Vec` templates with `{corp}` and `{district}`, `signage: Vec`. - `FlavorPack::load(name: &str) -> Result` ("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`, `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::() > 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` (4-connected). - `pathing::components(size, passable) -> Vec>` (all 4-connected components, largest first, deterministic order by first cell index). - `pathing::astar(size, start, goal, cost: impl Fn(Pos, Pos)->Option) -> Option>` (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>` (shortest 4-path through passable to the nearest target; path includes start and the target). - `pathing::bresenham(a: Pos, b: Pos) -> Vec`. - `gen::Stage` (11 variants, `Stage::ALL`, `label()`, `index() -> u8`), `gen::Progress { Running { stage: Stage, pct: f32 }, Done }`. - `gen::Generator::{new(params) -> Result, step(&mut self) -> Progress, city(&self) -> &City, finish(self) -> Result, run(params) -> Result, error(&self) -> Option<&GenError>}`. - `pub(crate) struct GenCtx { city: City, flavor: FlavorPack, seed: u64, scratch: Scratch }`, `pub(crate) struct Scratch { land: Grid, wealth: Grid, decay: Grid, arterial: Grid, road: Grid, blocks: Vec, lots: Vec, water_edge: Option }`, `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 { Continue(f32), Finished }`. - `pub(crate) fn runner_for(stage: Stage) -> Box` (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>` keyed by `f = g + manhattan`, with a tie-break counter for determinism, `came_from: Grid>`, `g: Grid` initialised to `u32::MAX`. Flood fill and BFS use `VecDeque` and a `Grid` visited. Generator step machinery: ```rust pub struct Generator { ctx: GenCtx, stage_idx: usize, runner: Option>, error: Option, done: bool } impl Generator { pub fn new(params: GenParams) -> Result { params.validate()?; let flavor = FlavorPack::load(¶ms.flavor)?; let city = City::empty(params.clone()); let size = city.size; let scratch = Scratch::new(size); Ok(Self { ctx: GenCtx { seed: params.seed, city, flavor, scratch }, stage_idx: 0, runner: None, error: None, done: false }) } pub fn step(&mut self) -> Progress { if self.done || self.error.is_some() { return Progress::Done; } let stage = Stage::ALL[self.stage_idx]; if self.runner.is_none() { let mut r = runner_for(stage); if let Err(e) = r.begin(&mut self.ctx) { self.error = Some(e); return Progress::Done; } self.runner = Some(r); } match self.runner.as_mut().unwrap().step(&mut self.ctx) { Ok(StepOut::Continue(pct)) => Progress::Running { stage, pct: pct.clamp(0.0, 1.0) }, Ok(StepOut::Finished) => { self.runner = None; self.stage_idx += 1; if self.stage_idx == Stage::ALL.len() { self.done = true; Progress::Done } else { Progress::Running { stage: Stage::ALL[self.stage_idx], pct: 0.0 } } } Err(e) => { self.error = Some(e); Progress::Done } } } pub fn finish(self) -> Result { 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 { 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>` (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` (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::() 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>` (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 = 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>`. - [ ] **Step 1: Tests** (`tests/seeds.rs`) ```rust use citygen::*; fn sweep(preset: SizePreset, seeds: std::ops::Range) { 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>)>`; `Drop` restores; `term::install_panic_hook()`. - `save::{FORMAT_VERSION: u16 = 1, save_city(path, &City) -> Result<()>, load_city(path) -> Result, list_cities(dir) -> Result>, 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, player: Option, style: StylePack, tick: u64, status: Option<(String, u64)>, pending_save: Option }, 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, fx: TransitionKind }, Quit }, run(initial: Box) -> Result<()>, MIN_W = 100, MIN_H = 30, draw_too_small(f) }`. - `cli::Cli` (clap): no subcommand → interactive; `Gen { seed: Option, size: String ("small"|"medium"|"large"|"WxH"), districts: Option, water: Option, 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, mut st: AppState) -> Result<()> { install_panic_hook(); let (_guard, mut term) = TerminalGuard::enter()?; let frame = Duration::from_micros(33_333); let mut transition: Option = 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` (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, rain: Vec, water: Vec, steam: Vec, mass: Vec, net: Vec }`, `StylePack::load() -> anyhow::Result` from `include_str!("../../../../flavor/style.ron")`, `theme(Theme) -> &ThemeStyle`, `stratum(StratumKind) -> &StratumStyle`, `hue(Hue) -> Rgb`. - `RenderCtx<'a> { style: &'a StylePack, theme: Option, stratum: StratumKind, light: u8, tick: u64, pos: Pos, vis: Visibility, connector: Option, kind: Option, 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>, overlays: &'a [&'a dyn Overlay], cursor: Option }` 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 }` 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, 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 }` 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` 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, 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, idx }, Text { label, value: String, max }, Button { label } }`, `Form { fields, selected }` with `handle(key) -> Option` (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, legend(stratum, style) -> Vec, strata_selector(current, tick) -> Vec, 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::(); 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 = (2..5).map(|y| (0..40).map(|x| t.backend().buffer()[(x, y)].symbol().to_string()).collect()).collect(); assert!(rows[0].contains("▸ NEW CITY")); assert!(rows[1].contains("LOAD CITY")); assert!(rows[2].contains("QUIT")); } #[test] fn form_adjusts_and_fires_button() { let mut f = Form { fields: vec![Field::Number { label: "Districts".into(), value: 7, min: 4, max: 12, step: 1 }, Field::Button { label: "GENERATE".into() }], selected: 0 }; assert_eq!(f.handle(KeyCode::Right), None); assert!(matches!(f.fields[0], Field::Number { value: 8, .. })); for _ in 0..10 { f.handle(KeyCode::Right); } assert!(matches!(f.fields[0], Field::Number { value: 12, .. })); f.handle(KeyCode::Down); assert_eq!(f.handle(KeyCode::Enter), Some(1)); } #[test] fn logo_width_and_render() { assert_eq!(logo::width("AB"), 12); let mut t = Terminal::new(TestBackend::new(60, 7)).unwrap(); t.draw(|f| Logo { text: "CITY 9", tick: 3, accent: Rgb(255, 0, 200) }.render(f.area(), f.buffer_mut())).unwrap(); assert!((0..60).any(|x| t.backend().buffer()[(x, 0)].symbol() == "█")); } #[test] fn glitch_transition_ends() { let from = Buffer::empty(Rect::new(0, 0, 20, 5)); let tr = Transition::new(TransitionKind::Glitch, from, 10); assert!(tr.active(12)); assert!(!tr.active(17)); } ``` `handle` takes `KeyCode` (crossterm) for testability. - [ ] **Step 2–3:** implement; the block font is a `const FONT: [(char, [&str; 5]); 37]` table (write all 37; e.g. `('A', ["▄▀▀▄ ", "█ █ ", "█▀▀█ ", "█ █ ", "▀ ▀ "])`, each row exactly 5 chars plus a 1-col gap = 6 cols). - [ ] **Step 4: Run tests** → PASS; gate. - [ ] **Step 5: Commit** `feat(city): logo, menu, form, panels and transitions`. --- ### Task 24: Title, Configure and Files screens **Files:** - Create: `screens/title.rs`, `screens/configure.rs`, `screens/files.rs` - Modify: `screens/mod.rs`, `main.rs` (start on `Title`), remove `Blank` **Interfaces:** - Produces: - `Title::new(tick) -> Title` (Screen). Draw: `Logo { text: "CYBERPUNK" }` centred in the top third with accent magenta; subtitle "city generator" typewritten; `MenuView` centred below; bottom hint "↑↓ select · enter confirm · q quit". Update: Up/Down/j/k move; Enter → `NEW CITY` ⇒ `Switch { Configure::new(st.params.clone(), tick), Glitch }`, `LOAD CITY` ⇒ `Switch { Files::load(tick), SlideUp }`, `QUIT` ⇒ `Quit`; `q`/Esc ⇒ Quit. - `Configure::new(params, tick)` (Screen). Form fields in order: Text seed (digits only, max 20; empty ⇒ random), Choice size [small, medium, large], Number districts 4..12, Slider density 0..1 step .05, Slider wealth skew −1..1 step .1, Slider decay 0..1 step .05, Choice water [none, bay, river], Choice flavor [default], Button RANDOM SEED, Button GENERATE. Right side shows a live "preview card": chosen size in tiles, estimated buildings (`area/900 * density`), current seed. Update: `handle(key)`; RANDOM SEED fills the seed from `hash64(tick, 0xC0FFEE)`; GENERATE builds `GenParams`, runs `validate()`; on error show it in red under the form (status for 90 ticks); on success `st.params = params` and `Switch { Generating::new(params, tick)?, Glitch }` (if `Generator::new` fails, show the error instead). Esc ⇒ `Switch { Title, SlideUp }`. - `Files::load(tick)` and `Files::save(tick, city_name)` (Screen). Load mode: list from `save::list_cities(default_dir())` rendered as rows `name · seed · WxH · modified`, Up/Down select, Enter ⇒ `load_city` → `st.city = Some(city); st.player = None; Switch { MapScreen::explore(tick), Glitch }`, errors shown in status; Esc ⇒ Title. Save mode: a single `Field::Text` prefilled with the city name, Enter ⇒ `save_city(default_dir().join(format!("{name}.city")))` (create dir), status "saved " and `Switch { MapScreen::explore(tick), None }`; Esc ⇒ back to the map. - [ ] **Step 1: Tests** ```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` (holds `Generator`, `steps_per_frame: u32 = 8`, `paused: bool`, `stage_done_tick: [Option; 11]`, `cam: Camera`, `error: Option`). Update: if not paused and not finished, call `gen.step()` up to `steps_per_frame` times (Enter ⇒ loop until `Done`); Space toggles pause; `+`/`-` doubles/halves steps (1..=64); when `Done`: `gen.finish()` → `st.city = Some(city); st.player = None; Switch { MapScreen::explore(tick), Glitch }` or keep the error on screen with hint `r reseed · esc back`; `r` ⇒ new `Generating` with `params.seed = hash64(seed, tick)`; Esc ⇒ Configure. Draw: left `MapView` of Street (origin = camera centred on map centre, camera slowly drifts: `tx = cx + (sin(tick/90)*w/6)`), right panel 34 wide: title "GENERATING ", one row per `Stage::ALL`: `[✓]` done (dim), `[▸]` active with a 16-wide progress bar `▓▓░░ 62%`, `[ ]` pending; bottom hint `space pause · +/- speed · enter skip`. - [ ] **Step 1: Test** ```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` 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> (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` (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`. - [ ] **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, 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`.