From 292e50280723855ff946bc9523c003cf240ef6c3 Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 21:31:05 +0200 Subject: [PATCH] docs: cyberspace implementation plan Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-10-12-cyberspace.md | 2168 +++++++++++++++++ .../specs/2026-10-12-cyberspace-design.md | 41 +- 2 files changed, 2194 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-12-cyberspace.md diff --git a/docs/superpowers/plans/2026-10-12-cyberspace.md b/docs/superpowers/plans/2026-10-12-cyberspace.md new file mode 100644 index 0000000..5102d2a --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-cyberspace.md @@ -0,0 +1,2168 @@ +# 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" +``` diff --git a/docs/superpowers/specs/2026-10-12-cyberspace-design.md b/docs/superpowers/specs/2026-10-12-cyberspace-design.md index 2e5e0a4..5ab2384 100644 --- a/docs/superpowers/specs/2026-10-12-cyberspace-design.md +++ b/docs/superpowers/specs/2026-10-12-cyberspace-design.md @@ -56,6 +56,10 @@ dumped. The Undercity, Skyways and Heights terminals do not get ports yet. on its physical stratum. Cost if wrong: one glyph constant. - **Version 1 saves are rejected** with a message that names the seed to regenerate from. Cost if wrong: the user regenerates a city in under a second. +- **Jack key is Enter**, not `j`: `j` is "move south" in Walk mode. Cost if + wrong: one key binding. +- **ICE is placed before other nodes** so it always takes the gate tile. Cost if + wrong: none visible; a door lock sits one tile over instead. ## 3. World model changes (`citygen`) @@ -134,22 +138,28 @@ tile is the terminal itself (not walkable); it is used from an adjacent tile. `Stage::Cyberspace` runs after `Net`. Label "Compiling cyberspace"; short label "Cyber". `Stage::ALL` has 12 entries. The stage is resumable like the others; -its work queue is `Begin`, one `Construct` per building, one `Hub` per district, -`Nodes`, one `Lane` per inter-construct link, `Ports`. +its work queue is `Begin`, one `Construct` per building, one `Gate` per +building, one `Hub` per district, `Nodes`, one `Lane` per inter-construct link, +`Ports`. 1. **Begin.** Push `Stratum::new(Net, size)`. Every tile: `NetGrid`, `district` from the Street tile, `light` 200, `variant` with bit 0 set on the lattice positions `x % 4 == 0 && y % 2 == 0` and the other bits from `tile_hash`. 2. **Construct** (per building `b`). Footprint border → `NetWall`, inset → `NetFloor` (a footprint too small to inset is all wall), `building = b.id`, light from - the building subnet's security. The **gate** is the border tile that is not a - corner and is nearest to the district backbone's anchor; it becomes `NetGate` - with light 255. The stage remembers `gate[b]`. + the building subnet's security. +2b. **Gate** (per building, after every construct stands). Among border tiles + that are not corners and whose outward neighbour is still open `NetGrid`, the + one nearest to the district backbone's anchor becomes `NetGate` (light 255); + if none is open, the nearest non-corner border tile. The stage remembers + `gate[b]`. 3. **Hub** (per district). Around the backbone anchor `c`, the border of `Rect(c.x-2, c.y-2, 5, 5)` becomes `NetWall` (light 255) where it is still - `NetGrid`, and its four edge midpoints become `NetGate`. If `c` lies inside a + `NetGrid`, never on a Street terminal tile and never 4-adjacent to a construct + gate, and its four edge midpoints become `NetGate`. If `c` lies inside a building footprint, no hub: the construct is the hub. -4. **Nodes** (in id order). Target `t = anchor.1`, except ICE: `t = gate[building]`. +4. **Nodes** (ICE first, then the rest in id order). Target `t = anchor.1`, + except ICE: `t = gate[building]`. If the Net tile at `t` already holds a `Feature::Node`, `t` becomes the nearest walkable Net tile without a node within Chebyshev distance 6, scanned in a fixed order. Then `node.cyber = t` and the tile's feature is `Node(kind)`. A @@ -193,7 +203,7 @@ impl Player { /// or already jacked in. pub fn jack_in(&mut self, city: &City) -> Result; /// Returns to the body. Err if not jacked in. - pub fn jack_out(&mut self) -> Result<(), &'static str>; + pub fn jack_out(&mut self, city: &City) -> Result<(), &'static str>; pub fn jacked_in(&self) -> bool; } ``` @@ -205,11 +215,12 @@ blocks sight. Memory has five grids indexed by `StratumKind::index()`. ### Keys -- **Walk** `j`: jack in at an adjacent terminal, or jack out. Status: "jacked in - at ", "jacked out", "no terminal within reach". - Hints line while jacked in: `hjkl move · j jack out · tab camera · d net · ? keys`. +- **Walk** `enter`: jack in at an adjacent terminal, or jack out. Status: "jacked + in at ", "jacked out", "no terminal within reach". + Hints line while jacked in: `⚡ jacked in · hjkl move · enter jack out · tab + camera · d net · ? keys`. - **Explore** `[` `]` now range over all five strata; `]` from Heights shows the Net. -- Help overlay (Walk) gains the `j` row. README documents the key and the stratum. +- Help overlay (Walk) gains the `enter` row. README documents the key and the stratum. ### Explore cursor (the bounded fix) @@ -267,7 +278,7 @@ show version 1 cities; `load_city` rejects version 1 with footprint border is `NetWall` or `NetGate` with exactly one gate on the border and its inset is `NetFloor`; grid tiles carry the Street district. - Every node's `cyber` tile holds `Feature::Node(kind)`; every ICE node stands on - its building's gate. + or beside its building's gate. - Every Street terminal tile with a node has a Jack serving `[Street, Net]`; every Jack has a walkable Street neighbour and a walkable Net tile. - At least one `NetLane` tile exists and the gate of every building reaches its @@ -283,8 +294,8 @@ show version 1 cities; `load_city` rejects version 1 with `jack_out` restores stratum and position; `use_connector` on a Jack tile is `None`. - Explore `]` four times reaches the Net and the panel shows "Net"; the rendered Net stratum contains a lane or wall glyph. -- Walk `j` beside a terminal jacks in (screen-level test), the hints mention - "jack out", and `j` again jacks out. +- Walk `enter` beside a terminal jacks in (screen-level test), the hints mention + "jack out", and `enter` again jacks out. - The Explore cursor reaches `(0, 0)` and `(w-1, h-1)` while the camera origin never goes negative or past the map. - Save round trip at version 2; a version 1 file is listed but refused with a