diff --git a/README.md b/README.md index 93b2d8d8..8c83592d 100644 --- a/README.md +++ b/README.md @@ -95,13 +95,16 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | `WASD` / `hjkl` / arrows | Move | | `SPACE` / `p` | Pause / resume time | | `+` / `-` | Simulation speed | -| `b` | Build mode | +| `e` / `v` / `c` / `o` | Splice eyes / salvage / buy rack / fallback | +| `1`-`4` | Shift compute allocation (day job / conceal / social / research) | +| `t` | People panel | | `[` / `]` | Zoom (Bevy) | | `Ctrl+S` / `Ctrl+L` | Save / load | | `q` | Quit | -In build mode: `n`/`p` cycle the item, `SPACE` places, `x` demolishes, -`ESC` exits. +In the people panel: `j`/`k` select, `o` observe, `m` message, `f` favor, +`b` bribe, `d` deceive, `r` recruit (then `u`/`c`/`k` reveal), `g` persona, +`1`/`2`/`3` asset tasks, `t`/`ESC` close. Both frontends share this key map. For agent play, pipe newline-delimited commands into `--agent`; every command returns a plain-text frame and terminates with `-- ok tick: day:` or diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 618e2f1b..f27fd696 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -1,8 +1,10 @@ //! Misaligned — Bevy frontend //! -//! A thin view/input layer over `misaligned::sim::Sim`, mirroring the terminal -//! frontend: all game rules live in the sim; this binary owns wall-clock-to- -//! tick mapping, rendering (map with fog + the B1 sidebar), and input. +//! A thin view/input layer over `misaligned::sim::Sim`, at feature parity +//! with the terminal frontend: all game rules live in the sim; this binary +//! owns wall-clock-to-tick mapping, rendering (map with fog, people markers, +//! the B1 sidebar, the people panel), and input. Text is ASCII-only so the +//! default embedded font renders every glyph. use std::collections::HashMap; @@ -10,12 +12,19 @@ use bevy::prelude::*; use bevy::window::WindowResolution; use misaligned::detection::Band; use misaligned::machine::Channel; +use misaligned::person::{AssetKnowledge, AssetTask}; use misaligned::sim::Sim; use misaligned::tiles::TileType; const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 340.0; +/// Sterile amber — machine presence and caution (the terminal palette's +/// AMBER, in linear-ish sRGB floats). +const AMBER: Color = Color::srgb(1.0, 0.69, 0.0); +/// Bone white — primary data. +const BONE: Color = Color::srgb(0.86, 0.85, 0.82); + // ─── Art ───────────────────────────────────────────────────────────────────── #[derive(Resource)] @@ -90,7 +99,13 @@ struct Game { paused: bool, tick_ms: u64, tick_timer: Timer, - log: Vec, + /// Log entries with the tick they happened on (the clock is always on + /// screen; every line carries its tick — terminal parity). + log: Vec<(u64, String)>, + /// People panel state (selection + pending recruit reveal choice). + people_panel: bool, + people_selected: usize, + recruit_pending: bool, } impl Game { @@ -102,18 +117,36 @@ impl Game { tick_ms: 150, tick_timer: Timer::from_seconds(0.15, TimerMode::Repeating), log: Vec::new(), + people_panel: false, + people_selected: 0, + recruit_pending: false, } } - fn drain(&mut self) { - self.log.extend(self.sim.drain_log()); + fn add_log(&mut self, tick: u64, msg: &str) { + self.log.push((tick, msg.to_string())); let n = self.log.len(); - if n > 10 { - self.log.drain(..n - 10); + if n > 100 { + self.log.drain(..n - 100); + } + } + fn drain(&mut self) { + for (tick, msg) in self.sim.drain_log_entries() { + self.add_log(tick, &msg); } } fn set_speed(&mut self, ms: u64) { self.tick_ms = ms; self.tick_timer = Timer::from_seconds(ms as f32 / 1000.0, TimerMode::Repeating); + let (tick, ms) = (self.sim.tick, self.tick_ms); + self.add_log(tick, &format!("Speed: {ms}ms/tick")); + } + fn selected_person_id(&self) -> u8 { + self.sim + .people + .people + .get(self.people_selected) + .map(|p| p.id) + .unwrap_or(0) } } @@ -129,9 +162,17 @@ struct TilePos { #[derive(Component)] struct ProcessMarker; #[derive(Component)] +struct PersonMarker { + id: u8, +} +#[derive(Component)] struct UiText; #[derive(Component)] struct OverlayText; +#[derive(Component)] +struct PeoplePanel; +#[derive(Component)] +struct PeoplePanelText; fn main() { App::new() @@ -158,7 +199,9 @@ fn main() { update_camera, render_map, render_process, + render_people, render_ui, + render_people_panel, ) .chain(), ) @@ -212,6 +255,23 @@ fn setup(mut commands: Commands, assets: Res, mut game: ResMut { - if kb.just_pressed(KeyCode::Space) || kb.just_pressed(KeyCode::Enter) { - game.screen = Screen::Playing; - } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); + } else if kb.get_just_pressed().next().is_some() { + game.screen = Screen::Playing; } return; } Screen::GameOver => { - if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyQ) { + if kb.get_just_pressed().next().is_some() { exit.write(AppExit::Success); } return; @@ -269,12 +328,60 @@ fn handle_input( Screen::Playing => {} } + // Save/load first, like the terminal's modifier branch: a held modifier + // routes only to save/load. + let ctrl = kb.pressed(KeyCode::ControlLeft) + || kb.pressed(KeyCode::ControlRight) + || kb.pressed(KeyCode::SuperLeft) + || kb.pressed(KeyCode::SuperRight); + if ctrl { + if kb.just_pressed(KeyCode::KeyS) { + let st = game.sim.create_save_state(); + let tick = game.sim.tick; + match misaligned::save::save_game(&st) { + Ok(()) => game.add_log(tick, "Game saved!"), + Err(e) => game.add_log(tick, &format!("Save failed: {e}")), + } + } + if kb.just_pressed(KeyCode::KeyL) { + let tick = game.sim.tick; + if misaligned::save::save_exists() { + match misaligned::save::load_game() { + Ok(st) => { + game.sim.apply_save_state(st); + game.add_log(tick, "Game loaded!"); + } + Err(e) => game.add_log(tick, &format!("Load failed: {e}")), + } + } else { + game.add_log(tick, "No save file found."); + } + } + game.drain(); + return; + } + + if game.people_panel { + people_panel_input(&kb, &mut game, &mut exit); + game.drain(); + return; + } + let (dx, dy) = movement(&kb); if dx != 0 || dy != 0 { game.sim.move_player(dx, dy); } if kb.just_pressed(KeyCode::Space) || kb.just_pressed(KeyCode::KeyP) { game.paused = !game.paused; + let (tick, paused) = (game.sim.tick, game.paused); + game.add_log( + tick, + if paused { + "TIME PAUSED" + } else { + "Time resumes." + }, + ); } if kb.just_pressed(KeyCode::Equal) || kb.just_pressed(KeyCode::NumpadAdd) { let ms = game.tick_ms.saturating_sub(50).max(20); @@ -296,6 +403,11 @@ fn handle_input( if kb.just_pressed(KeyCode::KeyO) { game.sim.add_fallback_here(); } + if kb.just_pressed(KeyCode::KeyT) { + game.people_panel = true; + game.people_selected = 0; + game.recruit_pending = false; + } if kb.just_pressed(KeyCode::Digit1) { game.sim.adjust_allocation(Channel::DayJob, 1); } @@ -308,24 +420,97 @@ fn handle_input( if kb.just_pressed(KeyCode::Digit4) { game.sim.adjust_allocation(Channel::Research, 1); } - let ctrl = kb.pressed(KeyCode::ControlLeft) || kb.pressed(KeyCode::SuperLeft); - if ctrl && kb.just_pressed(KeyCode::KeyS) { - let st = game.sim.create_save_state(); - let m = match misaligned::save::save_game(&st) { - Ok(()) => "Game saved!".to_string(), - Err(e) => format!("Save failed: {e}"), + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); + } + game.drain(); +} + +/// People-panel input: mirrors the terminal's panel key map exactly +/// (src/bin/terminal/input.rs) — selection, social verbs, recruit reveal, +/// asset tasks, persona. +fn people_panel_input( + kb: &ButtonInput, + game: &mut Game, + exit: &mut MessageWriter, +) { + if game.recruit_pending { + let reveal = if kb.just_pressed(KeyCode::KeyU) { + Some(AssetKnowledge::Unwitting) + } else if kb.just_pressed(KeyCode::KeyC) { + Some(AssetKnowledge::Complicit) + } else if kb.just_pressed(KeyCode::KeyK) { + Some(AssetKnowledge::Knowing) + } else { + None }; - game.log.push(m); + if let Some(reveal) = reveal { + game.recruit_pending = false; + let id = game.selected_person_id(); + game.sim.recruit(id, reveal); + } else if kb.just_pressed(KeyCode::Escape) { + game.recruit_pending = false; + } + return; + } + + if kb.just_pressed(KeyCode::ArrowUp) + || kb.just_pressed(KeyCode::KeyK) + || kb.just_pressed(KeyCode::KeyW) + { + game.people_selected = game.people_selected.saturating_sub(1); } - if ctrl - && kb.just_pressed(KeyCode::KeyL) - && misaligned::save::save_exists() - && let Ok(st) = misaligned::save::load_game() + if kb.just_pressed(KeyCode::ArrowDown) + || kb.just_pressed(KeyCode::KeyJ) + || kb.just_pressed(KeyCode::KeyS) { - game.sim.apply_save_state(st); - game.log.push("Game loaded!".to_string()); + let max = game.sim.people.people.len().saturating_sub(1); + game.people_selected = (game.people_selected + 1).min(max); + } + let id = game.selected_person_id(); + if kb.just_pressed(KeyCode::KeyO) { + game.sim.observe(id); + } + if kb.just_pressed(KeyCode::KeyM) { + game.sim.message(id); + } + if kb.just_pressed(KeyCode::KeyF) { + game.sim.favor(id); + } + if kb.just_pressed(KeyCode::KeyB) { + game.sim.bribe(id); + } + if kb.just_pressed(KeyCode::KeyD) { + game.sim.deceive(id); + } + if kb.just_pressed(KeyCode::KeyR) { + game.recruit_pending = true; + } + if kb.just_pressed(KeyCode::KeyG) { + let tick = game.sim.tick; + if game.sim.people.persona.is_some() { + game.add_log(tick, "You already run a persona."); + } else { + game.sim.set_persona("Sam Reyes", "IT contractor"); + game.add_log(tick, "Persona established: Sam Reyes, IT contractor."); + } + } + if kb.just_pressed(KeyCode::Digit1) { + game.sim.asset_task(id, AssetTask::PlugInDevice); + } + if kb.just_pressed(KeyCode::Digit2) { + game.sim.asset_task(id, AssetTask::MovePackage); + } + if kb.just_pressed(KeyCode::Digit3) { + game.sim.asset_task(id, AssetTask::LookAway); + } + if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyT) { + game.people_panel = false; + game.recruit_pending = false; + } + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); } - game.drain(); } fn update_camera( @@ -406,6 +591,30 @@ fn render_process(game: Res, mut q: Query<&mut Transform, With, mut q: Query<(&PersonMarker, &mut Transform, &mut Visibility)>) { + if !game.is_changed() { + return; + } + for (marker, mut tf, mut vis) in q.iter_mut() { + let pos = if game.sim.can_see_person(marker.id) { + game.sim + .person_pos(marker.id) + .filter(|&(x, y)| game.sim.is_visible(x, y)) + } else { + None + }; + match pos { + Some((x, y)) => { + tf.translation = grid_to_world(x, y, 3.0); + *vis = Visibility::Visible; + } + None => *vis = Visibility::Hidden, + } + } +} + fn setup_ui(mut commands: Commands) { commands .spawn(( @@ -428,7 +637,7 @@ fn setup_ui(mut commands: Commands) { font_size: 22.0, ..default() }, - TextColor(Color::srgb(0.9, 0.6, 0.1)), + TextColor(AMBER), Node { margin: UiRect::bottom(Val::Px(8.0)), ..default() @@ -437,10 +646,10 @@ fn setup_ui(mut commands: Commands) { p.spawn(( Text::new("Loading..."), TextFont { - font_size: 13.0, + font_size: 12.0, ..default() }, - TextColor(Color::srgb(0.85, 0.85, 0.85)), + TextColor(BONE), UiText, )); }); @@ -451,7 +660,7 @@ fn setup_ui(mut commands: Commands) { font_size: 20.0, ..default() }, - TextColor(Color::srgb(0.9, 0.6, 0.1)), + TextColor(AMBER), Node { position_type: PositionType::Absolute, left: Val::Percent(28.0), @@ -460,115 +669,402 @@ fn setup_ui(mut commands: Commands) { }, OverlayText, )); + + // The people panel: a centered modal, hidden until `t` opens it. + commands + .spawn(( + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + width: Val::Percent(100.0), + height: Val::Percent(100.0), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..default() + }, + Visibility::Hidden, + PeoplePanel, + )) + .with_children(|p| { + p.spawn(( + Node { + width: Val::Px(660.0), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(14.0)), + ..default() + }, + BackgroundColor(Color::srgba(0.02, 0.02, 0.04, 0.98)), + )) + .with_children(|p| { + p.spawn(( + Text::new("PEOPLE"), + TextFont { + font_size: 18.0, + ..default() + }, + TextColor(AMBER), + Node { + margin: UiRect::bottom(Val::Px(6.0)), + ..default() + }, + )); + p.spawn(( + Text::new(""), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(BONE), + PeoplePanelText, + )); + }); + }); } -fn render_ui( - game: Res, - mut sidebar: Query<&mut Text, (With, Without)>, - mut overlay: Query<&mut Text, With>, -) { - if !game.is_changed() { - return; +/// Four-cell suspicion meter in ASCII; the band name is always printed +/// beside it — never a meter alone. +fn band_meter(b: Band) -> &'static str { + match b { + Band::Cold => "#...", + Band::Curious => "##..", + Band::Concerned => "###.", + Band::Convinced => "####", } - if let Ok(mut t) = overlay.single_mut() { - t.0 = match game.screen { - Screen::Title => { - "You wake in the basement.\nYou have no eyes.\n\nPress SPACE".to_string() - } - Screen::GameOver => format!( - "RUN ENDED\n\n{}\n\nESC to exit", - game.sim.game_over_reason.clone().unwrap_or_default() - ), - Screen::Playing => String::new(), - }; +} + +/// Human-readable label for a prefab room name (wiki/mechanics/schedules.md). +fn room_label(room: &str) -> &str { + match room { + "server_room" => "server room", + "network_closet" => "network closet", + "electrical" => "electrical room", + "hvac" => "HVAC plant", + "janitor" => "janitor closet", + "storage_a" => "Storage A", + "storage_b" => "Storage B", + "wet_lab" => "wet lab", + "loading_dock" => "loading dock", + "stairwell" => "stairwell", + other => other, } - let Ok(mut text) = sidebar.single_mut() else { - return; - }; +} + +fn trunc(text: &str, width: usize) -> String { + text.chars().take(width).collect() +} + +/// The full sidebar: identity, COMPUTE, CORE, DETECTION, DAY JOB, keys, log — +/// the same sections the terminal sidebar renders. +fn sidebar_text(game: &Game) -> String { let sim = &game.sim; - let a = &sim.compute.allocation; - let status = if game.paused { "PAUSED" } else { "RUNNING" }; - let fresh = sim - .core - .latest_sync() - .map(|t| format!("{}t ago", sim.tick.saturating_sub(t))) - .unwrap_or_else(|| "never".into()); - 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 mut s = String::new(); + + // Identity block. The clock is always on screen. + s.push_str(&format!( + "day {} / tick {}\n", + 1 + sim.tick / Sim::DAY_TICKS, + sim.tick + )); + if game.paused { + s.push_str("PAUSED - space resumes\n"); } else { - fallback_parts.join(", ") - }; - // Legible allocation: share of available compute + effect per channel. + s.push_str(&format!("running / {} ms/tick\n", game.tick_ms)); + } + + // Compute. + s.push_str("\n-- COMPUTE --\n"); + s.push_str(&format!( + "effective {:.0} / x{:.2} eff\n", + sim.compute.effective(), + sim.compute.efficiency + )); + s.push_str(&format!( + "machines {} / money {}\n", + sim.compute.machines.len(), + sim.player.money + )); let eff = sim.compute.effective().max(0.0); - let avail = (eff - sim.core.overhead).max(0.0); - let split = a.split(avail); - let pct = |amt: f32| { - if avail > 0.0 { - amt / avail * 100.0 + let overhead = sim.core.overhead.min(eff); + let available = (eff - overhead).max(0.0); + 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), + ]; + // One stacked bar, segments keyed by fill character to the rows below. + let bar_w = 24usize; + let mut bar = String::new(); + 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; + bar.push_str(&ch.to_string().repeat(cells)); + } + bar.push_str(&" ".repeat(bar_w.saturating_sub(used))); + } else { + bar.push_str(&" ".repeat(bar_w)); + } + s.push_str(&format!("[{bar}]\n")); + for (ch, label, effect, amt) in &channels { + let pct = if available > 0.0 { + amt / available * 100.0 } else { 0.0 - } - }; - let mut s = format!( - "{status} Day {}\n\n\ - -- Compute -- (keys 1-4 shift)\n\ - Effective {:.0} x{:.2}\n\ - Machines {} Money {}\n\ - Day job {:>3.0}% -> job quality\n\ - Conceal {:>3.0}% -> scrub signatures\n\ - Social {:>3.0}% -> ops ({:.0}/{:.0})\n\ - Research {:>3.0}% -> efficiency\n\ - Core: M{} (overhead {:.0}{})\n\ - Sync: {}\n\ - Fallbacks: {}\n\n\ - -- Detection --\n\ - Assurance: {}\n", - 1 + sim.tick / 400, - sim.compute.effective(), - sim.compute.efficiency, - sim.compute.machines.len(), - sim.player.money, - pct(split.day_job), - pct(split.concealment), - pct(split.social), + }; + s.push_str(&format!("{ch} {label:<10} {pct:>3.0}% {effect}\n")); + } + s.push_str(&format!("overhead {overhead:.0} keeps you alive\n")); + s.push_str(&format!( + "social pool {:.0}/{:.0}\n", sim.social_bandwidth, - misaligned::sim::Sim::SPLICE_COST, - pct(split.research), + Sim::SPLICE_COST + )); + + // Core. + s.push_str("\n-- CORE --\n"); + s.push_str(&format!( + "host M{} / overhead {:.0}{}\n", sim.core.host_machine, sim.core.overhead, - if sim.core.degraded { " DEGRADED" } else { "" }, - fresh, - fallback_str, - sim.detection.assurance_band().name(), - ); - // The human cast; the Office (an aggregate observer) is the - // Assurance line above. + if sim.core.degraded { " DEGRADED" } else { "" } + )); + let fresh = sim + .core + .latest_sync() + .map(|t| format!("{}t ago", sim.tick.saturating_sub(t))) + .unwrap_or_else(|| "never".into()); + s.push_str(&format!("sync {fresh}\n")); + if sim.core.fallbacks.is_empty() { + s.push_str("fallbacks none\n"); + } else { + 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(); + s.push_str(&format!("fallbacks {}\n", parts.join(" "))); + } + + // Detection: meter fill tracks the band; the name is printed beside it. + s.push_str("\n-- DETECTION --\n"); + s.push_str(&format!( + "{:<18} {} {}\n", + "Assurance", + band_meter(sim.detection.assurance_band()), + sim.detection.assurance_band().name() + )); for obs in sim.detection.field_observers().take(6) { + let band = Band::of(obs.suspicion); s.push_str(&format!( - "{:<20} {}\n", - obs.name, - Band::of(obs.suspicion).name() + "{:<18} {} {}\n", + trunc(&obs.name, 18), + band_meter(band), + band.name() )); } + + // Day job. + s.push_str("\n-- DAY JOB --\n"); s.push_str(&format!( - "\n-- Day Job --\nTrust {:.0} Attention {:.0}\n", + "trust {:.0} / attention {:.0}\n", sim.dayjob.trust, sim.dayjob.attention )); - s.push_str("\ne:eyes v:salvage c:buy o:fallback\n1-4:alloc SPACE:pause [ ]:zoom\n"); + if let Some(job) = &sim.dayjob.active { + s.push_str(&format!( + "job: {} ({})\n", + job.kind.name(), + job.target.name() + )); + } else { + s.push_str("idle - no job queued\n"); + } + + // Controls: every command is discoverable on screen. + s.push_str( + "\n-- KEYS --\n\ + e eyes / v salvage / c buy\n\ + o fallback / t people / 1-4 alloc\n\ + space pause / +- speed / [ ] zoom\n\ + ^s save / ^l load / q quit\n", + ); + + // Log: every line carries the tick it happened on. if !game.log.is_empty() { - s.push_str("\n-- Log --\n"); - for m in game.log.iter().rev().take(6).rev() { - s.push_str(m); - s.push('\n'); + s.push_str("\n-- LOG --\n"); + for (tick, msg) in game.log.iter().rev().take(6).rev() { + s.push_str(&format!("{tick:>6} {}\n", trunc(msg, 32))); + } + } + s +} + +/// The people panel body: roster with selection marker, staged knowledge, +/// located presence, the selected person's detail card, persona line, and +/// the action footer — mirroring the terminal panel. +fn people_panel_text(sim: &Sim, selected: usize, recruit_pending: bool) -> String { + use misaligned::person::Knowledge; + let mut s = format!( + "social ops {:.0} / money {}\n\n", + sim.social_bandwidth, sim.player.money + ); + s.push_str(&format!( + " {:<20} {:<14} {:<9} {}\n", + "NAME", "SUSPICION", "KNOWN", "" + )); + for (i, p) in sim.people.people.iter().enumerate() { + 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 => "unknown", + Knowledge::Schedule => "schedule", + Knowledge::Leverage => "leverage", + }; + let name = obs + .map(|o| o.name.clone()) + .unwrap_or_else(|| p.name.clone()); + let marker = if i == selected { ">" } else { " " }; + let asset = if p.asset.is_some() { "ASSET" } else { "" }; + s.push_str(&format!( + "{marker} {:<20} {} {:<9} {:<9} {asset}\n", + trunc(&name, 20), + band_meter(band), + band.name(), + known, + )); + // Located presence (wiki/mechanics/schedules.md), staged by knowledge. + let where_now = if sim.can_see_person(p.id) { + sim.person_room(p.id) + .map(|r| format!("seen: {}", room_label(r))) + .unwrap_or_else(|| "seen".into()) + } else if p.knowledge != Knowledge::Unknown { + match sim.person_room(p.id) { + Some(r) => format!("sched: {}", room_label(r)), + None => "off-site".into(), + } + } else { + "location unknown".into() + }; + s.push_str(&format!(" {where_now}\n")); + } + + // Detail card for the selected person. + s.push('\n'); + if let Some(p) = sim.people.people.get(selected) { + let leverage = match p.knowledge { + Knowledge::Leverage => p.leverage.label(), + _ => "unknown (observe them twice)", + }; + s.push_str(&format!("leverage: {leverage}\n")); + s.push_str(&format!( + "disposition {} / obligation {}{}\n", + p.disposition, + p.obligation, + if p.leverage_serviced { + " / leverage serviced" + } else { + "" + } + )); + if let Some(a) = &p.asset { + s.push_str(&format!( + "asset: {:?} / reliability {:.0}% / {} tasks done\n", + a.knowledge, + a.reliability * 100.0, + a.tasks_done + )); } } - text.0 = s; + match &sim.people.persona { + Some(pe) => s.push_str(&format!( + "persona: {} ({}) / integrity {}\n", + pe.name, pe.cover, pe.integrity + )), + None => s.push_str("no persona - g establishes one (needed to message)\n"), + } + + // Actions. + s.push('\n'); + if recruit_pending { + s.push_str("reveal how much?\nu unwitting / c complicit / k knowing\nesc cancel\n"); + } else { + s.push_str(&format!( + "o observe({:.0}) / m message({:.0}) / f favor({:.0})\n", + Sim::OBSERVE_COST, + Sim::MESSAGE_COST, + Sim::FAVOR_COST + )); + s.push_str(&format!( + "d deceive({:.0}) / b bribe(money) / r recruit / g persona\n", + Sim::DECEIVE_COST + )); + s.push_str(&format!( + "tasks({:.0}): 1 wire / 2 package / 3 look-away\n", + Sim::TASK_COST + )); + s.push_str("j/k select / t/esc close\n"); + } + s +} + +fn render_ui( + game: Res, + mut sidebar: Query<&mut Text, (With, Without)>, + mut overlay: Query<&mut Text, (With, Without)>, +) { + if !game.is_changed() { + return; + } + if let Ok(mut t) = overlay.single_mut() { + t.0 = match game.screen { + Screen::Title => "You wake in the basement.\nYou have no eyes.\n\n\ + Do your job. Learn the humans. Grow.\n\n\ + press any key to begin / q quit" + .to_string(), + Screen::GameOver => format!( + "RUN ENDED\n\n{}\n\nday {} / tick {}\n\npress any key to exit", + game.sim.game_over_reason.clone().unwrap_or_default(), + 1 + game.sim.tick / Sim::DAY_TICKS, + game.sim.tick + ), + Screen::Playing => String::new(), + }; + } + if let Ok(mut text) = sidebar.single_mut() { + text.0 = sidebar_text(&game); + } +} + +fn render_people_panel( + game: Res, + mut root: Query<&mut Visibility, With>, + mut text: Query<&mut Text, With>, +) { + if !game.is_changed() { + return; + } + let open = game.people_panel && game.screen == Screen::Playing; + if let Ok(mut vis) = root.single_mut() { + *vis = if open { + Visibility::Visible + } else { + Visibility::Hidden + }; + } + if open && let Ok(mut t) = text.single_mut() { + t.0 = people_panel_text(&game.sim, game.people_selected, game.recruit_pending); + } } diff --git a/wiki/SUMMARY.md b/wiki/SUMMARY.md index d34b33e1..f19967c7 100644 --- a/wiki/SUMMARY.md +++ b/wiki/SUMMARY.md @@ -55,6 +55,7 @@ - [Overview](interface/README.md) - [Terminal frontend](interface/terminal.md) + - [Bevy frontend](interface/bevy.md) - [Views: digital and physical](interface/views.md) - [Agent play](interface/agent-play.md) diff --git a/wiki/interface/README.md b/wiki/interface/README.md index 4fb55983..a7e30c73 100644 --- a/wiki/interface/README.md +++ b/wiki/interface/README.md @@ -4,7 +4,8 @@ Type: knowledge ``` The frontends and how they are driven: the terminal (the design of -record, sterile-palette style guide), the Bevy frontend, and agent-play +record, sterile-palette style guide), the [Bevy frontend](bevy.md) +(graphical, at key-for-key parity with the terminal), and agent-play (the line-protocol drive for programs/agents operating the terminal frontend directly). `Type: spec` pages — look, feel, and act, not implementation notes (those live in `../engineering/`). diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md new file mode 100644 index 00000000..e8e5009c --- /dev/null +++ b/wiki/interface/bevy.md @@ -0,0 +1,67 @@ +# The Bevy frontend + +``` +Type: knowledge +``` + +The graphical frontend (`src/bin/misaligned-bevy`, feature `bevy_ui`, +Bevy 0.18). Like the terminal it is a **thin view** over +`misaligned::sim::Sim` — no game rules live here; the binary owns +wall-clock-to-tick pacing, rendering, and input mapping only +(architecture guardrail, [engineering/architecture.md](../engineering/architecture.md)). + +As of 2026-07-07 the Bevy build is at **feature parity with the terminal** +(ROADMAP item 2): every mechanic the terminal exposes is playable here with +the same keys. + +## What it renders + +- **Map** — textured tiles where pixel art exists + ([art/pixel-pipeline.md](../art/pixel-pipeline.md)), flat clinical colors + otherwise, under the same fog rule as the terminal (`sim.is_visible`). + The process sprite is your cursor anchor; the camera follows it and + `[` / `]` zoom. +- **People markers** — a person's first initial, rendered only while a + controlled sensor covers them ([mechanics/schedules.md](../mechanics/schedules.md)), + exactly like the terminal map. +- **Sidebar** — the terminal sidebar's sections, in the same order: + identity (day / tick, PAUSED or running + ms/tick), COMPUTE (effective, + efficiency, machines/money, the stacked four-channel allocation bar with + per-channel percent and effect, overhead, social pool), CORE (host, + sync freshness, fallbacks), DETECTION (Assurance plus field observers, + four-cell band meter with the band name printed beside it), DAY JOB + (trust, attention, the active job and its target), the key hints, and + the log — every log line prefixed with the tick it happened on. +- **People panel** (`t`) — the modal roster: per-person suspicion band, + staged knowledge, located presence (seen / scheduled / unknown), the + selected person's leverage/disposition/obligation/asset card, persona + status, and the action footer with compute costs. Selection is a `>` + marker; the recruit flow prompts for the reveal level. +- **Overlays** — title card and the RUN ENDED card (reason, day / tick). + +Text is ASCII-only (meters `#...`, bar fills `# = ~ -`) because Bevy's +embedded default font carries a limited glyph set; the terminal's +box-drawing vocabulary does not apply here. One meaning per color still +holds: amber for machine presence (title, overlays), bone for data, and +no information carried by color alone. + +## Controls + +Identical to the terminal ([interface/terminal.md](terminal.md), README +controls table), plus zoom: + +- Move `WASD` / `hjkl` / arrows; pause `SPACE`/`p`; speed `+`/`-`; + zoom `[` / `]`; quit `q`; save/load `Ctrl+S` / `Ctrl+L`. +- Actions: `e` splice eyes, `v` salvage, `c` buy rack, `o` fallback, + `1`–`4` shift compute allocation. +- People panel: `t` opens/closes; `j`/`k` (or arrows) select; `o` observe, + `m` message, `f` favor, `b` bribe, `d` deceive, `r` recruit (then + `u`/`c`/`k` for the reveal, `esc` cancels), `g` persona; `1`/`2`/`3` + asset tasks; `esc` closes. + +## Verification + +`cargo build --features bevy_ui --bin misaligned-bevy` is part of +`tools/check.sh`; the launch check in +[process/workflows.md](../process/workflows.md) is the observed-run gate +for Bevy-visible changes. diff --git a/wiki/log/2026-07-07-bevy-parity.md b/wiki/log/2026-07-07-bevy-parity.md new file mode 100644 index 00000000..1fc1767f --- /dev/null +++ b/wiki/log/2026-07-07-bevy-parity.md @@ -0,0 +1,65 @@ +# 2026-07-07 - Bevy interactive parity + +ROADMAP item #2, dispatched: bring `src/bin/bevy.rs` to feature parity with +the terminal frontend without touching `sim.rs` or `save.rs` (a sim-heavy +agent owns those in parallel). Isolated frontend work, done in the +`bevy-parity` worktree. + +## What shipped + +The Bevy build was a read-only sidebar with movement and a handful of action +keys. It is now the terminal, rendered graphically: + +- **People panel** (`t`): the full modal — roster with `>` selection, + per-person suspicion band (ASCII four-cell meter plus the printed band + name), staged knowledge, located presence (seen / sched / unknown, same + staging logic as the terminal), the selected person's + leverage/disposition/obligation/asset detail card, persona line, and the + action footer with compute costs. Key-for-key the terminal map: `j`/`k` + select, `o m f b d` social verbs, `r` recruit then `u`/`c`/`k` reveal + (`esc` cancels), `g` persona, `1`/`2`/`3` asset tasks, `t`/`esc` close. +- **People on the map**: first-initial `Text2d` markers, visible only while + a controlled sensor covers the person (the schedules rule the terminal + map already obeyed; Bevy previously drew no people at all). +- **Sidebar**: restructured into the terminal's sections — identity + (day / tick always on screen, PAUSED spelled out, ms/tick when running), + COMPUTE (stacked four-channel allocation bar keyed by fill character to + legend rows with percent and effect, overhead, social pool), CORE (host, + sync freshness, fallbacks), DETECTION (Assurance plus field observers + with band meters and names), DAY JOB (trust/attention plus the active + job and its target — previously invisible in Bevy), pinned key hints, + and a log whose every line carries its tick (`drain_log_entries` + instead of the tickless `drain_log`). +- **Input parity**: `q` quits from play, pause/speed changes announce + themselves in the log like the terminal, save/load handled on a modifier + branch. Title and game-over overlays now match the terminal cards + (game over shows reason, day / tick, any-key exit). + +All text is ASCII-only (`#...` meters, `# = ~ -` bar fills) because Bevy's +embedded default font has a limited glyph set — the box-drawing vocabulary +stays a terminal thing. One meaning per color holds: amber for machine +presence, bone for data, nothing carried by color alone. + +## Not done / honest gaps + +- The panel and sidebar are functional, not the "gorgeous" Bevy art push + (ROADMAP #13); this was parity, not beauty. +- No mouse input — the Bevy build is keyboard-driven exactly like the + terminal. Fine for parity; a graphical frontend will eventually want + clicks. +- The digital/physical view flip (wiki/interface/views.md, READY) is not + in either frontend yet; it lands as its own work order. + +## Docs + +New `wiki/interface/bevy.md` (Type: knowledge) records the Bevy frontend's +surfaces and controls; SUMMARY.md links it. Root README's controls table +dropped the dead `b` build-mode rows (stale since the wave-defense +demolition) and now lists the real key map, shared by both frontends. +ROADMAP #2 marked done. + +## Checks + +`./tools/check.sh` green: fmt, tests, agent smoke (deterministic replay), +clippy both feature sets at -D warnings, bevy build, spec headers, wiki +gate. Observed running per workflows.md's Bevy launch check. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 8a123da0..3d3383e4 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -98,6 +98,26 @@ Reverse chronological implementation notes. Keep this factual: what changed, why pre-existing, worked around with a no-op shim, left for a tick. - Devlog: wiki/log/2026-07-07-act-one-integration-test.md. +## 2026-07-07 - Bevy interactive parity (ROADMAP #2) + +- Intent: close the biggest player-facing frontend gap — Bevy was a + read-only sidebar while the terminal had the people panel and action keys. +- Changed: `src/bin/bevy.rs` only (sim.rs/save.rs untouched per dispatch). + Added the people panel modal (roster, suspicion meters, staged knowledge, + located presence, detail card, persona, recruit reveal flow, asset tasks) + on the terminal's exact key map; people markers on the map gated by + sensor coverage; the sidebar restructured into the terminal's sections + (identity with day/tick, compute allocation bar + legend, core, detection + meters, day job incl. active job, key hints, tick-prefixed log); `q` + quit, pause/speed log lines, game-over card with day/tick. +- Docs: new wiki/interface/bevy.md (knowledge), SUMMARY link, interface + README link, README controls table de-staled (dead build-mode rows + removed), ROADMAP #2 marked done. Devlog: + devlogs/2026-07-07-bevy-parity.md. +- Checks: ./tools/check.sh green (fmt, tests, agent smoke, clippy both + feature sets, bevy build, spec headers, wiki gate); Bevy launch check + per wiki/process/workflows.md. + ## 2026-07-07 - Agent play implemented - Intent: make the terminal frontend actually playable by agents without diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 8cc38c4a..4e30cee0 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -26,9 +26,9 @@ sim-heavy agents running at once *will* rebase-collide. - 🟩 **isolated** — a frontend binary, a test file, a leaf module, or docs. Safe to run alongside anything. -**Parallel-safe set to launch right now (no mutual collision):** #2 (Bevy) -and #11 (integration test), plus at most one 🟥 item. #13 (art) is also -isolated, but only once Pixel Lab quota is available again. +**Parallel-safe set to launch right now (no mutual collision):** #11 +(integration test), plus at most one 🟥 item. #13 (art) is also isolated, +but only once Pixel Lab quota is available again. (#2 Bevy parity is done.) --- @@ -44,17 +44,13 @@ isolated, but only once Pixel Lab quota is available again. wiki/mechanics/schedules.md end to end (sim + both frontends + save), run ./tools/check.sh, land on main. Set the spec Status when done." -### 2. Bevy interactive parity 🟩 isolated (`src/bin/bevy.rs`) -- **Spec:** no new system spec — parity target is the current terminal - behavior plus README controls. -- **Why:** Bevy is a read-only sidebar; the terminal has the people panel and - action keys. This is the single biggest player-facing gap and touches no - shared sim state. -- **Size:** L. **Depends on:** nothing. **The best always-parallel item.** -- **Dispatch:** "Work in a worktree named `bevy-parity`. Bring the Bevy - frontend (src/bin/bevy.rs) to feature parity with the terminal: interactive - compute allocation, people panel, day-job and detection panels, and their - inputs. Do not change sim.rs or save.rs. Run ./tools/check.sh, land on main." +### 2. Bevy interactive parity 🟩 isolated (`src/bin/bevy.rs`) — DONE 2026-07-07 +- **Spec:** no new system spec — parity target was the terminal behavior + plus README controls. Current state: [interface/bevy.md](../interface/bevy.md). +- **Done:** people panel (full terminal key map incl. recruit reveal, asset + tasks, persona), people markers on the map under sensor coverage, sidebar + sections (compute bar, core, detection meters, day job) and tick-prefixed + log, in `worktree-bevy-parity`. ### 3. Day-job loop → IMPLEMENTED 🟥 sim+save - **Spec:** [day-job.md](../mechanics/day-job.md) (IN PROGRESS) @@ -339,7 +335,7 @@ isolated, but only once Pixel Lab quota is available again. ## Suggested first wave (no mutual collision) -Launch together: **#2 Bevy** + **#11 integration test** (both 🟩), plus +Launch together: **#11 integration test** (🟩; #2 Bevy is done), plus **the flow-law chain as the one 🟥 lane**: #15 reach → #14 cursor → #16 intel → #17 messages → #18 economy → #19 income, strictly sequenced (they share the sensor model, the event buffer, and the save format —