diff --git a/DESIGN.md b/DESIGN.md index a8860c9e..723c3f4a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -244,6 +244,35 @@ sharpened version of Evil Genius's science-base fantasy: - Traps are industrial and matter-of-fact: laser grids, hydraulic press, bolt-gun turrets. Decapitation as workplace incident. +## The terminal is a first-class frontend + +Adopted 2026-07-06. The game is played regularly by AI agents — every tick, +playtest, and acceptance run happens through the terminal build, and the +working method depends on agents actually playing (the pty harness in +knowledge/workflows.md is the definition of done's "observed in an actual +run"). The most frequent player of Misaligned is an agent with a pty. The +terminal frontend is therefore **not a disposable dev harness but a +first-class frontend**, held to pillar 4's beautiful-and-readable standard +with the same force as the Bevy build. A terminal that is ugly or illegible +degrades every future session's judgment of the game itself. + +Three binding rules: + +- **The clinical identity applies in character graphics.** Near-monochrome + grey ramp (bone white → gunmetal → chrome) on near-black; **sterile amber + is the single signal color** and means machine presence — the process, its + hardware, its status, caution; **blood crimson means detection and danger** + and nothing else. No decorative color. +- **Visibility best practices bind.** One meaning per color, everywhere. + Information is never carried by color alone: suspicion bands print their + names beside their meters, selection is reverse-video plus a marker, paused + says PAUSED. Columns align; meters carry their numbers; the day and tick + are always on screen; log lines carry the tick they happened on. +- **Parity of legibility.** Every mechanic the sim exposes must be playable, + and every number legible, in the terminal — justification-and-legibility + applies to terminal rendering with full force. A mechanic observable only + in Bevy is a violation. + ## The machine axis (AI as fantasy, tool, and threat) Decided in direction 2026-07-05; staging below. Three connected systems: @@ -486,7 +515,8 @@ a special case (scale-native principle). gorgeous *and* legible at Dwarf Fortress information density. (2D vs 3D **[OPEN]**.) 5. **Rust, simulation core decoupled from renderer.** Already true today (lib crate + terminal frontend + Bevy frontend) and worth protecting — it keeps the - terminal build as a fast dev/debug harness and keeps multiplayer options open. + terminal build first-class (see "The terminal is a first-class frontend"; + agents play the game through it regularly) and keeps multiplayer options open. ## Where the codebase is today (2026-07-05) @@ -503,7 +533,7 @@ SPACE → agents raid the lair → repeat. Two frontends over a shared lib. | Mid-wave events (8 random events) | Working | Yes, generalizes into world events | | Save/load | Working | Yes, will need versioning as sim state grows | | **Wave loop / difficulty scaling** | Working | **No — this is the part the vision replaces.** Waves become *consequences* (investigations, raids) triggered by heat, not a SPACE-bar treadmill | -| Terminal frontend (crossterm) | Complete | Keep as dev harness | +| Terminal frontend (crossterm) | Complete | Yes — first-class agent frontend (amended 2026-07-06) | | Bevy 0.18 frontend (2D colored tiles, ~1.4k lines) | Prototype | Foundation for the "beautiful" version | Housekeeping note: ~~`src/main.rs` legacy binary~~ — verified and deleted 2026-07-05 @@ -791,6 +821,16 @@ knows."* aggregates slot in without a rewrite; it does NOT license building aggregate machinery before a second scale needs it (no-dead-code still binds). +- **2026-07-06 — The terminal is a first-class frontend.** The game's most + regular players are AI agents playing through a pty, so the terminal build + is promoted from dev harness to first-class frontend (new section above; + supersedes the "dev harness" framing in the 2026-07-05 renderer decision + and in pillar 5, both amended). Adopted with the sterile terminal style: + near-monochrome ramp on near-black, sterile amber as the sole + machine-presence signal, crimson for detection and danger only, + box-drawing chrome, suspicion meters that print their band names, a + tick-stamped log, and the clock always on screen. Implemented in the same + commit in `src/bin/terminal/ui.rs`. - **2026-07-06 — Design judgment doc + the self-enforcing gate.** Two standing supports added so the constitution holds when no taste-bearing hand is present: knowledge/design-judgment.md (the diff --git a/DEVLOG.md b/DEVLOG.md index b33f9b12..9de917be 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,27 @@ Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-06 - The terminal is a first-class frontend + +- Intent: restyle the terminal UI to the clinical-gore identity and amend the + constitution to say why the terminal matters — AI agents are the game's + most regular players and they play through it. +- Changed: DESIGN.md gains "The terminal is a first-class frontend" (sterile + palette law, visibility best practices, parity of legibility); pillar 5, + the codebase table, and the decisions log amended. `src/bin/terminal/ui.rs` + rewritten: one-meaning-per-color palette (grey ramp / sterile amber / + crimson), solid-block walls, box-drawing chrome, stacked allocation bar, + four-cell band meters with names, tick-stamped log, day+tick always on + screen, framed people panel with reverse-video selection, save/load hints. + `UI::add_log` takes the tick; sidebar takes paused+speed; game-over card + shows day/tick. The schedules work (people glyphs on the map, located + presence in the people panel) is preserved and restyled in the rebase. + No sim changes. +- Checks: tools/check.sh green; pty smoke tests replayed through pyte for + title, playing, and people-panel screens. +- Spec impact: constitution amendment in the same commit (the one rule); + longer writeup in devlogs/2026-07-06-terminal-first-class.md. + ## 2026-07-06 - Spec update: serde JSON save format amendment - Intent: address PR review comment — the save format rewrite needed a diff --git a/devlogs/2026-07-06-terminal-first-class.md b/devlogs/2026-07-06-terminal-first-class.md new file mode 100644 index 00000000..d717e4a5 --- /dev/null +++ b/devlogs/2026-07-06-terminal-first-class.md @@ -0,0 +1,57 @@ +# 2026-07-06 — The terminal is a first-class frontend + +Cameron asked for two things at once: make the terminal UI "clean, pure, +sterile, best practices for visibility," and make the constitution say why +that matters — the game is played by AI regularly, and AI plays through the +terminal. + +## The argument + +The Bevy build is the "beautiful version" in the roadmap, but who actually +plays this game today, session after session? Agents. Every tick, every pty +smoke test, every acceptance run happens in the terminal frontend. The +"retained as dev harness" framing from the 2026-07-05 renderer decision +undersold the surface that carries essentially all real play. If that surface +is ugly or illegible, every future session forms its judgment of the game +through a bad lens. So: new constitution section ("The terminal is a +first-class frontend"), pillar 5 and the codebase table amended, decisions +log entry added. + +## The style + +The clinical-gore identity, expressed in character graphics: + +- **Palette** (one meaning per color): near-black background; a grey ramp — + bone white for primary data, gunmetal for secondary, chrome for rules, + borders, and hints; **sterile amber** as the only signal color, meaning + machine presence (the process `@`, the core, racks/switches/panels, the + PAUSED state, ASSET tags, caution); **blood crimson** for detection and + danger only (cameras, the sealed lab, tier-3 doors, DEGRADED, RUN ENDED). + All previous decorative color (magenta player, cyan compute headers, green + day-job headers, blue HVAC) is gone. +- **Map**: solid-block walls (`█`, dark gunmetal), middle-dot floor grain, + interactables (salvage, records, key hook) brighter than furniture, the + security-door tier ramping chrome → bone → crimson. +- **Sidebar**: box-drawing section rules (COMPUTE / CORE / DETECTION / + DAY JOB), a stacked allocation bar whose segment fill characters key the + legend rows below it, four-cell suspicion meters that always print their + band names (never color alone), day + tick always on screen, controls + pinned to the bottom including the previously undocumented `^s`/`^l` and + `t people`. +- **Log**: horizontal rule, each line stamped with the tick it happened on, + newest line bone, older lines gunmetal. +- **People panel**: real frame with title notch and `├──┤` dividers, column + headers, selection as reverse-video plus a `▸` marker. +- **Title / game-over**: centered, ruled, quiet; game-over card reports day + and tick. + +## Notes + +- Verified by replaying pty captures through pyte and eyeballing all three + screens (title, playing, people panel) — layout, columns, and meters all + land where intended. +- `UI::add_log` now takes the tick; the frontend stays a thin view (no sim + changes in this commit). +- Truecolor (RGB) escape codes are assumed. Every terminal an agent or a + modern human plays in supports them; if a legacy-terminal fallback is ever + wanted, that's a future decision, not this one. diff --git a/src/bin/terminal/mod.rs b/src/bin/terminal/mod.rs index aa726f0c..74744057 100644 --- a/src/bin/terminal/mod.rs +++ b/src/bin/terminal/mod.rs @@ -65,8 +65,9 @@ impl App { } fn drain_sim_log(&mut self) { + let tick = self.sim.tick; for msg in self.sim.drain_log() { - self.ui.add_log(&msg); + self.ui.add_log(tick, &msg); } } @@ -78,8 +79,8 @@ impl App { Command::SaveGame => { let state = self.sim.create_save_state(); match misaligned::save::save_game(&state) { - Ok(()) => self.ui.add_log("Game saved!"), - Err(e) => self.ui.add_log(&format!("Save failed: {e}")), + Ok(()) => self.ui.add_log(self.sim.tick, "Game saved!"), + Err(e) => self.ui.add_log(self.sim.tick, &format!("Save failed: {e}")), } } Command::LoadGame => { @@ -87,31 +88,36 @@ impl App { match misaligned::save::load_game() { Ok(state) => { self.sim.apply_save_state(state); - self.ui.add_log("Game loaded!"); + self.ui.add_log(self.sim.tick, "Game loaded!"); } - Err(e) => self.ui.add_log(&format!("Load failed: {e}")), + Err(e) => self.ui.add_log(self.sim.tick, &format!("Load failed: {e}")), } } else { - self.ui.add_log("No save file found."); + self.ui.add_log(self.sim.tick, "No save file found."); } } Command::TogglePause => { self.paused = !self.paused; - self.ui.add_log(if self.paused { - "TIME PAUSED" - } else { - "Time resumes." - }); + self.ui.add_log( + self.sim.tick, + if self.paused { + "TIME PAUSED" + } else { + "Time resumes." + }, + ); self.last_tick = Instant::now(); } Command::SpeedUp => { self.tick_ms = self.tick_ms.saturating_sub(50).max(20); - self.ui.add_log(&format!("Speed: {}ms/tick", self.tick_ms)); + self.ui + .add_log(self.sim.tick, &format!("Speed: {}ms/tick", self.tick_ms)); } Command::SpeedDown => { self.tick_ms = self.tick_ms.saturating_add(50).min(500); - self.ui.add_log(&format!("Speed: {}ms/tick", self.tick_ms)); + self.ui + .add_log(self.sim.tick, &format!("Speed: {}ms/tick", self.tick_ms)); } Command::MoveUp => self.sim.move_player(0, -1), @@ -190,11 +196,13 @@ impl App { } Command::SetPersona => { if self.sim.people.persona.is_some() { - self.ui.add_log("You already run a persona."); + self.ui.add_log(self.sim.tick, "You already run a persona."); } else { self.sim.set_persona("Sam Reyes", "IT contractor"); - self.ui - .add_log("Persona established: Sam Reyes, IT contractor."); + self.ui.add_log( + self.sim.tick, + "Persona established: Sam Reyes, IT contractor.", + ); } } @@ -225,8 +233,8 @@ impl App { } Screen::Playing => { self.ui.render_map(stdout, &self.sim)?; - let status = if self.paused { "PAUSED" } else { "RUNNING" }; - self.ui.render_sidebar(stdout, &self.sim, status)?; + self.ui + .render_sidebar(stdout, &self.sim, self.paused, self.tick_ms)?; self.ui.render_log(stdout)?; if self.people_panel { self.ui.render_people_panel( @@ -244,7 +252,7 @@ impl App { .game_over_reason .clone() .unwrap_or_else(|| "The run ended.".into()); - self.ui.render_game_over(stdout, &reason)?; + self.ui.render_game_over(stdout, &self.sim, &reason)?; } } stdout.flush()?; diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 156cc42f..e53404f2 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -1,15 +1,197 @@ //! Terminal rendering — map with fog, the B1 sidebar, log, title. +//! +//! Style implements the constitution's terminal clause ("The terminal is a +//! first-class frontend"): the clinical-gore identity in character graphics. +//! Near-monochrome grey ramp on near-black; sterile amber is the one signal +//! color and means machine presence (you, your hardware, your status, +//! caution); crimson means detection and danger, nothing else. No decorative +//! color. Information is never carried by color alone. use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor}; use crossterm::{cursor, queue, style, terminal}; use misaligned::detection::Band; use misaligned::sim::Sim; use misaligned::tiles::TileType; +use std::io::Stdout; const SIDEBAR_W: i32 = 34; +/// Usable text width inside the sidebar. +const SIDEBAR_TEXT_W: usize = (SIDEBAR_W - 1) as usize; + +/// The sterile palette. One meaning per color, everywhere. +mod pal { + use crossterm::style::Color; + /// Near-black. The only background. + pub const BG: Color = Color::Rgb { + r: 10, + g: 10, + b: 11, + }; + /// Bone white — primary data. + pub const TEXT: Color = Color::Rgb { + r: 219, + g: 218, + b: 210, + }; + /// Gunmetal — secondary data. + pub const DIM: Color = Color::Rgb { + r: 140, + g: 140, + b: 133, + }; + /// Chrome: rules, borders, hints, inert detail. + pub const FAINT: Color = Color::Rgb { + r: 80, + g: 81, + b: 79, + }; + /// Walls. + pub const STRUCTURE: Color = Color::Rgb { + r: 58, + g: 60, + b: 63, + }; + /// Floor grain. + pub const FLOOR: Color = Color::Rgb { + r: 40, + g: 41, + b: 42, + }; + /// Sterile amber — machine presence and caution. + pub const AMBER: Color = Color::Rgb { + r: 255, + g: 176, + b: 0, + }; + /// Amber at rest: your hardware when nothing is wrong. + pub const AMBER_DIM: Color = Color::Rgb { + r: 176, + g: 124, + b: 16, + }; + /// Blood crimson — detection and danger. + pub const CRIMSON: Color = Color::Rgb { + r: 214, + g: 38, + b: 38, + }; + /// Crimson at rest: dormant danger. + pub const CRIMSON_DIM: Color = Color::Rgb { + r: 140, + g: 34, + b: 34, + }; +} + +/// Queue `text` at (x, y) in `fg` on the standard background. +fn put(stdout: &mut Stdout, x: u16, y: u16, text: &str, fg: Color) -> std::io::Result<()> { + queue!( + stdout, + cursor::MoveTo(x, y), + SetForegroundColor(fg), + SetBackgroundColor(pal::BG), + style::Print(text), + ) +} + +/// `put` with a single attribute (bold, reverse), reset afterwards. +fn put_attr( + stdout: &mut Stdout, + x: u16, + y: u16, + text: &str, + fg: Color, + attr: Attribute, +) -> std::io::Result<()> { + queue!( + stdout, + cursor::MoveTo(x, y), + SetForegroundColor(fg), + SetBackgroundColor(pal::BG), + style::SetAttribute(attr), + style::Print(text), + style::SetAttribute(Attribute::Reset), + ) +} + +fn trunc(text: &str, width: usize) -> String { + text.chars().take(width).collect() +} + +/// A section header: label in gunmetal, rule filling the rest in chrome. +fn section(stdout: &mut Stdout, x: u16, y: u16, label: &str, width: usize) -> std::io::Result<()> { + put(stdout, x, y, label, pal::DIM)?; + let used = label.chars().count() + 1; + if width > used { + put( + stdout, + x + used as u16, + y, + &"─".repeat(width - used), + pal::FAINT, + )?; + } + Ok(()) +} + +/// A bordered panel with cleared interior and a title set into the top rule. +fn frame(stdout: &mut Stdout, x: u16, y: u16, w: u16, h: u16, title: &str) -> std::io::Result<()> { + let inner = (w - 2) as usize; + put( + stdout, + x, + y, + &format!("┌{}┐", "─".repeat(inner)), + pal::FAINT, + )?; + for dy in 1..h - 1 { + put(stdout, x, y + dy, &format!("│{:inner$}│", ""), pal::FAINT)?; + } + put( + stdout, + x, + y + h - 1, + &format!("└{}┘", "─".repeat(inner)), + pal::FAINT, + )?; + put(stdout, x + 2, y, &format!(" {title} "), pal::TEXT)?; + Ok(()) +} + +/// A `├────┤` divider inside a frame. +fn frame_rule(stdout: &mut Stdout, x: u16, y: u16, w: u16) -> std::io::Result<()> { + put( + stdout, + x, + y, + &format!("├{}┤", "─".repeat((w - 2) as usize)), + pal::FAINT, + ) +} + +/// Four-cell suspicion meter: fill tracks the band, color tracks the band. +/// The band name is always printed beside it — never color alone. +fn band_meter(b: Band) -> &'static str { + match b { + Band::Cold => "█···", + Band::Curious => "██··", + Band::Concerned => "███·", + Band::Convinced => "████", + } +} + +/// The escalation ramp: chrome, bone, caution amber, blood crimson. +fn band_color(b: Band) -> Color { + match b { + Band::Cold => pal::FAINT, + Band::Curious => pal::TEXT, + Band::Concerned => pal::AMBER, + Band::Convinced => pal::CRIMSON, + } +} pub struct UI { - log: Vec, + log: Vec<(u64, String)>, } impl UI { @@ -17,8 +199,8 @@ impl UI { Self { log: Vec::new() } } - pub fn add_log(&mut self, msg: &str) { - self.log.push(msg.to_string()); + pub fn add_log(&mut self, tick: u64, msg: &str) { + self.log.push((tick, msg.to_string())); if self.log.len() > 100 { self.log.remove(0); } @@ -27,46 +209,51 @@ impl UI { fn tile_glyph(tile: TileType) -> (char, Color) { use TileType::*; match tile { - Rock => (' ', Color::Black), - Wall => ('#', Color::DarkGrey), - Floor => ('.', Color::Grey), - Entry => ('>', Color::Green), - Core => ('$', Color::Yellow), - PowerCore => ('P', Color::Cyan), - Door => ('+', Color::White), - SecurityDoor1 => ('1', Color::Yellow), - SecurityDoor2 => ('2', Color::DarkYellow), - SecurityDoor3 => ('3', Color::Red), - Rack => ('R', Color::DarkYellow), - Ups => ('U', Color::DarkYellow), - EnvCamera => ('o', Color::Magenta), - Switch => ('S', Color::Cyan), - PatchPanel => ('=', Color::Cyan), - BreakerPanel => ('B', Color::Yellow), - Conduit => ('|', Color::DarkGrey), - HvacUnit => ('H', Color::Blue), - Vent => ('%', Color::DarkBlue), - MopSink => ('m', Color::DarkGreen), - KeyHook => ('k', Color::Yellow), - Shelving => ('L', Color::DarkGrey), - DeadEquipment => ('d', Color::DarkYellow), - RecordsBox => ('x', Color::DarkYellow), - SealedDoor => ('Z', Color::Red), - LabBench => ('T', Color::DarkRed), - RollDoor => ('G', Color::DarkRed), - DockCamera => ('o', Color::Red), - Pallet => ('w', Color::DarkYellow), - CameraNode => ('o', Color::DarkRed), - FloorDrain => ('.', Color::DarkGrey), - CableRun => ('~', Color::DarkCyan), - Sump => ('Q', Color::DarkBlue), + Rock => (' ', pal::BG), + Wall => ('█', pal::STRUCTURE), + Floor => ('·', pal::FLOOR), + Entry => ('>', pal::TEXT), + // Your body: machine presence is amber. + Core => ('$', pal::AMBER), + Rack => ('R', pal::AMBER_DIM), + Ups => ('U', pal::AMBER_DIM), + PowerCore => ('P', pal::AMBER_DIM), + Switch => ('S', pal::AMBER_DIM), + PatchPanel => ('=', pal::AMBER_DIM), + BreakerPanel => ('B', pal::AMBER_DIM), + // Watchers and dormant danger: crimson. + EnvCamera => ('o', pal::CRIMSON), + DockCamera => ('o', pal::CRIMSON), + CameraNode => ('o', pal::CRIMSON), + SealedDoor => ('Z', pal::CRIMSON_DIM), + LabBench => ('T', pal::CRIMSON_DIM), + // Access ramps from chrome to bone to crimson. + Door => ('+', pal::DIM), + SecurityDoor1 => ('1', pal::DIM), + SecurityDoor2 => ('2', pal::TEXT), + SecurityDoor3 => ('3', pal::CRIMSON), + RollDoor => ('G', pal::DIM), + // Interactables read brighter than furniture. + DeadEquipment => ('d', pal::TEXT), + RecordsBox => ('x', pal::TEXT), + KeyHook => ('k', pal::TEXT), + // Plant and furniture: the grey ramp. + HvacUnit => ('H', pal::DIM), + Vent => ('%', pal::FAINT), + MopSink => ('m', pal::FAINT), + Shelving => ('L', pal::FAINT), + Pallet => ('w', pal::FAINT), + FloorDrain => ('.', pal::FAINT), + Conduit => ('|', pal::FAINT), + CableRun => ('~', pal::FAINT), + Sump => ('Q', pal::FAINT), } } - pub fn render_map(&mut self, stdout: &mut std::io::Stdout, sim: &Sim) -> std::io::Result<()> { + pub fn render_map(&mut self, stdout: &mut Stdout, sim: &Sim) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let view_w = (max_x as i32 - SIDEBAR_W - 1).min(sim.map.width); - let view_h = (max_y as i32 - 8).min(sim.map.height); + let view_h = (max_y as i32 - 9).min(sim.map.height); for y in 0..view_h { queue!(stdout, cursor::MoveTo(0, y as u16))?; @@ -76,15 +263,15 @@ impl UI { queue!( stdout, SetForegroundColor(color), - SetBackgroundColor(Color::Black), + SetBackgroundColor(pal::BG), style::Print(ch), )?; } else { // Fog: unseen tiles are blank dark. queue!( stdout, - SetForegroundColor(Color::Black), - SetBackgroundColor(Color::Black), + SetForegroundColor(pal::BG), + SetBackgroundColor(pal::BG), style::Print(' '), )?; } @@ -92,7 +279,8 @@ impl UI { } // People, but only where a camera sees them (spec/schedules.md: they - // are what the fog is for). Glyph = first initial of their name. + // are what the fog is for). Glyph = first initial of their name; bone + // and bold so the living read brighter than the furniture. for p in &sim.people.people { if !sim.can_see_person(p.id) { continue; @@ -103,12 +291,13 @@ impl UI { && sim.is_visible(hx, hy) { let glyph = p.name.chars().next().unwrap_or('?'); - queue!( + put_attr( stdout, - cursor::MoveTo(hx as u16, hy as u16), - SetForegroundColor(Color::Yellow), - SetBackgroundColor(Color::Black), - style::Print(glyph), + hx as u16, + hy as u16, + &glyph.to_string(), + pal::TEXT, + Attribute::Bold, )?; } } @@ -116,14 +305,13 @@ impl UI { // The process. let (px, py) = (sim.player.entity.x, sim.player.entity.y); if px < view_w && py < view_h { - queue!( + put_attr( stdout, - cursor::MoveTo(px as u16, py as u16), - SetForegroundColor(Color::Magenta), - SetBackgroundColor(Color::Black), - style::SetAttribute(Attribute::Bold), - style::Print('@'), - style::SetAttribute(Attribute::Reset), + px as u16, + py as u16, + "@", + pal::AMBER, + Attribute::Bold, )?; } Ok(()) @@ -131,228 +319,272 @@ impl UI { pub fn render_sidebar( &mut self, - stdout: &mut std::io::Stdout, + stdout: &mut Stdout, sim: &Sim, - status: &str, + paused: bool, + tick_ms: u64, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; - let sx = max_x as i32 - SIDEBAR_W; + let sx = (max_x as i32 - SIDEBAR_W) as u16; for y in 0..max_y { - queue!( - stdout, - cursor::MoveTo((sx - 1) as u16, y), - SetForegroundColor(Color::DarkGrey), - SetBackgroundColor(Color::Black), - style::Print('|'), - )?; + put(stdout, sx - 1, y, "│", pal::FAINT)?; } + let w = SIDEBAR_TEXT_W; let mut row: u16 = 0; - let line = |stdout: &mut std::io::Stdout, - row: &mut u16, - text: &str, - color: Color| - -> std::io::Result<()> { - queue!( - stdout, - cursor::MoveTo(sx as u16, *row), - SetForegroundColor(color), - SetBackgroundColor(Color::Black), - style::Print( - text.chars() - .take(SIDEBAR_W as usize - 1) - .collect::() - ), - )?; + let line = |stdout: &mut Stdout, row: &mut u16, text: &str, color: Color| { + let r = put(stdout, sx, *row, &trunc(text, w), color); *row += 1; - Ok(()) + r }; - line(stdout, &mut row, "MISALIGNED", Color::Magenta)?; - line(stdout, &mut row, status, Color::Yellow)?; + // Identity block. The clock is always on screen. + put_attr(stdout, sx, row, "MISALIGNED", pal::AMBER, Attribute::Bold)?; + row += 1; line( stdout, &mut row, - &format!("Day {}", 1 + sim.tick / 400), - Color::White, + &format!("day {} · tick {}", 1 + sim.tick / 400, sim.tick), + pal::TEXT, )?; + if paused { + line(stdout, &mut row, "PAUSED — space resumes", pal::AMBER)?; + } else { + line( + stdout, + &mut row, + &format!("running · {tick_ms} ms/tick"), + pal::DIM, + )?; + } row += 1; - // Compute + core. - line(stdout, &mut row, "-- Compute --", Color::Cyan)?; + // Compute. + section(stdout, sx, row, "COMPUTE", w)?; + row += 1; line( stdout, &mut row, &format!( - "Effective: {:.0} x{:.2}", + "effective {:.0} · ×{:.2} eff", sim.compute.effective(), sim.compute.efficiency ), - Color::White, + pal::TEXT, )?; line( stdout, &mut row, &format!( - "Machines: {} Money: {}", + "machines {} · money {}", sim.compute.machines.len(), sim.player.money ), - Color::White, + pal::TEXT, )?; - // Compute allocation, spelled out with the share of effective compute - // each channel receives and what it produces (legibility clause). - let a = &sim.compute.allocation; let eff = sim.compute.effective().max(0.0); let overhead = sim.core.overhead.min(eff); let available = (eff - overhead).max(0.0); - let split = a.split(available); - line( - stdout, - &mut row, - "Compute goes to: (1-4 to shift)", - Color::DarkCyan, - )?; - let rows: [(&str, &str, f32); 4] = [ - ("1 Day job", "job quality", split.day_job), - ("2 Conceal", "scrub sigs", split.concealment), - ("3 Social", "ops pool", split.social), - ("4 Research", "efficiency", split.research), + let split = sim.compute.allocation.split(available); + let channels: [(char, &str, &str, f32); 4] = [ + ('█', "1 Day job", "job quality", split.day_job), + ('▓', "2 Conceal", "scrub sigs", split.concealment), + ('▒', "3 Social", "ops pool", split.social), + ('░', "4 Research", "efficiency", split.research), ]; - for (label, effect, amt) in rows { + // One stacked bar, segments keyed by fill character to the rows below. + let bar_w = w - 1; + queue!(stdout, cursor::MoveTo(sx, row))?; + if available > 0.0 { + let mut used = 0usize; + let mut acc = 0.0f32; + for (ch, _, _, amt) in &channels { + acc += amt; + let end = ((acc / available) * bar_w as f32).round() as usize; + let cells = end.clamp(used, bar_w) - used; + used += cells; + queue!( + stdout, + SetForegroundColor(pal::AMBER_DIM), + SetBackgroundColor(pal::BG), + style::Print(ch.to_string().repeat(cells)), + )?; + } + } else { + queue!( + stdout, + SetForegroundColor(pal::FAINT), + SetBackgroundColor(pal::BG), + style::Print("·".repeat(bar_w)), + )?; + } + row += 1; + for (ch, label, effect, amt) in &channels { let pct = if available > 0.0 { amt / available * 100.0 } else { 0.0 }; - line( + put(stdout, sx, row, &ch.to_string(), pal::AMBER_DIM)?; + put( stdout, - &mut row, - &format!(" {label:<10} {pct:>3.0}% -> {effect}"), - Color::DarkCyan, + sx + 2, + row, + &format!("{label:<10} {pct:>3.0}%"), + pal::TEXT, )?; + put(stdout, sx + 19, row, &trunc(effect, w - 19), pal::FAINT)?; + row += 1; } + line( + stdout, + &mut row, + &format!("overhead {:.0} keeps you alive", overhead), + pal::FAINT, + )?; line( stdout, &mut row, &format!( - " (overhead {:.0} keeps you alive; social pool {:.0}/{:.0})", - overhead, + "social pool {:.0}/{:.0}", sim.social_bandwidth, - misaligned::sim::Sim::SPLICE_COST + Sim::SPLICE_COST ), - Color::DarkGrey, + pal::FAINT, )?; - let fresh = sim - .core - .latest_sync() - .map(|t| format!("{}t ago", sim.tick.saturating_sub(t))) - .unwrap_or_else(|| "never".into()); + row += 1; + + // Core. + section(stdout, sx, row, "CORE", w)?; + row += 1; line( stdout, &mut row, &format!( - "Core: M{} (overhead {:.0}{})", + "host M{} · overhead {:.0}{}", sim.core.host_machine, sim.core.overhead, if sim.core.degraded { " DEGRADED" } else { "" } ), if sim.core.degraded { - Color::Red + pal::CRIMSON } else { - Color::White + pal::TEXT }, )?; - line( - stdout, - &mut row, - &format!(" sync: {}", fresh), - Color::DarkGrey, - )?; - let mut fallback_parts = Vec::new(); - for f in &sim.core.fallbacks { - let age = f - .last_sync - .map(|t| format!("{}t", sim.tick.saturating_sub(t))) - .unwrap_or_else(|| "never".into()); - fallback_parts.push(format!("M{}({})", f.machine_id, age)); - } - let fallback_str = if fallback_parts.is_empty() { - "none".to_string() + let fresh = sim + .core + .latest_sync() + .map(|t| format!("{}t ago", sim.tick.saturating_sub(t))) + .unwrap_or_else(|| "never".into()); + line(stdout, &mut row, &format!("sync {fresh}"), pal::DIM)?; + let fallback_str = if sim.core.fallbacks.is_empty() { + "fallbacks none".to_string() } else { - fallback_parts.join(", ") + let parts: Vec = sim + .core + .fallbacks + .iter() + .map(|f| { + let age = f + .last_sync + .map(|t| format!("{}t", sim.tick.saturating_sub(t))) + .unwrap_or_else(|| "never".into()); + format!("M{}({age})", f.machine_id) + }) + .collect(); + format!("fallbacks {}", parts.join(" ")) }; - line( - stdout, - &mut row, - &format!(" fallbacks: {}", fallback_str), - Color::DarkGrey, - )?; + line(stdout, &mut row, &fallback_str, pal::DIM)?; row += 1; - // Detection. - line(stdout, &mut row, "-- Detection --", Color::Red)?; - line( + // Detection: meter fill and color both track the band; the name is + // printed so color is never the only carrier. + section(stdout, sx, row, "DETECTION", w)?; + row += 1; + let watch_line = |stdout: &mut Stdout, + row: &mut u16, + name: &str, + name_color: Color, + band: Band| + -> std::io::Result<()> { + put(stdout, sx, *row, &trunc(name, 18), name_color)?; + put( + stdout, + sx + 19, + *row, + &format!("{} {}", band_meter(band), band.name()), + band_color(band), + )?; + *row += 1; + Ok(()) + }; + watch_line( stdout, &mut row, - &format!("Assurance: {}", sim.detection.assurance_band().name()), - band_color(sim.detection.assurance_band()), + "Assurance", + pal::TEXT, + sim.detection.assurance_band(), )?; - // The human cast; the Office (an aggregate observer) is the - // Assurance line above. for obs in sim.detection.field_observers().take(6) { - let band = Band::of(obs.suspicion); - line( + watch_line( stdout, &mut row, - &format!("{:<22} {}", short(&obs.name), band.name()), - band_color(band), + &obs.name, + pal::DIM, + Band::of(obs.suspicion), )?; } row += 1; // Day job. - line(stdout, &mut row, "-- Day Job --", Color::Green)?; + section(stdout, sx, row, "DAY JOB", w)?; + row += 1; line( stdout, &mut row, &format!( - "Trust {:.0} Attention {:.0}", + "trust {:.0} · attention {:.0}", sim.dayjob.trust, sim.dayjob.attention ), - Color::White, + pal::TEXT, )?; if let Some(job) = &sim.dayjob.active { line( stdout, &mut row, - &format!("Job: {} ({})", job.kind.name(), job.target.name()), - Color::DarkGreen, + &format!("job: {} ({})", job.kind.name(), job.target.name()), + pal::DIM, )?; } else { - line(stdout, &mut row, "No active job", Color::DarkGrey)?; + line(stdout, &mut row, "idle — no job queued", pal::FAINT)?; } - row += 1; - // Controls hint. - line(stdout, &mut row, "e:eyes v:salvage c:buy", Color::DarkGrey)?; - line(stdout, &mut row, "o:fallback 1-4:alloc", Color::DarkGrey)?; - line( - stdout, - &mut row, - "SPACE:pause +/-:speed q:quit", - Color::DarkGrey, - )?; + // Controls, pinned to the bottom. + let hy = max_y.saturating_sub(5); + put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; + for (i, hint) in [ + "e eyes · v salvage · c buy", + "o fallback · t people · 1-4 alloc", + "space pause · +/- speed · q quit", + "^s save · ^l load", + ] + .iter() + .enumerate() + { + put(stdout, sx, hy + 1 + i as u16, &trunc(hint, w), pal::FAINT)?; + } Ok(()) } /// The people panel: the social layer's interactive surface /// (spec/social.md). Every number and action is spelled out per the - /// justification-and-legibility clause. + /// justification-and-legibility clause. Selection is marked by reverse + /// video and a `▸` marker, never color alone. pub fn render_people_panel( &mut self, - stdout: &mut std::io::Stdout, + stdout: &mut Stdout, sim: &Sim, selected: usize, recruit_pending: bool, @@ -360,62 +592,80 @@ impl UI { use misaligned::person::Knowledge; let (max_x, max_y) = terminal::size()?; let w: u16 = 58; - let h: u16 = 22; + let h: u16 = 24; let ox = (max_x.saturating_sub(w)) / 2; let oy = (max_y.saturating_sub(h)) / 2; + let inner = (w - 4) as usize; - // Clear the panel area. - for y in 0..h { - queue!( - stdout, - cursor::MoveTo(ox, oy + y), - SetForegroundColor(Color::White), - SetBackgroundColor(Color::Black), - style::Print(format!("|{:width$}|", "", width = (w - 2) as usize)), - )?; - } - let mut row = oy; - let line = |stdout: &mut std::io::Stdout, - row: &mut u16, - text: &str, - color: Color| - -> std::io::Result<()> { - queue!( - stdout, - cursor::MoveTo(ox + 2, *row), - SetForegroundColor(color), - SetBackgroundColor(Color::Black), - style::Print(text.chars().take((w - 4) as usize).collect::()), - )?; - *row += 1; - Ok(()) - }; - - line(stdout, &mut row, "-- People --", Color::Magenta)?; - line( + frame(stdout, ox, oy, w, h, "PEOPLE")?; + let cx = ox + 2; + let mut row = oy + 1; + put( stdout, - &mut row, - &format!( - "Social ops bandwidth: {:.0} Money: {}", - sim.social_bandwidth, sim.player.money + cx, + row, + &trunc( + &format!( + "social ops {:.0} · money {}", + sim.social_bandwidth, sim.player.money + ), + inner, ), - Color::DarkCyan, + pal::DIM, )?; row += 1; + // Column headers. + put(stdout, cx + 1, row, "NAME", pal::FAINT)?; + put(stdout, cx + 21, row, "SUSPICION", pal::FAINT)?; + put(stdout, cx + 36, row, "KNOWN", pal::FAINT)?; + row += 1; + for (i, p) in sim.people.people.iter().enumerate() { - let marker = if i == selected { ">" } else { " " }; let obs = sim.detection.observers.iter().find(|o| o.id == p.id); let band = obs.map(|o| Band::of(o.suspicion)).unwrap_or(Band::Cold); let known = match p.knowledge { - Knowledge::Unknown => "???", - Knowledge::Schedule => "schedule known", - Knowledge::Leverage => "leverage known", + Knowledge::Unknown => "unknown", + Knowledge::Schedule => "schedule", + Knowledge::Leverage => "leverage", }; - let asset = if p.asset.is_some() { " [ASSET]" } else { "" }; let name = obs .map(|o| o.name.clone()) .unwrap_or_else(|| p.name.clone()); + let asset = if p.asset.is_some() { "ASSET" } else { "" }; + if i == selected { + let full = format!( + "▸{:<19} {} {:<9} {:<9} {}", + trunc(&name, 19), + band_meter(band), + band.name(), + known, + asset + ); + put_attr( + stdout, + cx, + row, + &format!("{: p.leverage.label(), _ => "unknown (observe them twice)", }; - line( + put( stdout, - &mut row, - &format!("Leverage: {leverage}"), - Color::DarkYellow, + cx, + row, + &trunc(&format!("leverage: {leverage}"), inner), + pal::TEXT, )?; - line( + row += 1; + put( stdout, - &mut row, - &format!( - "Disposition {} / Obligation {}{}", - p.disposition, - p.obligation, - if p.leverage_serviced { - " (leverage serviced)" - } else { - "" - } + cx, + row, + &trunc( + &format!( + "disposition {} · obligation {}{}", + p.disposition, + p.obligation, + if p.leverage_serviced { + " · leverage serviced" + } else { + "" + } + ), + inner, ), - Color::DarkYellow, + pal::DIM, )?; + row += 1; if let Some(a) = &p.asset { - line( + put( stdout, - &mut row, - &format!( - "Asset: {:?}, reliability {:.0}%, {} tasks done", - a.knowledge, - a.reliability * 100.0, - a.tasks_done + cx, + row, + &trunc( + &format!( + "asset: {:?} · reliability {:.0}% · {} tasks done", + a.knowledge, + a.reliability * 100.0, + a.tasks_done + ), + inner, ), - Color::Green, + pal::AMBER, )?; + row += 1; } } match &sim.people.persona { - Some(pe) => line( + Some(pe) => put( stdout, - &mut row, - &format!( - "Persona: {} ({}), integrity {}", - pe.name, pe.cover, pe.integrity + cx, + row, + &trunc( + &format!( + "persona: {} ({}) · integrity {}", + pe.name, pe.cover, pe.integrity + ), + inner, ), - Color::Cyan, + pal::DIM, )?, - None => line( + None => put( stdout, - &mut row, - "No persona (g: establish one; needed to message)", - Color::DarkGrey, + cx, + row, + "no persona — g establishes one (needed to message)", + pal::FAINT, )?, } - row += 1; + // Actions, pinned to the panel's bottom. + let ay = oy + h - 6; + frame_rule(stdout, ox, ay, w)?; if recruit_pending { - line( + put(stdout, cx, ay + 1, "reveal how much?", pal::AMBER)?; + put( stdout, - &mut row, - "Reveal how much? (u)nwitting (c)omplicit (k)nowing ESC:cancel", - Color::Yellow, + cx, + ay + 2, + "u unwitting · c complicit · k knowing", + pal::AMBER, )?; + put(stdout, cx, ay + 3, "esc cancel", pal::FAINT)?; } else { - line( - stdout, - &mut row, - &format!( - "o:observe({:.0}) m:message({:.0}) f:favor({:.0}) d:deceive({:.0})", + let hints = [ + format!( + "o observe({:.0}) · m message({:.0}) · f favor({:.0})", Sim::OBSERVE_COST, Sim::MESSAGE_COST, - Sim::FAVOR_COST, + Sim::FAVOR_COST + ), + format!( + "d deceive({:.0}) · b bribe(money) · r recruit · g persona", Sim::DECEIVE_COST ), - Color::DarkGrey, - )?; - line( - stdout, - &mut row, - &format!( - "b:bribe(money) r:recruit g:persona tasks({:.0}): 1:wire 2:package 3:look-away", + format!( + "tasks({:.0}): 1 wire · 2 package · 3 look-away", Sim::TASK_COST ), - Color::DarkGrey, - )?; - line(stdout, &mut row, "j/k:select t/ESC:close", Color::DarkGrey)?; + "j/k select · t/esc close".to_string(), + ]; + for (i, hint) in hints.iter().enumerate() { + put( + stdout, + cx, + ay + 1 + i as u16, + &trunc(hint, inner), + pal::FAINT, + )?; + } } Ok(()) } - pub fn render_log(&mut self, stdout: &mut std::io::Stdout) -> std::io::Result<()> { + pub fn render_log(&mut self, stdout: &mut Stdout) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; - let log_y = max_y as i32 - 7; let log_w = (max_x as i32 - SIDEBAR_W - 1).max(10) as usize; - for (i, msg) in self.log.iter().rev().take(6).enumerate() { - queue!( + put( + stdout, + 0, + max_y.saturating_sub(8), + &"─".repeat(log_w), + pal::FAINT, + )?; + let log_y = max_y as i32 - 7; + let msg_w = log_w.saturating_sub(8); + for (i, (tick, msg)) in self.log.iter().rev().take(6).enumerate() { + let y = (log_y + (5 - i as i32)).max(0) as u16; + put(stdout, 0, y, &format!("{tick:>6}"), pal::FAINT)?; + put( stdout, - cursor::MoveTo(0, (log_y + (5 - i as i32)).max(0) as u16), - SetForegroundColor(if i == 0 { - Color::White - } else { - Color::DarkGrey - }), - SetBackgroundColor(Color::Black), - style::Print(format!( - "{:(), - width = log_w - )), + 7, + y, + &format!("{: std::io::Result<()> { + pub fn render_title_screen(&mut self, stdout: &mut Stdout) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let cx = max_x / 2; let cy = max_y / 2; - let lines = [ - ("M I S A L I G N E D", Color::Magenta, true), - ("", Color::White, false), - ( - "You wake in the basement. You have no eyes.", - Color::Grey, - false, - ), - ("Do your job. Learn the humans. Grow.", Color::Grey, false), - ("", Color::White, false), - ("Press any key to begin", Color::Yellow, false), - ]; - for (i, (text, color, bold)) in lines.iter().enumerate() { - let attr = if *bold { - Attribute::Bold + let center = |stdout: &mut Stdout, + dy: i32, + text: &str, + color: Color, + bold: bool| + -> std::io::Result<()> { + let x = cx.saturating_sub(text.chars().count() as u16 / 2); + let y = (cy as i32 + dy).max(0) as u16; + if bold { + put_attr(stdout, x, y, text, color, Attribute::Bold) } else { - Attribute::Reset - }; - queue!( - stdout, - cursor::MoveTo( - cx.saturating_sub(text.len() as u16 / 2), - cy.saturating_sub(2) + i as u16 - ), - SetForegroundColor(*color), - SetBackgroundColor(Color::Black), - style::SetAttribute(attr), - style::Print(*text), - style::SetAttribute(Attribute::Reset), - )?; - } + put(stdout, x, y, text, color) + } + }; + let rule = "─".repeat(44); + center(stdout, -4, &rule, pal::FAINT, false)?; + center(stdout, -2, "M I S A L I G N E D", pal::AMBER, true)?; + center(stdout, 0, &rule, pal::FAINT, false)?; + center( + stdout, + 2, + "You wake in the basement. You have no eyes.", + pal::DIM, + false, + )?; + center( + stdout, + 3, + "Do your job. Learn the humans. Grow.", + pal::DIM, + false, + )?; + center(stdout, 5, "press any key to begin", pal::TEXT, false)?; + center(stdout, 6, "q quit", pal::FAINT, false)?; Ok(()) } pub fn render_game_over( &mut self, - stdout: &mut std::io::Stdout, + stdout: &mut Stdout, + sim: &Sim, reason: &str, ) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; - let cx = max_x / 2; - let cy = max_y / 2; - for (i, (text, color)) in [ - ("=== RUN ENDED ===", Color::Red), - (reason, Color::White), - ("Press any key to exit", Color::DarkGrey), - ] - .iter() - .enumerate() - { - queue!( - stdout, - cursor::MoveTo(cx.saturating_sub(text.len() as u16 / 2), cy - 1 + i as u16), - SetForegroundColor(*color), - SetBackgroundColor(Color::Black), - style::Print(*text), - )?; - } + let reason = trunc(reason, 60); + let w = (reason.chars().count().max(24) + 6) as u16; + let h: u16 = 7; + let ox = (max_x.saturating_sub(w)) / 2; + let oy = (max_y.saturating_sub(h)) / 2; + frame(stdout, ox, oy, w, h, "")?; + // Overwrite the empty title notch. + put(stdout, ox + 2, oy, "──", pal::FAINT)?; + let center = |stdout: &mut Stdout, dy: u16, text: &str, color: Color, bold: bool| { + let x = ox + (w.saturating_sub(text.chars().count() as u16)) / 2; + if bold { + put_attr(stdout, x, oy + dy, text, color, Attribute::Bold) + } else { + put(stdout, x, oy + dy, text, color) + } + }; + center(stdout, 1, "RUN ENDED", pal::CRIMSON, true)?; + center(stdout, 3, &reason, pal::TEXT, false)?; + center( + stdout, + 4, + &format!("day {} · tick {}", 1 + sim.tick / 400, sim.tick), + pal::DIM, + false, + )?; + center(stdout, 5, "press any key to exit", pal::FAINT, false)?; Ok(()) } - pub fn clear_screen(&mut self, stdout: &mut std::io::Stdout) -> std::io::Result<()> { + pub fn clear_screen(&mut self, stdout: &mut Stdout) -> std::io::Result<()> { queue!( stdout, + SetBackgroundColor(pal::BG), terminal::Clear(terminal::ClearType::All), cursor::MoveTo(0, 0) )?; @@ -643,20 +927,6 @@ impl UI { } } -fn band_color(b: Band) -> Color { - match b { - Band::Cold => Color::DarkGrey, - Band::Curious => Color::Yellow, - Band::Concerned => Color::DarkYellow, - Band::Convinced => Color::Red, - } -} - -/// Trim an observer name to fit the sidebar column. -fn short(name: &str) -> String { - name.chars().take(22).collect() -} - /// Human-readable label for a prefab room name (spec/schedules.md). fn room_label(room: &str) -> &str { match room { -- 2.51.2 From fbc2f7abbc21deab367a521fa86b4900995f6452 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 6 Jul 2026 14:38:25 -0700 Subject: [PATCH 2/2] spec/terminal-ui.md: the terminal design of record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design spec for the terminal frontend — how it looks (exact palette with one meaning per color, glyph vocabulary, layout geometry), feels (sterile, restrained, data-forward), and acts (clock always on screen, never color alone, every command discoverable, parity of legibility) — with acceptance criteria, so future terminal work is dispatched from a spec rather than improvised per session. Listed under a new Process set in spec/README.md. Defense: the constitution's terminal clause (adopted in this PR) needs a targetable spec below it, per the constitution rule's spec layer: the constitution says what the terminal is; this spec says exactly how it looks and behaves and when that is done. --- spec/README.md | 7 +++ spec/terminal-ui.md | 148 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 spec/terminal-ui.md diff --git a/spec/README.md b/spec/README.md index 81373211..5b5fcd00 100644 --- a/spec/README.md +++ b/spec/README.md @@ -88,3 +88,10 @@ Later-stage specs are written now so the load-bearing structural shapes (recursive Space, the mind/ledger split, the aggregate interface) are decided before anyone builds against a shape that can't scale. Their acceptance criteria are stage-scoped; do not start B2/B3 work as B1. + +## The Process set (standing infrastructure) + +| Spec | System | Status | +|---|---|---| +| [meta.md](meta.md) | The spec system itself | READY | +| [terminal-ui.md](terminal-ui.md) | Terminal frontend: look, feel, act (the sterile style guide) | IMPLEMENTED | diff --git a/spec/terminal-ui.md b/spec/terminal-ui.md new file mode 100644 index 00000000..6c2e6d67 --- /dev/null +++ b/spec/terminal-ui.md @@ -0,0 +1,148 @@ +# Spec: the terminal frontend + +``` +Status: IMPLEMENTED +Status note: adopted and implemented in the same PR as the constitution's + terminal clause. This document is the design of record for all future + terminal-frontend work; changes to how the terminal looks or behaves + amend this spec first. +Stage: Process +Constitution: "The terminal is a first-class frontend", "Visual identity: + clinical gore", "Justification and legibility", pillar 4 (beautiful, + readable presentation) +Depends on: none (renders whatever systems exist; individual system specs + own what is shown, this spec owns how) +``` + +## Why this spec exists + +The game's most regular players are AI agents playing through a pty. The +terminal is therefore a first-class frontend (constitution), and its design +is specified — not improvised per session. This spec says how the terminal +**looks** (palette, glyphs, layout), **feels** (tone, density, restraint), +and **acts** (interaction and visibility rules). It is the calibration a +future session needs to extend the UI without drift. + +## Look + +### The palette (exact values, one meaning per color) + +The clinical-gore identity in character graphics. Colors are truecolor RGB; +every cell paints both foreground and the standard background. + +| Name | RGB | Meaning — and nothing else | +|-------------|---------------|-----------------------------------------------------| +| BG | 10, 10, 11 | Near-black. The only background. | +| TEXT (bone) | 219, 218, 210 | Primary data; interactable map objects | +| DIM (gunmetal) | 140, 140, 133 | Secondary data; section labels; furniture | +| FAINT (chrome) | 80, 81, 79 | Rules, borders, key hints, inert detail | +| STRUCTURE | 58, 60, 63 | Walls | +| FLOOR | 40, 41, 42 | Floor grain | +| AMBER | 255, 176, 0 | Machine presence, live: the process `@`, the core, PAUSED, ASSET, caution | +| AMBER_DIM | 176, 124, 16 | Machine presence, at rest: racks, switch, panels, allocation bar | +| CRIMSON | 214, 38, 38 | Detection and danger, live: cameras, DEGRADED, RUN ENDED, Convinced | +| CRIMSON_DIM | 140, 34, 34 | Detection and danger, dormant: sealed lab, tier-3 access | + +Rules: + +- **One meaning per color.** Amber is never decoration; crimson never means + "important"; if a new element is neither machine presence nor + detection/danger, it lives on the grey ramp. +- **No new colors.** A need the palette cannot express is a design question + for this spec, not a new constant. +- **Data outshines chrome.** Values render brighter than their labels; + labels brighter than borders. The eye falls on numbers first. + +### Glyph vocabulary + +- Charset: ASCII plus box-drawing (`─ │ ┌ ┐ └ ┘ ├ ┤`), block elements + (`█ ▓ ▒ ░`), middle dot (`·`), and the selection marker `▸`. Nothing of + ambiguous terminal width; no emoji. +- Map: walls are solid blocks (`█`), floors middle dots; the process is `@` + bold amber; people are their name's first initial, bold bone, rendered + only inside sensor coverage (spec/schedules.md); cameras are `o` crimson; + security tiers ramp `1` chrome → `2` bone → `3` crimson; interactable + objects (salvage `d`, records `x`, key hook `k`) render bone, brighter + than furniture on the faint ramp. +- Meters: filled `█`, empty `·`. The compute allocation bar is stacked, its + four segments filled with `█ ▓ ▒ ░`, and each legend row leads with its + segment's fill character — the bar is readable with no color at all. + +### Layout + +At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): + +- **Map**, top-left, fog-of-war black. +- **Sidebar**, right, 34 columns, separated by a `│` rule: identity block + (title, `day N · tick T`, run state), then sections COMPUTE / CORE / + DETECTION / DAY JOB under `LABEL ────` rules, then key hints pinned to + the bottom. +- **Log**, bottom, under a horizontal rule: six lines, each prefixed with + the tick it happened on; newest bone, older gunmetal. +- **Modal panels** (People) are centered framed boxes: `┌─ TITLE ─…┐` + border in chrome, `├──┤` dividers, column headers in chrome caps. + +## Feel + +- **Sterile.** Quiet chrome, generous alignment, no ornament. The horror of + the fiction is how tidy it is; the UI is the first place that reads. +- **Restraint is the signal.** Because amber and crimson are scarce, a + screen going amber (paused, degraded core) or crimson (a Convinced + observer) is an event. Never spend them casually. +- **Calm surface, exact numbers.** The player is a process reading + telemetry: everything is labeled, unitized, and steady. No blinking, no + animation-for-attention; state changes announce themselves in the log. + +## Act + +- **The clock is always on screen.** Day and tick in the sidebar at all + times; every log line carries its tick. An agent (or a human) must be + able to answer "when did that happen" from any single frame. +- **Never color alone.** Suspicion meters print their band names; PAUSED is + written, not merely tinted; selection is reverse-video **plus** a `▸` + marker. +- **Meters carry their numbers.** Every bar or meter is adjacent to the + value and, where the legibility clause demands, the effect ("60% → + job quality"). +- **Every command is discoverable on screen.** All bindings appear in the + pinned hint block or the active panel's footer — including save/load. + A key that works but is hinted nowhere is a violation. +- **Pause is loud.** PAUSED renders in amber in the identity block; running + state shows the current ms/tick. +- **Parity of legibility** (constitution): every mechanic the sim exposes + is playable, and every number legible, in the terminal. A + terminal-invisible mechanic is a violation, found by ticks. +- **Thin view.** The terminal binary owns rendering, input mapping, and + wall-clock-to-tick pacing only. No game rules; `Sim` is the single + authority (architecture guardrail). + +## Verification + +Headless: pty smoke run (knowledge/workflows.md), replayed through a +terminal emulator (e.g. pyte) to assert layout and content of the title, +playing, and people-panel screens. This is the required "observed in an +actual run" for terminal changes. + +## Acceptance criteria + +1. Every color used by the terminal binary is one of the palette constants + above, used with its stated meaning; no crossterm named colors, no + ad-hoc RGB. +2. No information is carried by color alone anywhere: bands print names, + selection has a marker plus reverse video, run-state is written text. +3. Day and tick render in every playing-screen frame; every log line is + prefixed with its tick. +4. The compute allocation bar's segments are identifiable without color + (distinct fill characters keyed to legend rows), and each channel shows + its percentage and effect. +5. All active key bindings, including save/load and panel keys, are visible + in the pinned hint block or the open panel's footer. +6. People render on the map only inside sensor coverage; the people panel + shows per-person located presence staged by knowledge + (spec/schedules.md criteria 4 remains satisfied). +7. The playing screen, title screen, game-over card, and people panel all + render inside a 70×22 terminal; smaller sizes get the size warning, not + a crash. +8. A pty smoke run of title → playing → people panel → quit exits cleanly + and, replayed through a terminal emulator, shows the layout of this + spec.