docs: README, dead-code cleanup, smoke fixes

- README with run instructions and key reference
- lift the crate-level dead_code allowance; scope it to the anim library surface
- render paths tolerate a city that is still generating (district/building ids
  without entries), found by driving the app through a pty
- status messages show in the status bar, not only inside the inspector
- buffered JSON export (26 s -> 0.4 s for a large city)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 18:02:14 +02:00
parent 48320eb0e6
commit 3c75674489
15 changed files with 180 additions and 37 deletions

1
Cargo.lock generated
View File

@@ -142,6 +142,7 @@ name = "citygen"
version = "0.1.0"
dependencies = [
"noise",
"postcard",
"rand 0.9.5",
"rand_chacha",
"ron",

76
README.md Normal file
View File

@@ -0,0 +1,76 @@
# cyberpunk
A terminal game in a full cyberpunk setting. This is the first piece: a seeded,
deterministic **city generator** and the full-screen terminal app around it.
The generator builds a multi-level city as walkable tile maps on four strata:
| Stratum | What lives there |
|---|---|
| Heights | rooftops of the tall towers, helipads, bridges between them |
| Skyways | elevated walkways, mid-rise lobbies, platforms over intersections |
| Street | districts, arterials, blocks, buildings, markets, neon |
| Undercity | tunnels under the roads, sewers, a rail line, basements, black clinics |
Every building you see at street level is the same building you stand on at
Heights. Districts have themes (corp core, night market, megablocks, combat
zone, docks, industrial, neon strip, old town) that drive street layout,
building mix, palette and signage. A Net graph of subnets, nodes and links is
anchored to the physical tiles and can be shown as a digital overlay.
Same seed and parameters always produce a byte-identical city.
## Run
```sh
cargo run --release -p city
```
Headless generation, for scripts and the game later:
```sh
cargo run --release -p city -- gen --seed 42 --size medium --out cities/demo.city
cargo run --release -p city -- gen --seed 42 --size large --json --out /tmp/demo.json
```
Sizes: `small` (160x80), `medium` (240x120), `large` (320x160) or `WxH`.
Other flags: `--districts 4..12`, `--water none|bay|river`, `--density 0..1`.
Needs a truecolor terminal of at least 100x30 cells (iTerm2, Ghostty, kitty,
WezTerm). Saved cities go to `./cities/*.city`.
## Keys
**Title** `↑↓` select · `enter` confirm · `q` quit
**Configure** `↑↓` field · `←→` adjust · type digits into the seed · `enter`
on `RANDOM SEED` / `GENERATE` · `esc` back
**Generating** `space` pause · `+` `-` steps per frame · `enter` skip to the
end · `r` reseed after an error · `esc` back
**Explore** `hjkl` / arrows pan (shift: fast) · `[` `]` stratum down / up ·
`w` walk · `i` inspector · `m` minimap · `e` legend · `d` digital overlay ·
`s` save · `r` regenerate · `g` configure · `?` keys · `q` title
**Walk** `hjkl` `yubn` / arrows move · `<` `>` take stairs, lift, ladder or
manhole · `.` wait · `tab` back to the free camera · panels as above
## Layout
```
crates/citygen world model + generator (no terminal dependencies)
crates/city the ratatui app and the `gen` CLI
flavor/ names.ron (names, signage glyphs) · style.ron (palettes, glyph sets)
docs/superpowers/specs design spec
docs/superpowers/plans implementation plan
```
## Develop
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo test -p citygen --test seeds -- --ignored # 250-seed sweep
```

View File

@@ -1,4 +1,8 @@
//! Tick-driven animation primitives: easing, tweens, timelines.
//!
//! This is a small library surface for every screen, so parts of it (extra easings,
//! `Timeline`) are exercised by tests before any screen uses them.
#![allow(dead_code)]
pub mod effects;

View File

@@ -109,7 +109,9 @@ pub fn run() -> Result<()> {
if json {
let file = std::fs::File::create(&out)
.with_context(|| format!("creating {}", out.display()))?;
serde_json::to_writer_pretty(file, &city)?;
let mut writer = std::io::BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &city)?;
std::io::Write::flush(&mut writer)?;
} else {
save_city(&out, &city)?;
}

View File

@@ -1,8 +1,5 @@
//! `city`: generate, explore and walk a cyberpunk city in the terminal.
// Screens, widgets and overlays land task by task; lifted once the app is wired up.
#![allow(dead_code)]
mod anim;
mod app;
mod cli;

View File

@@ -56,7 +56,10 @@ impl Widget for Minimap<'_> {
water += 1;
}
if theme.is_none() {
theme = t.district.map(|d| self.city.district(d).theme);
theme = t
.district
.and_then(|d| self.city.district_opt(d))
.map(|d| d.theme);
}
}
let cell = &mut buf[(area.x + cx, area.y + cy)];

View File

@@ -9,7 +9,7 @@ pub mod traffic;
pub mod viewport;
pub mod weather;
use citygen::{BuildingKind, ConnectorKind, Pos, StratumKind, Theme};
use citygen::{BuildingKind, ConnectorKind, StratumKind, Theme};
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier, Style};
use serde::{Deserialize, Serialize};
@@ -95,7 +95,6 @@ pub struct RenderCtx<'a> {
pub stratum: StratumKind,
pub light: u8,
pub tick: u64,
pub pos: Pos,
pub vis: Visibility,
pub connector: Option<ConnDir>,
pub kind: Option<BuildingKind>,

View File

@@ -337,9 +337,7 @@ mod tests {
use crate::render::lighting::apply_vis;
use crate::render::style::StylePack;
use crate::render::{RenderCtx, Visibility};
use citygen::{
ALL_FEATURES, ALL_TERRAIN, Feature, Hue, Pos, StratumKind, Terrain, Theme, Tile,
};
use citygen::{ALL_FEATURES, ALL_TERRAIN, Feature, Hue, StratumKind, Terrain, Theme, Tile};
use unicode_width::UnicodeWidthChar;
#[test]
@@ -389,7 +387,6 @@ mod tests {
stratum: StratumKind::Street,
light,
tick,
pos: Pos::new(1, 1),
vis: Visibility::Visible,
connector: None,
kind: None,

View File

@@ -188,14 +188,19 @@ impl Widget for MapView<'_> {
.unwrap_or(Visibility::Visible);
let ctx = RenderCtx {
style: self.style,
theme: tile.district.map(|d| self.city.district(d).theme),
theme: tile
.district
.and_then(|d| self.city.district_opt(d))
.map(|d| d.theme),
stratum: self.stratum,
light: st.light.get(p).copied().unwrap_or(0),
tick: self.tick,
pos: p,
vis,
connector,
kind: tile.building.map(|b| self.city.building(b).kind),
kind: tile
.building
.and_then(|b| self.city.building_opt(b))
.map(|b| b.kind),
wall_mask: mask,
};
apply_vis(cell(tile, &ctx), vis).write(buf, sx, sy);
@@ -282,6 +287,46 @@ mod tests {
);
}
#[test]
fn mapview_renders_every_partial_generation_state() {
use citygen::{Generator, Progress};
let s = StylePack::load().unwrap();
let mut g = Generator::new(GenParams {
seed: 11,
size: SizePreset::Custom { w: 80, h: 40 },
districts: 4,
..Default::default()
})
.unwrap();
let mut t = Terminal::new(TestBackend::new(90, 45)).unwrap();
let mut steps = 0u32;
loop {
let progress = g.step();
steps += 1;
if steps.is_multiple_of(7) || matches!(progress, Progress::Done) {
for k in StratumKind::PHYSICAL {
t.draw(|f| {
MapView {
city: g.city(),
stratum: k,
origin: Pos::new(-2, -2),
tick: steps as u64,
style: &s,
vis: None,
overlays: &[&Weather],
cursor: None,
}
.render(f.area(), f.buffer_mut())
})
.unwrap();
}
}
if matches!(progress, Progress::Done) {
break;
}
}
}
#[test]
fn camera_follow_has_deadzone_and_eases() {
let mut cam = Camera::default();

View File

@@ -417,13 +417,6 @@ impl Screen for MapScreen {
let node = focus.map(|id| city.net.node(id));
lines.extend(inspector_lines(city, self.stratum, cursor, node));
}
if let Some(msg) = st.status_text() {
lines.push(Line::from(""));
lines.push(Line::styled(
format!(" {msg}"),
Style::default().fg(Color::Rgb(255, 200, 80)),
));
}
f.render_widget(
Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }),
inner,
@@ -435,7 +428,13 @@ impl Screen for MapScreen {
Mode::Walk => "hjkl move · <> stairs · tab camera · d net · ? keys",
};
f.render_widget(
Paragraph::new(status_bar(Some(city), self.stratum, self.fps.fps, hints)),
Paragraph::new(status_bar(
Some(city),
self.stratum,
self.fps.fps,
hints,
st.status_text(),
)),
status,
);

View File

@@ -64,6 +64,7 @@ impl Generating {
}
/// Something that changes whenever generation advances; used by tests.
#[cfg(test)]
pub fn progress_marker(&self) -> (usize, Option<(Stage, u32)>) {
(
self.done.len(),

View File

@@ -38,13 +38,6 @@ impl Menu {
pub fn entrance(&self, i: usize, tick: u64) -> f32 {
Tween::new(self.opened + i as u64 * 4, 8, Easing::OutCubic).progress(tick)
}
pub fn settled(&self, tick: u64) -> bool {
self.items
.iter()
.enumerate()
.all(|(i, _)| self.entrance(i, tick) >= 1.0)
}
}
pub struct MenuView<'a> {

View File

@@ -46,8 +46,7 @@ pub fn inspector_lines(
if let Some(f) = t.feature {
out.push(kv("feature", format!("{f:?}").to_lowercase()));
}
if let Some(d) = t.district {
let d = city.district(d);
if let Some(d) = t.district.and_then(|d| city.district_opt(d)) {
out.push(kv("district", d.name.clone()));
out.push(kv("theme", d.theme.label()));
out.push(kv(
@@ -55,8 +54,7 @@ pub fn inspector_lines(
format!("{:.0}% decay {:.0}%", d.wealth * 100.0, d.decay * 100.0),
));
}
if let Some(b) = t.building {
let b = city.building(b);
if let Some(b) = t.building.and_then(|b| city.building_opt(b)) {
out.push(kv(
"building",
b.name.clone().unwrap_or_else(|| b.kind.label().to_string()),
@@ -149,6 +147,7 @@ pub fn status_bar(
stratum: StratumKind,
fps: f32,
hints: &str,
status: Option<&str>,
) -> Line<'static> {
let mut spans = Vec::new();
if let Some(c) = city {
@@ -169,7 +168,15 @@ pub fn status_bar(
format!("· {fps:.0} fps "),
Style::default().fg(DIM),
));
spans.push(Span::styled(format!("· {hints}"), Style::default().fg(DIM)));
match status {
Some(msg) => spans.push(Span::styled(
format!("· {msg}"),
Style::default()
.fg(Color::Rgb(255, 200, 80))
.add_modifier(Modifier::BOLD),
)),
None => spans.push(Span::styled(format!("· {hints}"), Style::default().fg(DIM))),
}
Line::from(spans)
}
@@ -198,8 +205,16 @@ mod tests {
let sel = strata_selector(StratumKind::Skyways, 0);
let text: String = sel.iter().map(|l| l.to_string() + "\n").collect();
assert!(text.contains("Skyways") && text.contains("▌"));
let bar = status_bar(Some(&c), StratumKind::Street, 30.0, "q quit");
assert!(bar.to_string().contains(&c.name));
let bar = status_bar(Some(&c), StratumKind::Street, 30.0, "q quit", None);
assert!(bar.to_string().contains(&c.name) && bar.to_string().contains("q quit"));
let bar = status_bar(
Some(&c),
StratumKind::Street,
30.0,
"q quit",
Some("saved x"),
);
assert!(bar.to_string().contains("saved x") && !bar.to_string().contains("q quit"));
assert!(!legend(StratumKind::Undercity).is_empty());
}
}

View File

@@ -24,7 +24,8 @@ impl Player {
let is_core = |p: Pos| {
city.tile(street, p)
.and_then(|t| t.district)
.is_some_and(|d| city.district(d).theme == Theme::CorpCore)
.and_then(|d| city.district_opt(d))
.is_some_and(|d| d.theme == Theme::CorpCore)
};
let public: Vec<Pos> = city
.connectors

View File

@@ -366,13 +366,23 @@ impl City {
self.connectors.iter().filter(move |c| c.pos == p)
}
/// Panics if the id is unknown; use `district_opt` on a city that may still be generating.
pub fn district(&self, id: DistrictId) -> &District {
&self.districts[id.index()]
}
pub fn district_opt(&self, id: DistrictId) -> Option<&District> {
self.districts.get(id.index())
}
/// Panics if the id is unknown; use `building_opt` on a city that may still be generating.
pub fn building(&self, id: BuildingId) -> &Building {
&self.buildings[id.index()]
}
pub fn building_opt(&self, id: BuildingId) -> Option<&Building> {
self.buildings.get(id.index())
}
}
#[cfg(test)]