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

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