Files
cyberpunk/docs/superpowers/plans/2026-10-12-cyberspace.md
Jeffrey Wilcke 292e502807 docs: cyberspace implementation plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 21:31:05 +02:00

2169 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<BuildingId>,
}
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: <its anchor pos>`.
- [ ] **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<Pos>` (per building index) and `backbones: Vec<Option<Pos>>` (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<Work>,
total: usize,
/// Gate tile per building index.
pub(crate) gates: Vec<Pos>,
/// Backbone anchor per district index.
pub(crate) backbones: Vec<Option<Pos>>,
}
/// 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<BuildingId>, 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<Pos> {
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<Pos> = 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<StepOut, GenError> {
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<u32>) -> Option<Vec<Pos>>` (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<Pos> {
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<usize> = (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<bool> = 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<DistrictId> {
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<Pos> = 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<Pos> = 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<Pos> = 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<NodeId> {
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<Pos, &'static str>`, `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<Grid<Visibility>>,
}
```
`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<Pos, &'static str> {
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<City> {
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"
```