# Cyberspace 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:** Make the reserved `Net` stratum a real, walkable cyberspace generated from the city, and let the player jack in at street terminals and back out, leaving the body at the port. **Architecture:** A twelfth generator stage (`Cyberspace`) builds a fifth tile map from the finished city and Net graph: constructs on building footprints, hubs around district backbones, A*-routed lanes for inter-construct links, nodes as tile features, and `Jack` connectors at street terminals. The `cyberpunk` crate gets a Net tileset, a `body` on the player with `jack_in`/`jack_out`, Explore access to the fifth stratum, and a free-moving Explore cursor. Save format bumps to version 2. **Tech Stack:** Rust 2024, ratatui 0.29, crossterm 0.28, serde + postcard, existing `citygen` pathing (`astar`), existing `walker::fov`. **Spec:** `docs/superpowers/specs/2026-10-12-cyberspace-design.md` ## Global Constraints - Same seed and parameters produce a byte-identical city, Net stratum included (seed sweeps in `crates/citygen/tests/seeds.rs` stay green). - Every glyph is exactly one terminal cell wide (`style_glyphs_are_single_width` in `tileset.rs` covers `ALL_TERRAIN` and `ALL_FEATURES`). - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` green after every task. - No new crates. `citygen` keeps zero terminal dependencies. - Medium city generates in under two seconds (existing test); large city target under 0.3 s release. - Keys: jack in/out on **Enter** in Walk mode (`j` is "move south"). Explore `[` `]` range over all five strata. ## Review Focus 1. A private port (lobby terminal inside a building): the avatar emerges inside the construct and must be able to walk out through the gate. Test in Task 4 (`every_jack_reaches_the_open_grid`). 2. A `Jack` must not count as a district's public connector, or a district with no stairs passes validation. Test in Task 5 (`jack_is_not_a_public_connector_for_districts`). 3. A hub ring or a neighbouring construct must never seal a construct gate or stand on a port tile. Test in Task 4 (`gates_open_onto_walkable_tiles`) and the Net reachability check in Task 5. 4. On a Net tile that holds a Jack, Walk `<`/`>` must not move the avatar onto the unwalkable terminal tile on Street. Test in Task 7 (the `use_connector` asserts in `jacking_in_leaves_the_body_at_the_port`). 5. A version 1 `.city` file must still list in the load screen and refuse to load with a message naming its seed, never a panic. Test in Task 10 (`version_one_files_list_but_refuse_to_load`). --- ## File structure | File | Responsibility | |---|---| | `crates/citygen/src/tile.rs` | Net terrains, `Feature::Node`, `Terrain::label`, walkability, sight | | `crates/citygen/src/world.rs` | `StratumKind::ALL` | | `crates/citygen/src/net.rs` | `Node.cyber`, `Net::node_in_cyber` | | `crates/citygen/src/generate/net.rs` | sets `cyber = anchor.1` on node creation | | `crates/citygen/src/generate/mod.rs` | `Stage::Cyberspace`, runner wiring | | `crates/citygen/src/generate/cyberspace.rs` | **new**: the stage (grid, constructs, gates, hubs, nodes, lanes, ports) | | `crates/citygen/src/validate.rs` | `CyberAnchor`, `JackUnreachable`, Net reachability, Jack excluded from district public check | | `crates/cyberpunk/src/render/tileset.rs` | Net glyphs, colours, `connects`, legend, `node_glyph` | | `crates/cyberpunk/src/render/overlay.rs` | `node_under` on the Net, links in cyberspace, shared `node_glyph` | | `crates/cyberpunk/src/walker/mod.rs` | `body`, `jack_in`, `jack_out`, `jacked_in`, five memory grids, Net radius | | `crates/cyberpunk/src/screens/explore.rs` | free cursor, five strata, Enter to jack, avatar and body drawing, hints | | `crates/cyberpunk/src/screens/help.rs` | key rows | | `crates/cyberpunk/src/screens/generating.rs` | short label for the new stage | | `crates/cyberpunk/src/ui/panel.rs` | selector over `ALL`, `Terrain::label`, node feature label | | `crates/cyberpunk/src/save.rs` | version 2, version 1 refusal | | `README.md` | keys, strata table, cyberspace paragraph | --- ### Task 1: Net tiles in the model and the tileset **Files:** - Modify: `crates/citygen/src/tile.rs` - Modify: `crates/citygen/src/world.rs` (after `PHYSICAL`, line 21) - Modify: `crates/cyberpunk/src/render/tileset.rs` - Test: `crates/citygen/src/tile.rs` (tests module), `crates/cyberpunk/src/render/tileset.rs` (tests module) **Interfaces:** - Consumes: `citygen::net::NodeKind` (existing, derives `Hash + Eq`), `crate::render::overlay::SIGNAL: Rgb`. - Produces: `Terrain::{NetGrid, NetLane, NetWall, NetFloor, NetGate}`, `Terrain::label(self) -> &'static str`, `Terrain::is_net(self) -> bool`, `Feature::Node(NodeKind)`, `ALL_TERRAIN: [Terrain; 25]`, `ALL_FEATURES: [Feature; 16]`, `StratumKind::ALL: [StratumKind; 5]`, `tileset::node_glyph(kind: NodeKind, style: &StylePack) -> char`. - [ ] **Step 1: Write the failing model test** In `crates/citygen/src/tile.rs` tests module, add: ```rust #[test] fn net_tiles_walk_and_block_like_the_spec_says() { assert!(Tile::new(Terrain::NetGrid).is_walkable()); assert!(Tile::new(Terrain::NetLane).is_walkable()); assert!(Tile::new(Terrain::NetFloor).is_walkable()); assert!(Tile::new(Terrain::NetGate).is_walkable()); assert!(!Tile::new(Terrain::NetWall).is_walkable()); assert!(Tile::new(Terrain::NetWall).blocks_sight()); assert!(!Tile::new(Terrain::NetGate).blocks_sight()); let mut t = Tile::new(Terrain::NetGrid); t.feature = Some(Feature::Node(crate::net::NodeKind::Ice)); assert!(t.is_walkable(), "nodes never block walking in this spec"); assert_eq!(Terrain::NetWall.label(), "construct wall"); assert_eq!(Terrain::Road.label(), "road"); assert!(Terrain::NetLane.is_net() && !Terrain::Road.is_net()); assert_eq!(ALL_TERRAIN.len(), 25); assert_eq!(ALL_FEATURES.len(), 16); assert_eq!(crate::world::StratumKind::ALL.len(), 5); assert_eq!(crate::world::StratumKind::ALL[4], crate::world::StratumKind::Net); } ``` Also change the existing `walkability_rules` asserts `assert_eq!(ALL_TERRAIN.len(), 20);` → `25` and `ALL_FEATURES.len(), 15` → `16`. - [ ] **Step 2: Run it to verify it fails** Run: `cargo test -p citygen net_tiles_walk -- --nocapture` Expected: compile error, `no variant named NetGrid`. - [ ] **Step 3: Implement the model** In `crates/citygen/src/tile.rs`: ```rust use crate::net::NodeKind; ``` Extend the enum and constant: ```rust Grate, NetGrid, NetLane, NetWall, NetFloor, NetGate, } pub const ALL_TERRAIN: [Terrain; 25] = [ /* existing 20 entries unchanged */ Terrain::NetGrid, Terrain::NetLane, Terrain::NetWall, Terrain::NetFloor, Terrain::NetGate, ]; impl Terrain { /// Human label for the inspector and legend. pub fn label(self) -> &'static str { use Terrain::*; match self { Void => "void", Water => "water", Sewage => "sewage", Road => "road", Sidewalk => "sidewalk", Alley => "alley", Plaza => "plaza", Floor => "floor", Wall => "wall", Door => "door", Window => "window", Rooftop => "rooftop", Walkway => "walkway", Bridge => "bridge", Tunnel => "tunnel", Rail => "rail", Stair => "stair", Lift => "lift", Ladder => "ladder", Grate => "grate", NetGrid => "grid", NetLane => "lane", NetWall => "construct wall", NetFloor => "construct floor", NetGate => "gate", } } /// Cyberspace-only terrain. pub fn is_net(self) -> bool { use Terrain::*; matches!(self, NetGrid | NetLane | NetWall | NetFloor | NetGate) } } ``` Feature: ```rust Cable, /// A Net node standing on this cyberspace tile. Node(NodeKind), } pub const ALL_FEATURES: [Feature; 16] = [ /* existing 15 */ Feature::Node(NodeKind::Server), ]; ``` Walkability: add `| NetGrid | NetLane | NetFloor | NetGate` to the `terrain_ok` list. Sight: `matches!(self.terrain, Terrain::Wall | Terrain::Door | Terrain::NetWall)`. In `crates/citygen/src/world.rs` after `PHYSICAL`: ```rust /// Every stratum a finished city has, bottom first, Net last. pub const ALL: [StratumKind; 5] = [ StratumKind::Undercity, StratumKind::Street, StratumKind::Skyways, StratumKind::Heights, StratumKind::Net, ]; ``` - [ ] **Step 4: Run the citygen tests** Run: `cargo test -p citygen` Expected: PASS (the `cyberpunk` crate does not compile yet; that is the next step). If any `match` on `Terrain` in `citygen` is non-exhaustive, add a `_ => {}`-free explicit arm that treats the Net terrains like `Void`. - [ ] **Step 5: Write the failing tileset test** In `crates/cyberpunk/src/render/tileset.rs` tests module: ```rust #[test] fn net_tiles_render_in_the_signal_palette() { let s = StylePack::load().unwrap(); let ctx = |stratum| RenderCtx { style: &s, theme: Some(Theme::CorpCore), stratum, light: 255, tick: 0, vis: Visibility::Visible, connector: None, kind: None, wall_mask: MASK_E | MASK_W, }; let wall = cell(&Tile::new(Terrain::NetWall), &ctx(StratumKind::Net)); assert_eq!(wall.ch, '━'); assert!(wall.fg.2 > wall.fg.0, "wall is cyan-ish: {:?}", wall.fg); let lane = cell(&Tile::new(Terrain::NetLane), &ctx(StratumKind::Net)); assert_eq!(lane.ch, '═'); let mut grid = Tile::new(Terrain::NetGrid); grid.variant = 1; assert_eq!(cell(&grid, &ctx(StratumKind::Net)).ch, '·'); grid.variant = 2; assert_eq!(cell(&grid, &ctx(StratumKind::Net)).ch, ' '); assert_eq!(cell(&Tile::new(Terrain::NetGate), &ctx(StratumKind::Net)).ch, '▯'); let mut node = Tile::new(Terrain::NetGrid); node.feature = Some(Feature::Node(NodeKind::Server)); assert_eq!(cell(&node, &ctx(StratumKind::Net)).ch, node_glyph(NodeKind::Server, &s)); assert_eq!(node_glyph(NodeKind::Server, &s), '◆'); assert_eq!(node_glyph(NodeKind::Ice, &s), '◈'); assert!(connects(Terrain::NetWall, Terrain::NetGate)); assert!(connects(Terrain::NetLane, Terrain::NetGate)); assert!(!connects(Terrain::NetLane, Terrain::NetWall)); let legend = legend_entries(StratumKind::Net); assert!(legend.iter().any(|(_, l)| *l == "construct wall")); assert!(!legend.iter().any(|(_, l)| *l == "stairs")); } ``` Add `NodeKind` to the test module's `use citygen::{...}`. - [ ] **Step 6: Run it to verify it fails** Run: `cargo test -p cyberpunk net_tiles_render` Expected: compile error (non-exhaustive `match` on `Terrain` in `glyph_for`/`terrain_colors`, missing `node_glyph`). - [ ] **Step 7: Implement the tileset** In `crates/cyberpunk/src/render/tileset.rs`: ```rust use crate::render::overlay::SIGNAL; use citygen::{BuildingKind, ConnectorKind, Feature, NodeKind, StratumKind, Terrain, Tile}; /// Glyph for a Net node kind from the style's `net` set. pub fn node_glyph(kind: NodeKind, style: &StylePack) -> char { let g = &style.glyphs.net; let i = match kind { NodeKind::Terminal => 0, NodeKind::Server => 1, NodeKind::Backbone => 2, NodeKind::DataVault => 3, NodeKind::Ice => 5, _ => 4, }; g[i % g.len()] } ``` `connects`: ```rust pub fn connects(a: Terrain, b: Terrain) -> bool { use Terrain::*; let wallish = |t| matches!(t, Wall | Door | Window); let spanish = |t| matches!(t, Walkway | Bridge); let netwall = |t| matches!(t, NetWall | NetGate); let lane = |t| matches!(t, NetLane | NetGate); (wallish(a) && wallish(b)) || (spanish(a) && (spanish(b) || matches!(b, Door | Floor | Stair | Lift | Ladder))) || (netwall(a) && netwall(b)) || (lane(a) && lane(b)) } ``` `glyph_for` feature arm: `Feature::Node(k) => node_glyph(k, style),`. Terrain arms: ```rust Terrain::NetGrid => { if variant & 1 == 1 { '·' } else { ' ' } } Terrain::NetLane => DOUBLE[(mask & 15) as usize], Terrain::NetWall => { let set = if kind.is_some_and(|k| k.is_corp()) { &DOUBLE } else { &HEAVY }; set[(mask & 15) as usize] } Terrain::NetFloor => ' ', Terrain::NetGate => '▯', ``` (`DOUBLE[0]` is `═`, so an unconnected lane already draws as `═`.) `terrain_colors` gains `tile: &Tile, tick: u64` parameters (call site in `cell`: `terrain_colors(tile.terrain, tile, ctx, tick)`) and arms: ```rust Terrain::NetGrid => (ts.accent.mix(SIGNAL, 0.5).scale(0.45), Rgb(3, 6, 10)), Terrain::NetLane => ( SIGNAL.scale(0.6 + 0.4 * pulse(tick + tile.variant as u64, 20)), Rgb(3, 6, 10), ), Terrain::NetWall => (SIGNAL, Rgb(3, 6, 10)), Terrain::NetFloor => (Rgb(10, 40, 44), Rgb(6, 18, 22)), Terrain::NetGate => (Rgb(200, 255, 245), Rgb(6, 18, 22)), ``` `feature_color`: `Feature::Node(_) => Rgb(240, 255, 250).scale(0.7 + 0.3 * pulse(tick + tile.variant as u64, 30)),`. In `cell`: add `| Feature::Node(_)` to the `emits` list, and make the bold rule: ```rust let jack = ctx .connector .is_some_and(|c| c.kind == ConnectorKind::Jack); let bold = tile.is_connector() || matches!(tile.feature, Some(Feature::Sign(_) | Feature::Neon(_))) || (matches!(tile.feature, Some(Feature::Node(_))) && jack); ``` `legend_entries`: replace the `StratumKind::Net` arm with ```rust StratumKind::Net => vec![ ('·', "grid"), ('═', "lane"), ('┃', "construct wall"), ('▯', "gate"), ('◇', "terminal / port"), ('◆', "server"), ('⬡', "backbone"), ('⌗', "data vault"), ('◈', "ice"), ('▪', "sensor"), ], ``` and guard the common rows: `if k != StratumKind::Net { out.extend(common); }`. - [ ] **Step 8: Run the whole suite** Run: `cargo test --workspace` Expected: PASS. Any other non-exhaustive `match` on `Terrain` or `Feature` the compiler reports (weather, minimap, dressing) gets an explicit arm that treats Net terrain as not-outdoor, not-building, not-water. - [ ] **Step 9: Commit** ```bash git add crates/citygen/src/tile.rs crates/citygen/src/world.rs crates/cyberpunk/src/render/tileset.rs git commit -m "feat(net): cyberspace terrains, node features and their tileset" ``` --- ### Task 2: Nodes know their cyberspace tile **Files:** - Modify: `crates/citygen/src/net.rs` - Modify: `crates/citygen/src/generate/net.rs` (`add_node`, line ~44) - Test: `crates/citygen/src/net.rs` (new tests module) **Interfaces:** - Produces: `Node.cyber: Pos`, `Net::node_in_cyber(&self, p: Pos) -> Option<&Node>`. - [ ] **Step 1: Write the failing test** Append to `crates/citygen/src/net.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn nodes_are_found_by_cyber_tile() { let mut net = Net::default(); net.subnets.push(Subnet { id: SubnetId(0), owner: SubnetOwner::City, security: 10, }); net.nodes.push(Node { id: NodeId(0), kind: NodeKind::Server, anchor: (StratumKind::Street, Pos::new(5, 5)), cyber: Pos::new(7, 5), subnet: SubnetId(0), building: None, }); assert_eq!(net.node_in_cyber(Pos::new(7, 5)).map(|n| n.id), Some(NodeId(0))); assert_eq!(net.node_in_cyber(Pos::new(5, 5)), None); assert!(net.node_at(StratumKind::Street, Pos::new(5, 5)).is_some()); } } ``` - [ ] **Step 2: Run it to verify it fails** Run: `cargo test -p citygen nodes_are_found_by_cyber_tile` Expected: compile error, `struct Node has no field named cyber`. - [ ] **Step 3: Implement** `crates/citygen/src/net.rs`: ```rust pub struct Node { pub id: NodeId, pub kind: NodeKind, pub anchor: (StratumKind, Pos), /// The node's tile on the Net stratum. pub cyber: Pos, pub subnet: SubnetId, pub building: Option, } impl Net { pub fn node_in_cyber(&self, p: Pos) -> Option<&Node> { self.nodes.iter().find(|n| n.cyber == p) } } ``` `crates/citygen/src/generate/net.rs` `add_node`: `cyber: anchor.1,` in the `Node { .. }` literal. - [ ] **Step 4: Run the suite** Run: `cargo test --workspace` Expected: PASS. Any test that builds a `Node` literal (search `Node {` under `crates/`) gets `cyber: `. - [ ] **Step 5: Commit** ```bash git add crates/citygen/src/net.rs crates/citygen/src/generate/net.rs git commit -m "feat(net): nodes carry their cyberspace tile" ``` --- ### Task 3: Stage 12 — grid, constructs, gates, hubs **Files:** - Create: `crates/citygen/src/generate/cyberspace.rs` - Modify: `crates/citygen/src/generate/mod.rs` (module list, `Stage`, `ALL`, `label`, `runner_for`, doc comment) - Modify: `crates/cyberpunk/src/screens/generating.rs:32-45` (`short`) - Modify: `crates/cyberpunk/src/save.rs:96` (`strata_ok` uses `StratumKind::ALL`) - Test: `crates/citygen/src/generate/cyberspace.rs` (tests module) **Interfaces:** - Consumes: `GenCtx`, `StageRunner`, `StepOut` (generate/mod.rs), `Stratum::new`, `tile_hash(seed, k, p) -> u8`, `Rect::{border, inset, iter, right, bottom, center}`, `Pos::dist2`, `SubnetOwner`, `NodeKind`. - Produces: `Stage::Cyberspace`; `CyberspaceStage` with `gates: Vec` (per building index) and `backbones: Vec>` (per district index) used by Task 4; helper `set(ctx, p, terrain, building, light)`. - [ ] **Step 1: Write the failing test** Create `crates/citygen/src/generate/cyberspace.rs` with the module doc and a tests module only: ```rust //! Stage 12: cyberspace — the Net stratum as a grid with constructs, hubs, lanes and ports. #[cfg(test)] mod tests { use crate::generate::Generator; use crate::geom::Pos; use crate::params::{GenParams, SizePreset}; use crate::tile::Terrain; use crate::world::StratumKind; pub(crate) fn city() -> crate::world::City { Generator::run(GenParams { seed: 6, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }) .unwrap() } #[test] fn net_stratum_is_a_grid_with_constructs_on_every_footprint() { let c = city(); let net = c.stratum(StratumKind::Net).expect("fifth stratum"); assert_eq!(c.strata.len(), 5); assert_eq!(net.tiles.size(), c.size); // Grid tiles mirror the street's districts and carry the lattice bit. let lattice = Pos::new(4, 2); let t = net.tiles[lattice]; assert_eq!(t.terrain, Terrain::NetGrid); assert_eq!(t.variant & 1, 1); assert_eq!(net.tiles[Pos::new(5, 2)].variant & 1, 0); assert_eq!(t.district, c.tile(StratumKind::Street, lattice).unwrap().district); for b in &c.buildings { let fp = b.footprint; let gates = fp .border() .filter(|p| net.tiles[*p].terrain == Terrain::NetGate) .count(); assert_eq!(gates, 1, "building {:?} has {gates} gates", b.id); for p in fp.border() { let t = net.tiles[p]; assert!( matches!(t.terrain, Terrain::NetWall | Terrain::NetGate), "{:?} border {p:?} is {:?}", b.id, t.terrain ); assert_eq!(t.building, Some(b.id)); } for p in fp.inset(1).iter() { assert_eq!(net.tiles[p].terrain, Terrain::NetFloor); } let light = net.light[fp.center()]; assert!(light >= 96, "construct light {light}"); } assert!(net.tiles.count(|t| t.terrain == Terrain::NetGrid) > c.size.area() / 4); } #[test] fn hubs_ring_every_backbone_that_stands_on_the_grid() { let c = city(); let net = c.stratum(StratumKind::Net).unwrap(); let mut hubs = 0; for n in c.net.nodes.iter().filter(|n| n.kind == crate::net::NodeKind::Backbone) { let a = n.anchor.1; if net.tiles[a].building.is_some() { continue; } hubs += 1; let ring = crate::geom::Rect::new(a.x - 2, a.y - 2, 5, 5); let walls = ring .border() .filter(|p| net.tiles.get(*p).is_some_and(|t| t.terrain == Terrain::NetWall)) .count(); let gates = ring .border() .filter(|p| net.tiles.get(*p).is_some_and(|t| t.terrain == Terrain::NetGate)) .count(); assert!(walls >= 8, "hub at {a:?} has only {walls} wall tiles"); assert!(gates >= 2, "hub at {a:?} has only {gates} gates"); } assert!(hubs >= 1); } } ``` - [ ] **Step 2: Run it to verify it fails** Run: `cargo test -p citygen cyberspace::` Expected: compile error, `unresolved module cyberspace` (add `pub mod cyberspace;` to `generate/mod.rs` first, then the test fails with `expected fifth stratum`). - [ ] **Step 3: Wire the stage** `crates/citygen/src/generate/mod.rs`: - Doc comment: `//! The resumable generator: twelve chunked stages over a growing \`City\`.` - `pub mod cyberspace;` in the module list (alphabetical: after `connectivity`). - `Stage` gains `Cyberspace,` after `Net`; `ALL: [Stage; 12]` adds `Stage::Cyberspace`; `label`: `Stage::Cyberspace => "Compiling cyberspace",`; `runner_for`: `Stage::Cyberspace => Box::new(cyberspace::CyberspaceStage::default()),`. `crates/cyberpunk/src/screens/generating.rs` `short`: `Stage::Cyberspace => "Cyber",`. `crates/cyberpunk/src/save.rs`: `let strata_ok = city.strata.len() == citygen::StratumKind::ALL.len() && ...`. - [ ] **Step 4: Implement grid, constructs, gates, hubs** Above the tests module in `cyberspace.rs`: ```rust use super::{GenCtx, StageRunner, StepOut}; use crate::geom::{Pos, Rect}; use crate::ids::{BuildingId, DistrictId}; use crate::net::{NodeKind, SubnetOwner}; use crate::params::GenError; use crate::rng::tile_hash; use crate::tile::{Feature, Terrain, Tile}; use crate::world::{Stratum, StratumKind}; use std::collections::VecDeque; const K: StratumKind = StratumKind::Net; const GRID_LIGHT: u8 = 200; const HUB_RADIUS: i32 = 2; enum Work { Begin, Construct(usize), Gate(usize), Hub(usize), } #[derive(Default)] pub(crate) struct CyberspaceStage { queue: VecDeque, total: usize, /// Gate tile per building index. pub(crate) gates: Vec, /// Backbone anchor per district index. pub(crate) backbones: Vec>, } /// Sets terrain, owner and light of one Net tile; out-of-bounds is a no-op. pub(crate) fn set(ctx: &mut GenCtx, p: Pos, t: Terrain, b: Option, light: u8) { if let Some(tile) = ctx.city.tile_mut(K, p) { tile.terrain = t; tile.building = b; } if let Some(st) = ctx.city.stratum_mut(K) { st.light.set(p, light); } } fn is_corner(r: Rect, p: Pos) -> bool { (p.x == r.x || p.x == r.right() - 1) && (p.y == r.y || p.y == r.bottom() - 1) } /// The tile just outside a footprint border tile, away from the centre. fn outward(r: Rect, p: Pos) -> Pos { if p.y == r.y { p.offset(0, -1) } else if p.y == r.bottom() - 1 { p.offset(0, 1) } else if p.x == r.x { p.offset(-1, 0) } else { p.offset(1, 0) } } fn backbone_anchor(ctx: &GenCtx, d: DistrictId) -> Option { ctx.city .net .nodes .iter() .find(|n| { n.kind == NodeKind::Backbone && ctx.city.net.subnet(n.subnet).owner == SubnetOwner::District(d) }) .map(|n| n.anchor.1) } impl CyberspaceStage { fn begin_net(ctx: &mut GenCtx) { let size = ctx.size(); ctx.city.strata.retain(|s| s.kind != K); let mut st = Stratum::new(K, size); for y in 0..size.h as i32 { for x in 0..size.w as i32 { let p = Pos::new(x, y); let district = ctx.city.tile(StratumKind::Street, p).and_then(|t| t.district); let lattice = u8::from(x % 4 == 0 && y % 2 == 0); let variant = (tile_hash(ctx.seed, K, p) & 0xFE) | lattice; st.tiles.set( p, Tile { terrain: Terrain::NetGrid, feature: None, district, building: None, variant, }, ); st.light.set(p, GRID_LIGHT); } } ctx.city.strata.push(st); } fn construct(&mut self, ctx: &mut GenCtx, i: usize) { let b = ctx.city.buildings[i].clone(); let security = ctx .city .net .subnets .iter() .find(|s| s.owner == SubnetOwner::Building(b.id)) .map(|s| s.security) .unwrap_or(0); let light = 96u8.saturating_add((security as u32 * 5 / 8) as u8); for p in b.footprint.iter() { set(ctx, p, Terrain::NetWall, Some(b.id), light); } for p in b.footprint.inset(1).iter() { set(ctx, p, Terrain::NetFloor, Some(b.id), light); } } /// Picks the gate once every construct stands, so it never opens onto another wall. fn gate(&mut self, ctx: &mut GenCtx, i: usize) { let b = ctx.city.buildings[i].clone(); let fp = b.footprint; let target = self.backbones[b.district.index()].unwrap_or(fp.center()); let open = |ctx: &GenCtx, p: Pos| { ctx.city .tile(K, outward(fp, p)) .is_some_and(|t| t.terrain == Terrain::NetGrid) }; let candidates: Vec = fp.border().filter(|p| !is_corner(fp, *p)).collect(); let gate = candidates .iter() .copied() .filter(|p| open(ctx, *p)) .min_by_key(|p| (p.dist2(target), p.y, p.x)) .or_else(|| { candidates .iter() .copied() .min_by_key(|p| (p.dist2(target), p.y, p.x)) }) .unwrap_or(fp.center()); set(ctx, gate, Terrain::NetGate, Some(b.id), 255); self.gates[i] = gate; } fn hub(&mut self, ctx: &mut GenCtx, d: usize) { let Some(c) = self.backbones[d] else { return }; if ctx.city.tile(K, c).is_none_or(|t| t.building.is_some()) { return; } let r = Rect::new( c.x - HUB_RADIUS, c.y - HUB_RADIUS, 2 * HUB_RADIUS + 1, 2 * HUB_RADIUS + 1, ); let keep_open = |ctx: &GenCtx, p: Pos| { // Never wall a port, and never seal a construct gate. ctx.city .tile(StratumKind::Street, p) .is_some_and(|t| t.feature == Some(Feature::Terminal)) || p .neighbors4() .any(|q| ctx.terrain(K, q) == Terrain::NetGate) }; for p in r.border() { if ctx.terrain(K, p) == Terrain::NetGrid && !keep_open(ctx, p) { set(ctx, p, Terrain::NetWall, None, 255); } } for p in [ Pos::new(c.x, r.y), Pos::new(r.right() - 1, c.y), Pos::new(c.x, r.bottom() - 1), Pos::new(r.x, c.y), ] { if ctx.terrain(K, p) == Terrain::NetWall && ctx.city.tile(K, p).is_some_and(|t| t.building.is_none()) { set(ctx, p, Terrain::NetGate, None, 255); } } } } impl StageRunner for CyberspaceStage { fn begin(&mut self, ctx: &mut GenCtx) -> Result<(), GenError> { let nb = ctx.city.buildings.len(); let nd = ctx.city.districts.len(); self.gates = vec![Pos::new(0, 0); nb]; self.backbones = (0..nd) .map(|d| backbone_anchor(ctx, DistrictId(d as u32))) .collect(); self.queue.clear(); self.queue.push_back(Work::Begin); self.queue.extend((0..nb).map(Work::Construct)); self.queue.extend((0..nb).map(Work::Gate)); self.queue.extend((0..nd).map(Work::Hub)); self.total = self.queue.len().max(1); Ok(()) } fn step(&mut self, ctx: &mut GenCtx) -> Result { let Some(work) = self.queue.pop_front() else { return Ok(StepOut::Finished); }; match work { Work::Begin => Self::begin_net(ctx), Work::Construct(i) => self.construct(ctx, i), Work::Gate(i) => self.gate(ctx, i), Work::Hub(d) => self.hub(ctx, d), } let done = self.total - self.queue.len(); Ok(StepOut::Continue(done as f32 / self.total as f32)) } } ``` Check `Rect::inset(1)` on a footprint narrower than 3: read `geom.rs:120`; if `inset` can produce a negative width whose `iter()` misbehaves, guard with `if fp.w > 2 && fp.h > 2`. - [ ] **Step 5: Run the tests** Run: `cargo test -p citygen cyberspace::` Expected: PASS both tests. - [ ] **Step 6: Run the whole suite** Run: `cargo test --workspace` Expected: PASS. The generating-screen test iterates `Stage::ALL` and the save round trip now expects five strata. - [ ] **Step 7: Commit** ```bash git add crates/citygen/src/generate/cyberspace.rs crates/citygen/src/generate/mod.rs crates/cyberpunk/src/screens/generating.rs crates/cyberpunk/src/save.rs git commit -m "feat(citygen): cyberspace stage with grid, constructs, gates and hubs" ``` --- ### Task 4: Stage 12 — nodes, lanes, ports **Files:** - Modify: `crates/citygen/src/generate/cyberspace.rs` - Test: same file **Interfaces:** - Consumes: `pathing::astar(size, start, goal, cost: impl Fn(Pos, Pos) -> Option) -> Option>` (4-neighbour steps; confirm in `pathing.rs:58`), `Connector`, `ConnectorKind::Jack`, `Net::node_at`. - Produces: `Feature::Node(kind)` on Net tiles with `node.cyber` matching; `Terrain::NetLane` tiles; `Jack` connectors `serves: [Street, Net]`. - [ ] **Step 1: Write the failing tests** Add to the tests module: ```rust #[test] fn every_node_stands_on_its_feature_and_ice_guards_the_gate() { let c = city(); let net = c.stratum(StratumKind::Net).unwrap(); for n in &c.net.nodes { assert_eq!( net.tiles[n.cyber].feature, Some(crate::tile::Feature::Node(n.kind)), "node {:?} ({:?}) at {:?}", n.id, n.kind, n.cyber ); } let mut ice = 0; for n in c.net.nodes.iter().filter(|n| n.kind == crate::net::NodeKind::Ice) { let Some(b) = n.building else { continue }; ice += 1; let fp = c.building(b).footprint; let gate = fp .border() .find(|p| net.tiles[*p].terrain == Terrain::NetGate) .unwrap(); assert!( n.cyber.chebyshev(gate) <= 1, "ICE {:?} at {:?} is not at gate {gate:?}", n.id, n.cyber ); } assert!(ice >= 1, "fixture has no ICE"); } #[test] fn lanes_link_every_construct_gate_to_its_backbone() { let c = city(); let net = c.stratum(StratumKind::Net).unwrap(); assert!(net.tiles.count(|t| t.terrain == Terrain::NetLane) > 20); for b in &c.buildings { let gate = b .footprint .border() .find(|p| net.tiles[*p].terrain == Terrain::NetGate) .unwrap(); let backbone = c .net .nodes .iter() .find(|n| { n.kind == crate::net::NodeKind::Backbone && c.net.subnet(n.subnet).owner == crate::net::SubnetOwner::District(b.district) }) .unwrap(); let reach = crate::pathing::flood_fill(c.size, gate, |p| { net.tiles.get(p).is_some_and(|t| t.is_walkable()) }); assert!( reach.contains(&backbone.cyber), "gate {gate:?} of {:?} cannot reach backbone {:?}", b.id, backbone.cyber ); } } #[test] fn street_terminals_with_nodes_become_jacks() { let c = city(); let street = StratumKind::Street; let jacks: Vec<_> = c .connectors .iter() .filter(|k| k.kind == crate::world::ConnectorKind::Jack) .collect(); assert!(jacks.len() >= 3, "only {} jacks", jacks.len()); for j in &jacks { assert_eq!(j.serves, vec![street, StratumKind::Net]); let t = c.tile(street, j.pos).unwrap(); assert_eq!(t.feature, Some(crate::tile::Feature::Terminal)); assert_eq!(j.public, t.building.is_none()); assert!(c.walkable(StratumKind::Net, j.pos), "port {:?} not walkable", j.pos); assert!(j.pos.neighbors8().any(|n| c.walkable(street, n))); } for (p, t) in c.stratum(street).unwrap().tiles.iter() { if t.feature == Some(crate::tile::Feature::Terminal) && c.net.node_at(street, p).is_some() { assert!(jacks.iter().any(|j| j.pos == p), "terminal {p:?} has no jack"); } } assert!(jacks.iter().any(|j| !j.public), "fixture has no private port"); } #[test] fn every_jack_reaches_the_open_grid() { let c = city(); let net = c.stratum(StratumKind::Net).unwrap(); for j in c .connectors .iter() .filter(|k| k.kind == crate::world::ConnectorKind::Jack) { let reach = crate::pathing::flood_fill(c.size, j.pos, |p| { net.tiles.get(p).is_some_and(|t| t.is_walkable()) }); assert!( reach.iter().any(|p| net.tiles[*p].terrain == Terrain::NetGrid && net.tiles[*p].building.is_none()), "port {:?} is sealed in", j.pos ); } } #[test] fn gates_open_onto_walkable_tiles() { for seed in [6u64, 11, 29] { let c = Generator::run(GenParams { seed, size: SizePreset::Custom { w: 80, h: 40 }, districts: 4, ..Default::default() }) .unwrap(); let net = c.stratum(StratumKind::Net).unwrap(); for b in &c.buildings { let fp = b.footprint; let gate = fp .border() .find(|p| net.tiles[*p].terrain == Terrain::NetGate) .unwrap(); assert!( gate.neighbors4().any(|q| { !fp.contains(q) && net.tiles.get(q).is_some_and(|t| t.is_walkable()) }), "seed {seed}: gate {gate:?} of {:?} is sealed", b.id ); } } } ``` - [ ] **Step 2: Run them to verify they fail** Run: `cargo test -p citygen cyberspace::` Expected: `every_node_stands_on_its_feature...` FAILS (`feature None`), `lanes_link...` FAILS (`count > 20`), `street_terminals...` FAILS (`only 0 jacks`), `every_jack_reaches...` passes vacuously (no jacks yet; it bites once Ports exist), `gates_open_onto_walkable_tiles` PASSES already (gate choice was Task 3). - [ ] **Step 3: Implement nodes, lanes and ports** Extend `Work`: ```rust enum Work { Begin, Construct(usize), Gate(usize), Hub(usize), Nodes, Lane(usize), Ports, } ``` Add constants and imports: ```rust use crate::net::Node; use crate::pathing::astar; use crate::world::{Connector, ConnectorKind}; const NODE_SEARCH: i32 = 6; const LANE_LIGHT: u8 = 255; ``` Methods on `CyberspaceStage`: ```rust /// Nearest walkable Net tile without a feature, scanning square rings in a fixed order. fn nearest_free(ctx: &GenCtx, c: Pos) -> Option { for r in 1..=NODE_SEARCH { let ring = Rect::new(c.x - r, c.y - r, 2 * r + 1, 2 * r + 1); let hit = ring.border().find(|p| { ctx.city .tile(K, *p) .is_some_and(|t| t.is_walkable() && t.feature.is_none()) }); if hit.is_some() { return hit; } } None } /// ICE first so it always takes the gate; then everything else in id order. fn nodes(&mut self, ctx: &mut GenCtx) { let mut order: Vec = (0..ctx.city.net.nodes.len()).collect(); order.sort_by_key(|i| (ctx.city.net.nodes[*i].kind != NodeKind::Ice, *i)); for i in order { let node = ctx.city.net.nodes[i].clone(); let mut t = node.anchor.1; if node.kind == NodeKind::Ice && let Some(b) = node.building { t = self.gates[b.index()]; } let taken = ctx .city .tile(K, t) .is_none_or(|x| matches!(x.feature, Some(Feature::Node(_)))); if taken { t = Self::nearest_free(ctx, t).unwrap_or(t); } ctx.set_feature(K, t, Some(Feature::Node(node.kind))); ctx.city.net.nodes[i].cyber = t; } } fn lane_end(&self, n: &Node) -> Pos { match n.building { Some(b) => self.gates[b.index()], None => n.cyber, } } fn lane(&mut self, ctx: &mut GenCtx, li: usize) { let link = ctx.city.net.links[li]; let a = ctx.city.net.node(link.0).clone(); let b = ctx.city.net.node(link.1).clone(); if a.building.is_some() && a.building == b.building { return; } let (start, goal) = (self.lane_end(&a), self.lane_end(&b)); if start == goal { return; } let size = ctx.size(); let path = { let city = &ctx.city; astar(size, start, goal, |_, q| { let t = city.tile(K, q)?; if q == start || q == goal { return Some(1); } match t.terrain { Terrain::NetLane | Terrain::NetGate => Some(1), Terrain::NetGrid => Some(if matches!(t.feature, Some(Feature::Node(_))) { 1 } else { 3 }), _ => None, } }) }; let Some(path) = path else { return }; for p in path { if ctx.terrain(K, p) == Terrain::NetGrid { ctx.set_terrain(K, p, Terrain::NetLane); if let Some(st) = ctx.city.stratum_mut(K) { st.light.set(p, LANE_LIGHT); } } } } fn ports(ctx: &mut GenCtx) { let street = StratumKind::Street; let mut jacks = Vec::new(); if let Some(st) = ctx.city.stratum(street) { for (p, t) in st.tiles.iter() { if t.feature == Some(Feature::Terminal) && ctx.city.net.node_at(street, p).is_some() { jacks.push(Connector { kind: ConnectorKind::Jack, pos: p, serves: vec![street, K], public: t.building.is_none(), }); } } } ctx.city.connectors.retain(|c| c.kind != ConnectorKind::Jack); ctx.city.connectors.extend(jacks); } ``` `begin` queues, after the hubs: `self.queue.push_back(Work::Nodes); self.queue.extend((0..ctx.city.net.links.len()).map(Work::Lane)); self.queue.push_back(Work::Ports);`. `step` dispatches `Work::Nodes => self.nodes(ctx)`, `Work::Lane(i) => self.lane(ctx, i)`, `Work::Ports => Self::ports(ctx)`. - [ ] **Step 4: Run the tests** Run: `cargo test -p citygen cyberspace::` Expected: PASS all. If `lanes_link_every_construct_gate_to_its_backbone` fails because a backbone hub is sealed, the `keep_open` rule in `hub` is wrong; fix the rule, not the test. - [ ] **Step 5: Run the seed sweeps and timing** Run: `cargo test -p citygen --test seeds` then `cargo build --release -p cyberpunk && time ./target/release/cyberpunk gen --seed 1 --size large --out /tmp/l.city` Expected: sweeps PASS (determinism + `validate`); large generation well under 0.3 s. If over budget, restrict `astar` in `lane` to the bounding box of both districts grown by 8 (pass a smaller `size` by translating coordinates is not available; instead return `None` from the cost closure for tiles outside that box). - [ ] **Step 6: Commit** ```bash git add crates/citygen/src/generate/cyberspace.rs git commit -m "feat(citygen): cyberspace nodes, lanes and jack ports" ``` --- ### Task 5: Validation of cyberspace **Files:** - Modify: `crates/citygen/src/validate.rs` - Test: same file **Interfaces:** - Produces: `Violation::CyberAnchor(NodeId)`, `Violation::JackUnreachable { pos: Pos }`; `StratumUnreachable` may now name `Net`. - [ ] **Step 1: Write the failing tests** In the tests module of `validate.rs`: ```rust #[test] fn detects_moved_cyber_nodes_and_walled_in_jacks() { let mut c = Generator::run(GenParams { seed: 6, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }) .unwrap(); assert!(c.validate().is_ok()); let moved = c.net.nodes[0].cyber; c.net.nodes[0].cyber = Pos::new(moved.x, (moved.y + 1) % c.size.h as i32); let v = c.validate().unwrap_err(); assert!(v.iter().any(|v| matches!(v, Violation::CyberAnchor(NodeId(0)))), "{v:?}"); c.net.nodes[0].cyber = moved; let jack = c .connectors .iter() .find(|k| k.kind == ConnectorKind::Jack) .unwrap() .pos; for n in jack.neighbors8() { if let Some(t) = c.tile_mut(StratumKind::Street, n) { t.terrain = Terrain::Wall; } } let v = c.validate().unwrap_err(); assert!( v.iter().any(|v| matches!(v, Violation::JackUnreachable { pos } if *pos == jack)), "{v:?}" ); } #[test] fn jack_is_not_a_public_connector_for_districts() { let mut c = Generator::run(GenParams { seed: 6, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }) .unwrap(); // Strip every non-jack public connector from district 0: it must now be flagged. let d = DistrictId(0); let keep: Vec = c .connectors .iter() .map(|k| k.kind == ConnectorKind::Jack || !k.public || c_district(&c, k.pos) != Some(d)) .collect(); let mut i = 0; c.connectors.retain(|_| { let k = keep[i]; i += 1; k }); let v = c.validate().unwrap_err(); assert!(v.iter().any(|v| matches!(v, Violation::DistrictEmpty(x) if *x == d)), "{v:?}"); } fn c_district(c: &City, p: Pos) -> Option { c.tile(StratumKind::Street, p).and_then(|t| t.district) } #[test] fn net_reachability_is_checked_from_the_jacks() { let mut c = Generator::run(GenParams { seed: 6, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }) .unwrap(); // Wall off the whole grid except the ports: almost nothing is reachable. let jacks: Vec = c .connectors .iter() .filter(|k| k.kind == ConnectorKind::Jack) .map(|k| k.pos) .collect(); let size = c.size; let net = c.stratum_mut(StratumKind::Net).unwrap(); for y in 0..size.h as i32 { for x in 0..size.w as i32 { let p = Pos::new(x, y); if let Some(t) = net.tiles.get_mut(p) && t.terrain == Terrain::NetGrid && !jacks.contains(&p) { t.terrain = Terrain::NetWall; } } } let v = c.validate().unwrap_err(); assert!( v.iter().any(|v| matches!(v, Violation::StratumUnreachable { stratum: StratumKind::Net, .. })), "{v:?}" ); } ``` The test module's imports gain `Generator`, `NodeId`, `ConnectorKind` (already imported), `Violation`. - [ ] **Step 2: Run them to verify they fail** Run: `cargo test -p citygen validate::` Expected: compile error, `no variant CyberAnchor`. - [ ] **Step 3: Implement** `validate.rs`: enum gains ```rust CyberAnchor(NodeId), JackUnreachable { pos: Pos }, ``` Display arms: ```rust Violation::CyberAnchor(n) => write!(f, "net node {n:?} is not on its cyberspace tile"), Violation::JackUnreachable { pos } => { write!(f, "jack at {pos:?} has no walkable neighbour or a dead port") } ``` Connector loop becomes: ```rust for c in &self.connectors { if c.kind == ConnectorKind::Jack { let street_ok = self .tile(StratumKind::Street, c.pos) .is_some_and(|t| t.feature == Some(Feature::Terminal)) && c.pos.neighbors8().any(|n| self.walkable(StratumKind::Street, n)); let net_ok = self.walkable(StratumKind::Net, c.pos); if !street_ok || !net_ok { out.push(Violation::JackUnreachable { pos: c.pos }); } continue; } for k in &c.serves { /* unchanged */ } } ``` After the physical reachability loop: ```rust if let Some(net) = self.stratum(StratumKind::Net) { let jacks: Vec = self .connectors .iter() .filter(|c| c.kind == ConnectorKind::Jack) .map(|c| c.pos) .collect(); let walkable = net.tiles.count(|t| t.is_walkable()); if !jacks.is_empty() && walkable > 0 { let mut seen = Grid::filled(self.size, false); let mut queue: VecDeque = VecDeque::new(); for p in jacks { if net.tiles.get(p).is_some_and(|t| t.is_walkable()) && !seen[p] { seen.set(p, true); queue.push_back(p); } } while let Some(p) = queue.pop_front() { for n in p.neighbors4() { if net.tiles.get(n).is_some_and(|t| t.is_walkable()) && !seen[n] { seen.set(n, true); queue.push_back(n); } } } let reached = seen.count(|b| *b); if (reached as f32) < REACH_TARGET * walkable as f32 { out.push(Violation::StratumUnreachable { stratum: StratumKind::Net, reached, walkable, }); } } } ``` Cyber anchors, after the existing `NetAnchor` loop: ```rust for n in &self.net.nodes { let ok = self .tile(StratumKind::Net, n.cyber) .is_some_and(|t| t.feature == Some(Feature::Node(n.kind))); if !ok { out.push(Violation::CyberAnchor(n.id)); } } ``` Only when the Net stratum exists: wrap in `if self.stratum(StratumKind::Net).is_some()` so `bare_city` fixtures without a Net stay valid. District check: `c.public && c.kind != ConnectorKind::Jack && ...`. Imports: `use crate::geom::{Grid, Pos}; use crate::world::{City, ConnectorKind, StratumKind}; use std::collections::VecDeque;`. - [ ] **Step 4: Run the suite** Run: `cargo test --workspace` Expected: PASS, including the seed sweeps (which call `validate`). - [ ] **Step 5: Commit** ```bash git add crates/citygen/src/validate.rs git commit -m "feat(citygen): validate cyberspace anchors, jacks and reachability" ``` --- ### Task 6: Digital overlay in cyberspace **Files:** - Modify: `crates/cyberpunk/src/render/overlay.rs` - Test: same file **Interfaces:** - Consumes: `tileset::node_glyph`, `Net::node_in_cyber`. - Produces: `Digital::node_under(city, stratum, p)` returns the node by `cyber` when `stratum == Net`. - [ ] **Step 1: Write the failing test** Add to the overlay tests module: ```rust #[test] fn overlay_in_cyberspace_finds_nodes_by_cyber_tile_and_draws_links() { 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 server = c .net .nodes .iter() .find(|n| n.kind == NodeKind::Server && n.building.is_some()) .unwrap(); assert_eq!( Digital::node_under(&c, StratumKind::Net, server.cyber), Some(server.id) ); let backbone = c .net .links .iter() .find_map(|l| { let (a, b) = (c.net.node(l.0), c.net.node(l.1)); if a.id == server.id && b.kind == NodeKind::Backbone { Some(b) } else if b.id == server.id && a.kind == NodeKind::Backbone { Some(a) } else { None } }) .unwrap(); let mut t = Terminal::new(TestBackend::new(80, 40)).unwrap(); t.draw(|f| { MapView { city: &c, stratum: StratumKind::Net, origin: Pos::new(0, 0), tick: 0, style: &s, vis: None, overlays: &[&Digital { enabled: true, focus: Some(server.id), }], cursor: None, } .render(f.area(), f.buffer_mut()) }) .unwrap(); // The focused link is drawn as dots between the two cyber tiles. let dots = citygen::pathing::bresenham(server.cyber, backbone.cyber) .into_iter() .filter(|p| c.net.node_in_cyber(*p).is_none()) .filter(|p| t.backend().buffer()[(p.x as u16, p.y as u16)].symbol() == "·") .count(); assert!(dots >= 1, "no link dots drawn"); } ``` - [ ] **Step 2: Run it to verify it fails** Run: `cargo test -p cyberpunk overlay_in_cyberspace` Expected: FAIL on `node_under` (returns `None` for a cyber tile) or on `dots >= 1`. - [ ] **Step 3: Implement** `overlay.rs`: ```rust use crate::render::tileset::node_glyph; impl Digital { pub fn node_under(city: &City, stratum: StratumKind, p: Pos) -> Option { if stratum == StratumKind::Net { city.net.node_in_cyber(p).map(|n| n.id) } else { city.net.node_at(stratum, p).map(|n| n.id) } } } ``` Delete `glyph_index`; the node-glyph line becomes `let g = node_glyph(n.kind, style);`. In `draw`, right after the `enabled` check: ```rust let focus_subnet = self.focus.map(|id| city.net.node(id).subnet); if stratum == StratumKind::Net { // Nodes are tiles here; only the focused subnet's links are drawn. let Some(sn) = focus_subnet else { return }; for link in &city.net.links { let a = city.net.node(link.0); let b = city.net.node(link.1); if a.subnet != sn && b.subnet != sn { continue; } for p in bresenham(a.cyber, b.cyber) { if city.net.node_in_cyber(p).is_some() { continue; } if let Some((sx, sy)) = tile_to_screen(area, origin, p) { buf[(sx, sy)] .set_char('·') .set_fg(SIGNAL.scale(0.8).to_color()); } } } return; } ``` The physical-side code below stays as it is (minus the removed `glyph_index`). - [ ] **Step 4: Run the suite** Run: `cargo test --workspace` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add crates/cyberpunk/src/render/overlay.rs git commit -m "feat(render): digital overlay works inside cyberspace" ``` --- ### Task 7: The player jacks in and out **Files:** - Modify: `crates/cyberpunk/src/walker/mod.rs` - Test: same file **Interfaces:** - Produces: `Player.body: Option<(StratumKind, Pos)>`, `Player::jack_in(&mut self, city: &City) -> Result`, `Player::jack_out(&mut self, city: &City) -> Result<(), &'static str>`, `Player::jacked_in(&self) -> bool`, `RADIUS_NET: i32 = 24`. - [ ] **Step 1: Write the failing test** ```rust #[test] fn jacking_in_leaves_the_body_at_the_port() { let c = Generator::run(GenParams { seed: 6, size: SizePreset::Custom { w: 120, h: 60 }, districts: 5, ..Default::default() }) .unwrap(); let jack = c .connectors .iter() .find(|k| k.kind == citygen::ConnectorKind::Jack && k.public) .unwrap(); let stand = jack .pos .neighbors8() .find(|n| c.walkable(StratumKind::Street, *n)) .unwrap(); let mut p = Player::spawn(&c, 1); p.pos = stand; p.stratum = StratumKind::Street; assert!(!p.jacked_in()); assert_eq!(p.jack_in(&c), Ok(jack.pos)); assert!(p.jacked_in()); assert_eq!(p.stratum, StratumKind::Net); assert_eq!(p.pos, jack.pos); assert_eq!(p.body, Some((StratumKind::Street, stand))); assert_eq!(p.radius(), RADIUS_NET); assert!(p.vis().count(|v| *v == Visibility::Visible) > 10); assert_eq!(p.jack_in(&c), Err("already jacked in")); assert_eq!(p.use_connector(&c, false), None, "jacks are not stairs"); assert_eq!(p.use_connector(&c, true), None); assert_eq!(p.jack_out(&c), Ok(())); assert_eq!((p.stratum, p.pos, p.body), (StratumKind::Street, stand, None)); assert_eq!(p.jack_out(&c), Err("not jacked in")); // Far from any terminal. let far = c .stratum(StratumKind::Street) .unwrap() .tiles .iter() .find(|(q, t)| { t.is_walkable() && std::iter::once(*q) .chain(q.neighbors8()) .all(|n| c.connectors_at(n).all(|k| k.kind != citygen::ConnectorKind::Jack)) }) .map(|(q, _)| q) .unwrap(); p.pos = far; assert_eq!(p.jack_in(&c), Err("no terminal within reach")); } ``` - [ ] **Step 2: Run it to verify it fails** Run: `cargo test -p cyberpunk jacking_in_leaves` Expected: compile error, `no method named jack_in`. - [ ] **Step 3: Implement** ```rust pub const RADIUS_NET: i32 = 24; #[derive(Clone, Debug)] pub struct Player { pub pos: Pos, pub stratum: StratumKind, /// Where the body waits while jacked in: (stratum, tile). None when not jacked in. pub body: Option<(StratumKind, Pos)>, /// What has been seen on each stratum, indexed by `StratumKind::index`. memory: Vec>, } ``` `spawn`: `body: None,` and `memory: StratumKind::ALL.iter().map(|_| Grid::filled(city.size, Visibility::Unseen)).collect()`. `radius`: ```rust pub fn radius(&self) -> i32 { match self.stratum { StratumKind::Undercity => RADIUS_UNDER, StratumKind::Net => RADIUS_NET, _ => RADIUS_STREET, } } ``` `use_connector`: add `.filter(|c| c.kind != ConnectorKind::Jack)` before `filter_map`. Import `ConnectorKind`. New methods: ```rust pub fn jacked_in(&self) -> bool { self.body.is_some() } /// Jacks in at a Jack connector on this tile or an 8-neighbour of it, leaving the body here. pub fn jack_in(&mut self, city: &City) -> Result { if self.jacked_in() { return Err("already jacked in"); } let here = self.pos; let k = self.stratum; let port = std::iter::once(here) .chain(here.neighbors8()) .find_map(|p| { city.connectors_at(p) .find(|c| c.kind == ConnectorKind::Jack && c.serves.contains(&k)) .map(|c| c.pos) }) .ok_or("no terminal within reach")?; if !city.walkable(StratumKind::Net, port) { return Err("the port is dead"); } self.body = Some((k, here)); self.stratum = StratumKind::Net; self.pos = port; self.update_fov(city); Ok(port) } /// Returns to the body. pub fn jack_out(&mut self, city: &City) -> Result<(), &'static str> { let (k, p) = self.body.take().ok_or("not jacked in")?; self.stratum = k; self.pos = p; self.update_fov(city); Ok(()) } ``` - [ ] **Step 4: Run the suite** Run: `cargo test --workspace` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add crates/cyberpunk/src/walker/mod.rs git commit -m "feat(walker): jack in and out, body left at the port" ``` --- ### Task 8: Free Explore cursor with a following camera **Files:** - Modify: `crates/cyberpunk/src/screens/explore.rs` - Test: same file **Interfaces:** - Consumes: `Camera::follow(p, view_w, view_h)`, `Camera::clamp`, `Camera::origin`. - Produces: `MapScreen.cursor: Pos` (Explore-mode inspector position). - [ ] **Step 1: Write the failing test and fix the old one** Add: ```rust #[test] fn explore_cursor_reaches_the_map_corners_without_showing_void() { let mut st = state(); let mut s = MapScreen::explore(0); let mut t = Terminal::new(TestBackend::new(120, 40)).unwrap(); t.draw(|f| s.draw(&st, f)).unwrap(); s.update(&mut st, &[]); let map = s.last_map.get(); for _ in 0..40 { s.update(&mut st, &[key(KeyCode::Char('H')), key(KeyCode::Char('K'))]); } assert_eq!(s.cursor, Pos::new(0, 0)); for _ in 0..30 { s.update(&mut st, &[]); } let o = s.cam.origin(map.width, map.height); assert!(o.x >= 0 && o.y >= 0, "origin {o:?}"); for _ in 0..40 { s.update(&mut st, &[key(KeyCode::Char('L')), key(KeyCode::Char('J'))]); } assert_eq!(s.cursor, Pos::new(119, 59)); for _ in 0..30 { s.update(&mut st, &[]); } let o = s.cam.origin(map.width, map.height); assert!(o.x + map.width as i32 <= 120 && o.y + map.height as i32 <= 60, "origin {o:?}"); t.draw(|f| s.draw(&st, f)).unwrap(); let text = buffer_text(t.backend().buffer()); assert!(text.contains("119,59"), "{text}"); } ``` In `explore_pans_and_switches_to_other_screens`, replace the camera assertion: ```rust let before = s.cursor; s.update(&mut st, &[key(KeyCode::Right), key(KeyCode::Down)]); assert_eq!(s.cursor, Pos::new(before.x + 1, before.y + 1)); ``` - [ ] **Step 2: Run them to verify they fail** Run: `cargo test -p cyberpunk explore_cursor_reaches` Expected: compile error, `no field cursor`. - [ ] **Step 3: Implement** `MapScreen` gains `pub cursor: Pos` (init `Pos::new(0, 0)` in `explore`). In `update`, where `cam_ready` is set: ```rust if !self.cam_ready { self.cam.snap(size.center()); self.cursor = size.center(); self.cam_ready = true; } ``` `cursor()`: ```rust fn cursor(&self, st: &AppState) -> Pos { match (self.mode, &st.player) { (Mode::Walk, Some(p)) => p.pos, _ => self.cursor, } } ``` `handle_explore` pan arm: ```rust { let (dx, dy) = dir_for(code).expect("checked").delta(); let size = st.city.as_ref().map(|c| c.size)?; self.cursor = Pos::new( (self.cursor.x + dx * step).clamp(0, size.w as i32 - 1), (self.cursor.y + dy * step).clamp(0, size.h as i32 - 1), ); } ``` In `update`, the follow block becomes: ```rust let map = self.last_map.get(); match (self.mode, &st.player) { (Mode::Walk, Some(p)) => self.cam.follow(p.pos, map.width, map.height), _ => self.cam.follow(self.cursor, map.width, map.height), } ``` `handle_walk` `Tab`: `KeyCode::Tab => { self.cursor = player.pos; self.mode = Mode::Explore; }`. - [ ] **Step 4: Run the suite** Run: `cargo test --workspace` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add crates/cyberpunk/src/screens/explore.rs git commit -m "fix(explore): cursor moves freely and the camera follows it" ``` --- ### Task 9: Cyberspace on screen — fifth stratum, Enter to jack, avatar and body **Files:** - Modify: `crates/cyberpunk/src/screens/explore.rs` - Modify: `crates/cyberpunk/src/screens/help.rs:11-31` - Modify: `crates/cyberpunk/src/ui/panel.rs` (`inspector_lines`, `strata_selector`) - Test: `crates/cyberpunk/src/screens/explore.rs` **Interfaces:** - Consumes: `Player::{jack_in, jack_out, jacked_in, body}`, `Terrain::label`, `StratumKind::ALL`, `Digital::node_under`. - [ ] **Step 1: Write the failing test** ```rust #[test] fn enter_jacks_in_and_the_net_is_the_fifth_stratum() { let mut st = state(); let mut s = MapScreen::explore(0); let mut t = Terminal::new(TestBackend::new(120, 40)).unwrap(); t.draw(|f| s.draw(&st, f)).unwrap(); for _ in 0..4 { s.update(&mut st, &[key(KeyCode::Char(']'))]); } assert_eq!(s.stratum, StratumKind::Net); s.update(&mut st, &[key(KeyCode::Char(']'))]); assert_eq!(s.stratum, StratumKind::Net); t.draw(|f| s.draw(&st, f)).unwrap(); let text = buffer_text(t.backend().buffer()); assert!(text.contains("▌ Net"), "{text}"); assert!(text.contains('═') || text.contains('┃') || text.contains('━'), "{text}"); // Walk up to a public port and jack in. s.update(&mut st, &[key(KeyCode::Char('['))]); s.update(&mut st, &[key(KeyCode::Char('w'))]); let city = st.city.as_ref().unwrap(); let jack = city .connectors .iter() .find(|k| k.kind == citygen::ConnectorKind::Jack && k.public) .unwrap() .pos; let stand = jack .neighbors8() .find(|n| city.walkable(StratumKind::Street, *n)) .unwrap(); { let p = st.player.as_mut().unwrap(); p.pos = stand; p.stratum = StratumKind::Street; } s.update(&mut st, &[key(KeyCode::Enter)]); let p = st.player.as_ref().unwrap(); assert!(p.jacked_in()); assert_eq!(s.stratum, StratumKind::Net); assert_eq!(p.pos, jack); st.tick += 500; t.draw(|f| s.draw(&st, f)).unwrap(); let text = buffer_text(t.backend().buffer()); assert!(text.contains("jack out"), "{text}"); assert!(text.contains("jacked in"), "{text}"); // The body shows on the Street while the avatar is in the Net. s.mode = Mode::Explore; s.stratum = StratumKind::Street; s.cursor = stand; s.cam.snap(stand); t.draw(|f| s.draw(&st, f)).unwrap(); let map = s.last_map.get(); let origin = s.cam.origin(map.width, map.height); let (sx, sy) = tile_to_screen(map, origin, stand).unwrap(); assert_eq!(t.backend().buffer()[(sx, sy)].symbol(), "@"); s.mode = Mode::Walk; s.stratum = StratumKind::Net; s.update(&mut st, &[key(KeyCode::Enter)]); let p = st.player.as_ref().unwrap(); assert!(!p.jacked_in()); assert_eq!((p.stratum, p.pos), (StratumKind::Street, stand)); assert_eq!(s.stratum, StratumKind::Street); s.update(&mut st, &[key(KeyCode::Char('?'))]); t.draw(|f| s.draw(&st, f)).unwrap(); assert!(buffer_text(t.backend().buffer()).contains("jack in")); } ``` Note on the `"jacked in"` assertion: the status set by the key is transient; the test advances `tick` by 500 before drawing, so the assertion needs the badge in the hints line, not the status. The hints while jacked in must contain both `jack out` and `jacked in` (see Step 3). - [ ] **Step 2: Run it to verify it fails** Run: `cargo test -p cyberpunk enter_jacks_in` Expected: FAIL at `assert_eq!(s.stratum, StratumKind::Net)` (still clamped to Heights). - [ ] **Step 3: Implement** `explore.rs`: ```rust fn change_stratum(&mut self, st: &AppState, delta: i32) { let max = st.city.as_ref().map(|c| c.strata.len() as i32 - 1).unwrap_or(3); let i = (self.stratum.index() as i32 + delta).clamp(0, max); if let Some(k) = StratumKind::from_index(i as usize) { self.stratum = k; } } ``` (call sites pass `st`.) `handle_walk` gains, before the `code =>` arm: ```rust KeyCode::Enter => { let result = if player.jacked_in() { player.jack_out(city).map(|_| None) } else { player.jack_in(city).map(Some) }; match result { Ok(Some(port)) => { self.stratum = StratumKind::Net; self.cam.snap(port); let where_ = city .tile(StratumKind::Street, port) .and_then(|t| t.building) .and_then(|b| city.building_opt(b)) .and_then(|b| b.name.clone()) .or_else(|| { city.tile(StratumKind::Street, port) .and_then(|t| t.district) .and_then(|d| city.district_opt(d)) .map(|d| d.name.clone()) }) .unwrap_or_else(|| "the Net".to_string()); st.set_status(format!("jacked in at {where_}"), 90); } Ok(None) => { self.stratum = player.stratum; self.cam.snap(player.pos); st.set_status("jacked out", 60); } Err(e) => st.set_status(e, 60), } } ``` `st.set_status` needs the player borrow released first: bind `let player = st.player.as_mut()?;` is already there; restructure the arm to compute `result`, `port`, and the message while `player` is borrowed, then drop it before `st.set_status` (take `let msg: String = ...;` then `st.set_status(msg, 90)` after the match). The compiler will insist; follow it. Hints: ```rust let hints = match (self.mode, st.player.as_ref().is_some_and(|p| p.jacked_in())) { (Mode::Explore, _) => "hjkl pan · [] level · w walk · d net · e legend · s save · ? keys", (Mode::Walk, true) => "⚡ jacked in · hjkl move · enter jack out · tab camera · d net · ? keys", (Mode::Walk, false) => "hjkl move · <> stairs · enter jack in · tab camera · ? keys", }; ``` Avatar and body drawing replaces the `@` block: ```rust if let Some(p) = &st.player { if let Some((bk, bp)) = p.body && bk == self.stratum && let Some((sx, sy)) = tile_to_screen(map, origin, bp) { f.buffer_mut()[(sx, sy)].set_char('@').set_style( Style::default() .fg(Color::Rgb(110, 120, 130)) .add_modifier(Modifier::DIM), ); } if p.stratum == self.stratum && let Some((sx, sy)) = tile_to_screen(map, origin, p.pos) { let fg = if p.stratum == StratumKind::Net { Color::Rgb(0, 255, 200) } else { Color::Rgb(255, 255, 255) }; f.buffer_mut()[(sx, sy)] .set_char('@') .set_style(Style::default().fg(fg).add_modifier(Modifier::BOLD)); } } ``` Node focus for the inspector: `let focus = if self.digital || self.stratum == StratumKind::Net { Digital::node_under(city, self.stratum, cursor) } else { None };`. `help.rs`: EXPLORE row `("[ ]", "stratum down / up (Net on top)")`; WALK gains `("enter", "jack in at an adjacent terminal / jack out")` after the `< >` row. `panel.rs`: `strata_selector` iterates `StratumKind::ALL` (doc comment: "Every stratum, top first"); `inspector_lines` uses `t.terrain.label()` and ```rust match t.feature { Some(Feature::Node(k)) => out.push(kv("feature", format!("node · {}", k.label()))), Some(f) => out.push(kv("feature", format!("{f:?}").to_lowercase())), None => {} } ``` (import `citygen::Feature`). - [ ] **Step 4: Run the suite** Run: `cargo test --workspace` Expected: PASS. The existing `explore_and_walk_round_trip` still expects `[` from Street twice to land on Undercity and stay there. - [ ] **Step 5: Commit** ```bash git add crates/cyberpunk/src/screens/explore.rs crates/cyberpunk/src/screens/help.rs crates/cyberpunk/src/ui/panel.rs git commit -m "feat(screens): walk cyberspace, jack in on enter, body left behind" ``` --- ### Task 10: Save format version 2 **Files:** - Modify: `crates/cyberpunk/src/save.rs` - Test: same file **Interfaces:** - Produces: `FORMAT_VERSION = 2`; `split` returns `(Header, u16, &[u8])`; `load_city` refuses version 1 naming the seed. - [ ] **Step 1: Write the failing test** ```rust #[test] fn version_one_files_list_but_refuse_to_load() { let dir = std::env::temp_dir().join(format!("cyberpunk-v1-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let header = postcard::to_allocvec(&Header { name: "Old Town".into(), seed: 42, w: 80, h: 40, }) .unwrap(); let mut bytes = Vec::new(); bytes.extend_from_slice(MAGIC); bytes.extend_from_slice(&1u16.to_le_bytes()); bytes.extend_from_slice(&(header.len() as u32).to_le_bytes()); bytes.extend_from_slice(&header); bytes.extend_from_slice(&[0u8; 8]); let path = dir.join("old.city"); std::fs::write(&path, &bytes).unwrap(); let listed = list_cities(&dir).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].seed, 42); let err = load_city(&path).unwrap_err().to_string(); assert!(err.contains("older version") && err.contains("seed 42"), "{err}"); assert_eq!(FORMAT_VERSION, 2); std::fs::remove_dir_all(&dir).ok(); } ``` - [ ] **Step 2: Run it to verify it fails** Run: `cargo test -p cyberpunk version_one_files` Expected: FAIL: `listed.len()` is 0 (split rejects version 1) or `FORMAT_VERSION == 2` fails. - [ ] **Step 3: Implement** ```rust pub const FORMAT_VERSION: u16 = 2; /// Versions whose header this build can read (for listing); only FORMAT_VERSION loads. const READABLE_VERSIONS: [u16; 2] = [1, 2]; fn split<'a>(bytes: &'a [u8], path: &Path) -> Result<(Header, u16, &'a [u8])> { /* magic check unchanged */ let version = u16::from_le_bytes([bytes[4], bytes[5]]); if !READABLE_VERSIONS.contains(&version) { bail!( "{} is .city version {version}; this build reads version {FORMAT_VERSION}", path.display() ); } /* header parse unchanged */ Ok((header, version, &bytes[header_end..])) } pub fn load_city(path: &Path) -> Result { let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; let (header, version, body) = split(&bytes, path)?; if version != FORMAT_VERSION { bail!( "{} was saved by an older version ({version}); regenerate it from seed {}", path.display(), header.seed ); } /* rest unchanged */ } ``` `list_cities`: `let Ok((header, _, _)) = split(&bytes, &path) else { continue };`. - [ ] **Step 4: Run the suite** Run: `cargo test --workspace` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add crates/cyberpunk/src/save.rs git commit -m "feat(save): format version 2 with cyberspace; refuse v1 by seed" ``` --- ### Task 11: Docs, lint, sweep, smoke **Files:** - Modify: `README.md` - Modify: `docs/superpowers/specs/2026-10-12-cyberspace-design.md` (only if a ruling changed a stated behaviour) - [ ] **Step 1: README** Strata table gains a first row `| Net | cyberspace: a dotted grid, constructs on every building, hubs, lanes, ICE at the gates |`. After the Net graph paragraph add: ``` Jack in at any street terminal (walk up to it and press `enter`): your body stays at the port and you emerge in cyberspace at the same spot. `enter` again jacks out. In Explore mode `]` past Heights shows the Net. ``` Keys: **Explore** `[` `]` → "stratum down / up (Net on top)"; **Walk** adds `enter` jack in / jack out. Layout line for `crates/cyberpunk` mentions cyberspace. - [ ] **Step 2: Lint and full checks** Run: ```sh cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace cargo test -p citygen --test seeds -- --include-ignored ``` Expected: all green. Fix formatting or clippy findings in place. - [ ] **Step 3: Timing** Run: `cargo build --release -p cyberpunk && time ./target/release/cyberpunk gen --seed 1 --size large --out /tmp/large.city && time ./target/release/cyberpunk gen --seed 1 --size 1024x512 --out /tmp/huge.city` Expected: large well under 0.3 s; 1024x512 under 10 s. Record both in the final report. - [ ] **Step 4: Pty smoke** Drive the release binary through a pseudo-terminal (reuse the session's `drive2.py` approach): title → generate → explore `]` ×4 (Net visible, selector shows Net) → `[` → `w` → navigate to a port → `enter` → hints show "jack out" → `enter` → `q`. Expected: no panic, clean exit. Keep the script in the scratchpad, not the repo. - [ ] **Step 5: Commit** ```bash git add README.md git commit -m "docs: cyberspace keys and stratum" ```