diff --git a/README.md b/README.md index 1cf21282..d79bc088 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | `Ctrl+S` / `Ctrl+L` | Save / load | | `q` | Quit | +The Bevy right rail keeps status/nudge and core controls pinned while the +middle cards scroll for secondary detail. + Parking the cursor on the host rack attends the current day job; moving it away leaves the job on its standing policy. diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 6e129cb8..2bd394c9 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use bevy::asset::RenderAssetUsages; use bevy::camera::visibility::RenderLayers; +use bevy::ecs::hierarchy::ChildSpawnerCommands; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; @@ -24,9 +25,12 @@ use misaligned::sim::{FactSource, Fog, Sim}; use misaligned::tiles::TileType; const TILE_SIZE: f32 = 16.0; -const SIDEBAR_WIDTH: f32 = 380.0; +const SIDEBAR_WIDTH: f32 = 420.0; const SIDEBAR_SCROLL_LINE: f32 = 18.0; const SIDEBAR_SCROLL_PAGE: f32 = 180.0; +const COMPUTE_CHANNELS: usize = 4; +const DETECTION_ROWS: usize = 6; +const DETECTION_CELLS: usize = 4; // HD-2D prototype (F3 material render): one 3D world unit per map tile. const WALL_HEIGHT: f32 = 1.5; @@ -464,11 +468,39 @@ struct PersonMarker { id: u8, } #[derive(Component)] -struct SidebarHeaderText; -#[derive(Component)] struct SidebarScrollArea; +#[derive(Component, Clone, Copy)] +enum SidebarText { + Header, + Nudge, + Focus, + CycleStats, + CycleRows, + Core, + Cover, + NetworkMoney, + Log, + Footer, +} +#[derive(Component)] +struct ComputeBarSegment { + index: usize, +} +#[derive(Component, Clone, Copy)] +struct DetectionText { + row: usize, + slot: DetectionSlot, +} +#[derive(Clone, Copy)] +enum DetectionSlot { + Label, + Band, +} #[derive(Component)] -struct UiText; +struct DetectionCell { + row: usize, + cell: usize, +} #[derive(Component)] struct OverlayText; #[derive(Component)] @@ -484,16 +516,21 @@ struct FinancePanel; #[derive(Component)] struct FinancePanelText; -/// Dynamic pinned sidebar header text; split from UiText to avoid a single -/// clipped terminal dump. -type SidebarHeaderQuery<'w, 's> = Query< +type SidebarTextQuery<'w, 's> = + Query<'w, 's, (&'static SidebarText, &'static mut Text), Without>; +type ComputeBarQuery<'w, 's> = Query<'w, 's, (&'static ComputeBarSegment, &'static mut Node)>; +type DetectionTextQuery<'w, 's> = + Query<'w, 's, (&'static DetectionText, &'static mut Text), Without>; +type DetectionCellQuery<'w, 's> = + Query<'w, 's, (&'static DetectionCell, &'static mut BackgroundColor)>; +type OverlayTextQuery<'w, 's> = Query< 'w, 's, &'static mut Text, ( - With, - Without, - Without, + With, + Without, + Without, ), >; @@ -2088,6 +2125,229 @@ fn render_people(game: Res, mut q: Query<(&PersonMarker, &mut Transform, & } } +fn sidebar_card_bg() -> BackgroundColor { + BackgroundColor(Color::srgba(0.018, 0.019, 0.026, 0.94)) +} + +fn sidebar_card_border() -> BorderColor { + BorderColor::all(Color::srgba(0.34, 0.34, 0.31, 0.78)) +} + +fn compute_channel_color(index: usize) -> Color { + match index { + 0 => AMBER, + 1 => Color::srgb(0.62, 0.59, 0.48), + 2 => AMBER_DIM, + _ => Color::srgb(0.34, 0.36, 0.38), + } +} + +fn band_fill_count(band: Band) -> usize { + match band { + Band::Cold => 1, + Band::Curious => 2, + Band::Concerned => 3, + Band::Convinced => 4, + } +} + +fn band_fill_color(band: Band) -> Color { + match band { + Band::Cold => AMBER_DIM, + Band::Curious => AMBER, + Band::Concerned | Band::Convinced => CRIMSON, + } +} + +fn spawn_sidebar_text( + parent: &mut ChildSpawnerCommands, + kind: SidebarText, + font_size: f32, + color: Color, +) { + parent.spawn(( + Text::new(""), + TextFont { + font_size, + ..default() + }, + LineHeight::RelativeToFont(1.14), + TextColor(color), + Node { + width: Val::Percent(100.0), + ..default() + }, + kind, + )); +} + +fn spawn_sidebar_title(parent: &mut ChildSpawnerCommands, title: &'static str) { + parent.spawn(( + Text::new(title), + TextFont { + font_size: 10.5, + ..default() + }, + LineHeight::RelativeToFont(1.0), + TextColor(AMBER_DIM), + Node { + width: Val::Percent(100.0), + margin: UiRect::bottom(Val::Px(3.0)), + ..default() + }, + )); +} + +fn spawn_sidebar_card(parent: &mut ChildSpawnerCommands, title: &'static str, kind: SidebarText) { + parent + .spawn(( + Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(8.0)), + border: UiRect::all(Val::Px(1.0)), + row_gap: Val::Px(2.0), + ..default() + }, + sidebar_card_bg(), + sidebar_card_border(), + )) + .with_children(|card| { + spawn_sidebar_title(card, title); + spawn_sidebar_text(card, kind, 11.5, BONE); + }); +} + +fn spawn_compute_card(parent: &mut ChildSpawnerCommands) { + parent + .spawn(( + Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(8.0)), + border: UiRect::all(Val::Px(1.0)), + row_gap: Val::Px(5.0), + ..default() + }, + sidebar_card_bg(), + sidebar_card_border(), + )) + .with_children(|card| { + spawn_sidebar_title(card, "CYCLES"); + spawn_sidebar_text(card, SidebarText::CycleStats, 11.5, BONE); + card.spawn(( + Node { + width: Val::Percent(100.0), + height: Val::Px(12.0), + flex_direction: FlexDirection::Row, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::srgba(0.03, 0.031, 0.035, 1.0)), + BorderColor::all(Color::srgba(0.44, 0.42, 0.34, 0.8)), + )) + .with_children(|bar| { + for index in 0..COMPUTE_CHANNELS { + bar.spawn(( + Node { + width: Val::Percent(25.0), + height: Val::Percent(100.0), + ..default() + }, + BackgroundColor(compute_channel_color(index)), + ComputeBarSegment { index }, + )); + } + }); + spawn_sidebar_text(card, SidebarText::CycleRows, 10.5, DIM); + }); +} + +fn spawn_detection_card(parent: &mut ChildSpawnerCommands) { + parent + .spawn(( + Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(8.0)), + border: UiRect::all(Val::Px(1.0)), + row_gap: Val::Px(5.0), + ..default() + }, + sidebar_card_bg(), + sidebar_card_border(), + )) + .with_children(|card| { + spawn_sidebar_title(card, "OBSERVER MODEL"); + for row in 0..DETECTION_ROWS { + card.spawn((Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(6.0), + ..default() + },)) + .with_children(|line| { + line.spawn(( + Text::new(""), + TextFont { + font_size: 10.5, + ..default() + }, + LineHeight::RelativeToFont(1.0), + TextColor(BONE), + Node { + width: Val::Px(158.0), + flex_shrink: 0.0, + ..default() + }, + DetectionText { + row, + slot: DetectionSlot::Label, + }, + )); + line.spawn((Node { + width: Val::Px(54.0), + height: Val::Px(7.0), + flex_direction: FlexDirection::Row, + column_gap: Val::Px(2.0), + ..default() + },)) + .with_children(|meter| { + for cell in 0..DETECTION_CELLS { + meter.spawn(( + Node { + width: Val::Px(11.0), + height: Val::Px(7.0), + ..default() + }, + BackgroundColor(Color::srgba(0.09, 0.09, 0.095, 1.0)), + DetectionCell { row, cell }, + )); + } + }); + line.spawn(( + Text::new(""), + TextFont { + font_size: 10.5, + ..default() + }, + LineHeight::RelativeToFont(1.0), + TextColor(DIM), + Node { + flex_grow: 1.0, + ..default() + }, + DetectionText { + row, + slot: DetectionSlot::Band, + }, + )); + }); + } + }); +} + fn setup_ui(mut commands: Commands) { commands .spawn(( @@ -2098,34 +2358,40 @@ fn setup_ui(mut commands: Commands) { width: Val::Px(SIDEBAR_WIDTH), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, - padding: UiRect::all(Val::Px(10.0)), + padding: UiRect::all(Val::Px(12.0)), border: UiRect::left(Val::Px(1.0)), + row_gap: Val::Px(8.0), ..default() }, - BackgroundColor(Color::srgba(0.006, 0.006, 0.012, 0.98)), - BorderColor::all(Color::srgba(0.25, 0.25, 0.23, 0.85)), + BackgroundColor(Color::srgba(0.004, 0.005, 0.009, 0.985)), + BorderColor::all(Color::srgba(0.26, 0.26, 0.24, 0.9)), )) .with_children(|p| { p.spawn(( - Text::new("MISALIGNED // SENSORIUM"), - TextFont { - font_size: 13.0, - ..default() - }, - LineHeight::RelativeToFont(1.14), - TextColor(AMBER), Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(9.0)), + border: UiRect::all(Val::Px(1.0)), + row_gap: Val::Px(4.0), flex_shrink: 0.0, - margin: UiRect::bottom(Val::Px(6.0)), ..default() }, - SidebarHeaderText, - )); + BackgroundColor(Color::srgba(0.028, 0.026, 0.018, 0.96)), + BorderColor::all(Color::srgba(0.66, 0.47, 0.10, 0.82)), + )) + .with_children(|header| { + spawn_sidebar_text(header, SidebarText::Header, 14.5, AMBER); + spawn_sidebar_text(header, SidebarText::Nudge, 11.5, BONE); + }); + p.spawn(( Node { width: Val::Percent(100.0), flex_grow: 1.0, min_height: Val::Px(0.0), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(8.0), overflow: Overflow::scroll_y(), padding: UiRect::right(Val::Px(6.0)), ..default() @@ -2133,21 +2399,30 @@ fn setup_ui(mut commands: Commands) { ScrollPosition(Vec2::ZERO), SidebarScrollArea, )) - .with_children(|p| { - p.spawn(( - Text::new("Loading..."), - TextFont { - font_size: 12.0, - ..default() - }, - LineHeight::RelativeToFont(1.12), - TextColor(BONE), - Node { - width: Val::Percent(100.0), - ..default() - }, - UiText, - )); + .with_children(|scroll| { + spawn_sidebar_card(scroll, "FOCUS", SidebarText::Focus); + spawn_compute_card(scroll); + spawn_sidebar_card(scroll, "COVER PROCESS", SidebarText::Cover); + spawn_detection_card(scroll); + spawn_sidebar_card(scroll, "SELF HOST", SidebarText::Core); + spawn_sidebar_card(scroll, "NETWORK + MONEY", SidebarText::NetworkMoney); + spawn_sidebar_card(scroll, "RECENT TRACE", SidebarText::Log); + }); + + p.spawn(( + Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(8.0)), + border: UiRect::all(Val::Px(1.0)), + flex_shrink: 0.0, + ..default() + }, + BackgroundColor(Color::srgba(0.012, 0.013, 0.018, 0.96)), + BorderColor::all(Color::srgba(0.26, 0.26, 0.24, 0.85)), + )) + .with_children(|footer| { + spawn_sidebar_text(footer, SidebarText::Footer, 10.0, DIM); }); }); @@ -2373,208 +2648,136 @@ fn fact_source(source: &FactSource) -> String { } /// Pinned sidebar status: the player should not lose clock, run state, current -/// representation, or the one actionable nudge while scrolling the dense ops -/// readout below. +/// representation, or the one actionable nudge while scanning the ops cards. fn sidebar_header_text(game: &Game, material: bool) -> String { let sim = &game.sim; - let mut s = String::new(); - s.push_str(if material { - "MISALIGNED // MATERIAL\n" + let mode = if material { "MATERIAL" } else { "SENSORIUM" }; + let run = if game.paused { + "PAUSED".to_string() } else { - "MISALIGNED // SENSORIUM\n" - }); - s.push_str(&format!( - "day {} / tick {}", + format!("{}ms/t", game.tick_ms) + }; + let flip = if material { "F3 flat" } else { "F3 material" }; + format!( + "MISALIGNED // {mode}\nday {} tick {} {run}\n{flip}", 1 + sim.tick / Sim::DAY_TICKS, sim.tick - )); - if game.paused { - s.push_str(" / PAUSED\n"); - } else { - s.push_str(&format!(" / {}ms/t\n", game.tick_ms)); - } - s.push_str(if material { - "F3: flat view / PgUp PgDn scroll\n" - } else { - "F3: material view / PgUp PgDn scroll\n" - }); - if let Some(nudge) = sidebar_nudge(sim) { - s.push_str(&format!("now: {nudge}\n")); - } - s.push_str("wheel over right pane scrolls"); - s + ) +} + +fn sidebar_nudge_text(game: &Game) -> String { + let nudge = sidebar_nudge(&game.sim).unwrap_or("stable - inspect a device or open a panel"); + format!("NEXT: {nudge}\nwheel/PgUp/PgDn for details") } fn sidebar_nudge(sim: &Sim) -> Option<&'static str> { if sim.dayjob.pilot_failed { None } else if sim.reach.player_sight().next().is_none() { - Some("no eyes - r reach, tap a camera") + Some("get eyes - r reach, tap a camera") } else if sim .dayjob .active .as_ref() .is_some_and(|job| sim.day_job_rate() + 0.05 < job.band_lo) { - Some("job underfed - 1 feeds it, c buys") + Some("job underfed - feed Day job or buy compute") } else { None } } -/// The scrollable sidebar body: the terminal data rephrased as an AI -/// sensorium/ops surface, while preserving the same facts and controls. -fn sidebar_body_text(game: &Game) -> String { +fn sidebar_focus_text(game: &Game) -> String { let sim = &game.sim; - let mut s = String::new(); - - // Inspect: every fact is tagged with provenance (cursor.md). - s.push_str("FOCUS\n"); - s.push_str(&format!( - "cursor {},{} / {:?}\n", + let mut s = format!( + "{},{} {:?}\n", game.cursor_x, game.cursor_y, sim.fog_at(game.cursor_x, game.cursor_y) - )); + ); let card = sim.inspect(game.cursor_x, game.cursor_y); if card.facts.is_empty() { - s.push_str("no earned facts\n"); + s.push_str("no earned facts\nmove focus or tap a feed"); } else { - for fact in card.facts.iter().take(6) { + for fact in card.facts.iter().take(4) { s.push_str(&format!( "{}: {} [{}]\n", fact.label, - trunc(&fact.value, 20), + trunc(&fact.value, 24), fact_source(&fact.source) )); } } + s.trim_end().to_string() +} - // Compute. Effective includes seized devices' cycles (reach.md). - s.push_str("\nCYCLE ROUTER\n"); - s.push_str(&format!( - "effective {:.0} / x{:.2} eff\n", - sim.effective_compute(), - sim.compute.efficiency - )); - s.push_str(&format!( - "machines {} / slush {} / dev +{:.0}\n", - sim.compute.machines.len(), - sim.accounts.slush_balance(), - sim.reach.taken_cycles() - )); +fn compute_channels( + sim: &Sim, +) -> ( + f32, + f32, + [(&'static str, &'static str, f32); COMPUTE_CHANNELS], +) { let eff = sim.effective_compute().max(0.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 { + ( + eff, + available, + [ + ("1 Day job", "quality", split.day_job), + ("2 Conceal", "scrub", split.concealment), + ("3 Social", "ops", split.social), + ("4 Research", "eff", split.research), + ], + ) +} + +fn sidebar_cycle_stats_text(sim: &Sim) -> String { + let (eff, available, _) = compute_channels(sim); + let overhead = (eff - available).max(0.0); + format!( + "usable {:.0}/{:.0} cycles x{:.2}\noverhead {:.0} keeps core alive\nmachines {} + seized {:.0} slush ${}", + available, + eff, + sim.compute.efficiency, + overhead, + sim.compute.machines.len(), + sim.reach.taken_cycles(), + sim.accounts.slush_balance() + ) +} + +fn sidebar_cycle_rows_text(sim: &Sim) -> String { + let (_, available, channels) = compute_channels(sim); + let mut s = String::new(); + for (label, effect, amount) in &channels { let pct = if available > 0.0 { - amt / available * 100.0 + amount / available * 100.0 } else { 0.0 }; - s.push_str(&format!("{ch} {label:<10} {pct:>3.0}% {effect}\n")); + s.push_str(&format!("{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", + "social pool {:.0}/{:.0}", sim.social_bandwidth, Sim::SPLICE_COST )); + s +} - // Core. - s.push_str("\nSELF HOST\n"); - s.push_str(&format!( - "host M{} / overhead {:.0}{}\n", - sim.core.host_machine, - sim.core.overhead, - 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("\nOBSERVER MODEL\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!( - "{:<18} {} {}\n", - trunc(&obs.name, 18), - band_meter(band), - band.name() - )); +fn sidebar_cover_text(sim: &Sim) -> String { + let mut s = format!( + "trust {:.0} attention {:.0}", + sim.dayjob.trust, sim.dayjob.attention + ); + if sim.dayjob.strikes > 0 { + s.push_str(&format!(" strikes {}", sim.dayjob.strikes)); } - - // Day job (day-job.md): delivered-vs-band is the number the outcome is - // judged by, so it is never hidden while a job is active. - s.push_str("\nCOVER PROCESS\n"); - s.push_str(&format!( - "trust {:.0} / attention {:.0}{}\n", - sim.dayjob.trust, - sim.dayjob.attention, - if sim.dayjob.strikes > 0 { - format!(" / strikes {}", sim.dayjob.strikes) - } else { - String::new() - } - )); + s.push('\n'); if let Some(job) = &sim.dayjob.active { - s.push_str(&format!( - "job: {} - due in {}t\n", - job.kind.name(), - job.deadline.saturating_sub(sim.tick) - )); let avg = job.avg_rate(sim.tick); let verdict = if avg < job.band_lo { "sandbag" @@ -2584,24 +2787,24 @@ fn sidebar_body_text(game: &Game) -> String { "excel" }; s.push_str(&format!( - "band {:.0}-{:.0}/t / avg {:.1}/t = {}\n", - job.band_lo, job.band_hi, avg, verdict - )); - s.push_str(&format!( - "target {} (x cycles) / fed {:.1}/t\n", + "{} due {}t\nneed {:.0}-{:.0}/t avg {:.1}/t -> {}\ntarget {} feed {:.1}/t\n", + job.kind.name(), + job.deadline.saturating_sub(sim.tick), + job.band_lo, + job.band_hi, + avg, + verdict, job.target.name(), sim.day_job_rate() )); - // Attended work (day-job.md criterion 7): the job is resident on - // the host rack; the cursor parked there runs it attended. if sim.dayjob.attended { s.push_str(&format!( - "attended on host rack (+{:.0}%)\n", + "attended on host (+{:.0}%)", misaligned::dayjob::DayJob::ATTENDED_BONUS * 100.0 )); } else { s.push_str(&format!( - "unattended / policy {}\n", + "unattended policy: {}", sim.dayjob .standing_policy .unwrap_or(misaligned::dayjob::JobTarget::Meet) @@ -2609,44 +2812,132 @@ fn sidebar_body_text(game: &Game) -> String { )); } } else { - s.push_str("idle - no job queued\n"); + s.push_str("idle - no job queued"); } + s +} + +fn sidebar_core_text(sim: &Sim) -> String { + let fresh = sim + .core + .latest_sync() + .map(|t| format!("{}t ago", sim.tick.saturating_sub(t))) + .unwrap_or_else(|| "never".into()); + let fallbacks = if sim.core.fallbacks.is_empty() { + "none".into() + } else { + 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::>() + .join(" ") + }; + format!( + "host M{} overhead {:.0}{}\nsync {}\nfallbacks {}", + sim.core.host_machine, + sim.core.overhead, + if sim.core.degraded { " DEGRADED" } else { "" }, + fresh, + fallbacks + ) +} - // Reach: the device-graph summary (wiki/mechanics/reach.md); the panel - // (r) holds the full topology and the digital verbs. +fn sidebar_network_money_text(sim: &Sim) -> String { let reachable = sim.reach.reach().len(); let known = sim.reach.known().count(); let eyes = sim.reach.player_sight().count(); let ears = sim.reach.player_hearing().count(); - s.push_str(&format!( - "\nDEVICE GRAPH\ndevices {known} known / {reachable} reachable\nfeeds {eyes} eyes / {ears} ears\n" - )); - - s.push_str(&format!( - "\nFINANCE\nslush ${} / known flows {} / records {}\n", + format!( + "devices {} known / {} reachable\nfeeds {} eyes / {} ears\nslush ${} flows {} records {}", + known, + reachable, + eyes, + ears, sim.accounts.slush_balance(), sim.accounts.known_flows().count(), sim.financial_records_waiting() - )); + ) +} - // Controls: every command is discoverable on screen. - s.push_str( - "\nINPUT VOCAB\n\ - r reach / e finance / t people\n\ - v salvage / c buy / o fallback\n\ - x target / 1-4 alloc / shift+1-4 lower\n\ - space pause / +- speed / [ ] zoom\n\ - f3 render / ^s save / ^l load / q quit\n", - ); +fn sidebar_log_text(game: &Game) -> String { + if game.log.is_empty() { + return "no events yet".into(); + } + let mut s = String::new(); + for (tick, msg) in game.log.iter().rev().take(4).rev() { + s.push_str(&format!("{tick:>5} {}\n", trunc(msg, 34))); + } + s.trim_end().to_string() +} + +fn sidebar_footer_text() -> &'static str { + "panels: r reach t people e finance +ops: v salvage c buy o fallback x target +alloc: 1-4 raise shift+1-4 lower +sim: space pause +/- speed [ ] zoom +save: ^s/^l q quit" +} + +fn update_compute_bar(sim: &Sim, bars: &mut ComputeBarQuery) { + let (_, available, channels) = compute_channels(sim); + for (segment, mut node) in bars.iter_mut() { + let pct = channels + .get(segment.index) + .map(|(_, _, amount)| { + if available > 0.0 { + amount / available * 100.0 + } else { + 0.0 + } + }) + .unwrap_or(0.0); + node.width = Val::Percent(pct.clamp(0.0, 100.0)); + } +} - // Log: every line carries the tick it happened on. - if !game.log.is_empty() { - s.push_str("\nEVENT TRACE\n"); - for (tick, msg) in game.log.iter().rev().take(6).rev() { - s.push_str(&format!("{tick:>6} {}\n", trunc(msg, 32))); +fn detection_rows(sim: &Sim) -> Vec<(String, Band)> { + let mut rows = vec![("Assurance".to_string(), sim.detection.assurance_band())]; + for obs in sim.detection.field_observers().take(DETECTION_ROWS - 1) { + rows.push((trunc(&obs.name, 18), Band::of(obs.suspicion))); + } + rows +} + +fn update_detection_rows( + sim: &Sim, + texts: &mut DetectionTextQuery, + cells: &mut DetectionCellQuery, +) { + let rows = detection_rows(sim); + for (marker, mut text) in texts.iter_mut() { + if let Some((label, band)) = rows.get(marker.row) { + text.0 = match marker.slot { + DetectionSlot::Label => label.clone(), + DetectionSlot::Band => band.name().to_string(), + }; + } else { + text.0.clear(); + } + } + for (cell, mut color) in cells.iter_mut() { + if let Some((_, band)) = rows.get(cell.row) { + let filled = cell.cell < band_fill_count(*band); + *color = BackgroundColor(if filled { + band_fill_color(*band) + } else { + Color::srgba(0.09, 0.09, 0.095, 1.0) + }); + } else { + *color = BackgroundColor(Color::srgba(0.04, 0.04, 0.045, 1.0)); } } - s } /// The people panel body: roster with selection marker, staged knowledge, @@ -2835,9 +3126,11 @@ fn scroll_sidebar( fn render_ui( game: Res, mode: Res, - mut header: SidebarHeaderQuery, - mut sidebar: Query<&mut Text, (With, Without)>, - mut overlay: Query<&mut Text, (With, Without)>, + mut sidebar_texts: SidebarTextQuery, + mut compute_bars: ComputeBarQuery, + mut detection_texts: DetectionTextQuery, + mut detection_cells: DetectionCellQuery, + mut overlay: OverlayTextQuery, ) { if !game.is_changed() && !mode.is_changed() { return; @@ -2857,12 +3150,23 @@ fn render_ui( Screen::Playing => String::new(), }; } - if let Ok(mut text) = header.single_mut() { - text.0 = sidebar_header_text(&game, mode.material); - } - if let Ok(mut text) = sidebar.single_mut() { - text.0 = sidebar_body_text(&game); + + for (kind, mut text) in sidebar_texts.iter_mut() { + text.0 = match *kind { + SidebarText::Header => sidebar_header_text(&game, mode.material), + SidebarText::Nudge => sidebar_nudge_text(&game), + SidebarText::Focus => sidebar_focus_text(&game), + SidebarText::CycleStats => sidebar_cycle_stats_text(&game.sim), + SidebarText::CycleRows => sidebar_cycle_rows_text(&game.sim), + SidebarText::Core => sidebar_core_text(&game.sim), + SidebarText::Cover => sidebar_cover_text(&game.sim), + SidebarText::NetworkMoney => sidebar_network_money_text(&game.sim), + SidebarText::Log => sidebar_log_text(&game), + SidebarText::Footer => sidebar_footer_text().to_string(), + }; } + update_compute_bar(&game.sim, &mut compute_bars); + update_detection_rows(&game.sim, &mut detection_texts, &mut detection_cells); } fn render_people_panel( diff --git a/wiki/interface/bevy-visual-floor.md b/wiki/interface/bevy-visual-floor.md index 2c0a2632..ddc1fc4f 100644 --- a/wiki/interface/bevy-visual-floor.md +++ b/wiki/interface/bevy-visual-floor.md @@ -2,16 +2,15 @@ ``` Type: spec -Status: IN PROGRESS -Status note: first frontend-only pass started 2026-07-07: amber reticle - replaces the humanoid process sprite, fog/tile palette moved to the - clinical amber/grey/crimson hierarchy, and the map now has an AI-sensorium - layer: dim learned-world tiles, amber device nodes, scan-grid overlays, and - frontend-only reach/topology traces. The first sidebar legibility patch pins - the status/nudge header and makes the dense right pane scrollable. Remaining - work before IMPLEMENTED is a fuller Bevy-native sidebar with graphical - compute/detection rows and an observed screenshot against all acceptance - criteria. +Status: IMPLEMENTED +Status note: implemented 2026-07-07 as a frontend-only visual floor: the B1 + Bevy frame uses an amber attention reticle instead of a humanoid sprite, + clinical fog/tile hierarchy, deliberate sensor-dark substrate, amber device + nodes and reach/topology traces, a material-preview toggle, and a right rail + rebuilt as a Bevy-native command surface with pinned status/nudge/footer, + priority cards, a graphical compute allocation bar, graphical detection + rows, and scrollable secondary detail. Further polish belongs in + interface/bevy-digital-real-canvas.md or narrower follow-up specs. Stage: Process / B1 frontend Constitution: "Visual identity: clinical gore", "The terminal is a first-class frontend" (parity of legibility), "Two views of one world: @@ -102,25 +101,40 @@ Within earned visibility, hierarchy is fixed: ### Sidebar: graphical command surface, not terminal dump The right sidebar may keep monospaced text for precision, but it must be a -Bevy UI surface, not copied terminal output. Required treatment: - -- A distinct dark panel with padding and section cards or dividers. -- Title/clock/run state as a header block. -- INSPECT, COMPUTE, CORE, DETECTION, DAY JOB, REACH, KEYS, and LOG grouped as - visually separate sections when present. -- Compute allocation and detection bands rendered with graphical bars or boxed - rows, with text labels and numbers adjacent (never color alone). -- The clock, run state, current representation, and one-line nudge remain - visible even when the detailed readout is scrolled. -- Dense terminal-parity details are scrollable instead of clipped; key hints - and log lines may live below the fold as long as their controls are visible - in the pinned header or current controls docs. -- Key hints pinned to the lower area and visually quieter than live telemetry. -- Log lines retain tick prefixes and have their own bounded area. - -ASCII strings can remain inside cards when they are data, but raw terminal -chrome (`-- SECTION --`, `[#===]`) should not be the primary Bevy visual -language once equivalent rectangles/bars exist. +Bevy UI surface, not copied terminal output. Scrollability is only an overflow +escape hatch; if the first screen is still a chronological text dump, the spec +has failed. The rail has an explicit read order: + +1. **Status header:** title, day/tick, run state, current representation, and + the render toggle. This is pinned. +2. **Priority nudge:** one actionable sentence answering "what should I look at + next?" This is pinned with the header. +3. **Focus:** cursor coordinate, fog state, and a small set of provenance-tagged + facts for the selected tile. +4. **Live systems:** cycles, cover process, observer model, self host, and + network/money as cards, ordered by what most often changes the next action. +5. **History and controls:** recent trace is secondary; the command vocabulary + lives in a quiet pinned footer instead of consuming the live telemetry area. + +Required treatment: + +- A distinct dark rail with padding and visually separated cards. +- No terminal chrome as primary structure: no `-- SECTION --` headers and no + ASCII allocation bars when Bevy rectangles can carry the shape. +- Compute allocation rendered as a real horizontal bar with adjacent labels, + percentages, and effects. +- Detection rendered as boxed rows or cells with observer labels and band names; + color is never the only carrier of risk. +- The clock, run state, current representation, priority nudge, and essential + controls remain visible even when secondary cards are scrolled. +- Key hints are visually quieter than live telemetry and pinned to the lower + area. +- Log lines retain tick prefixes and have their own bounded card. + +ASCII strings can remain inside cards when they are data. The Bevy sidebar is a +command surface: it should answer "where am I looking, what is starving, what +can catch me, and what key opens the next panel?" before it dumps exhaustive +state. ### Panels and overlays @@ -154,9 +168,10 @@ values are quieter than alerts. distinguishable and do not reveal facts the sim has not earned. 4. Core and powered hardware have visible amber machine-presence treatment; floors, walls, doors, and objects have a readable contrast hierarchy. -5. The sidebar uses Bevy UI chrome: grouped sections, padding, and at least the - compute allocation and detection meters represented as graphical bars/rows - with adjacent labels/numbers. +5. The sidebar uses Bevy UI chrome: grouped cards, pinned status/nudge/footer, + a readable first-screen priority order, and at least the compute allocation + and detection meters represented as graphical bars/rows with adjacent + labels/numbers. 6. People and reach panels remain fully playable and gain Bevy-native panel chrome (background, border, selected-row treatment, footer hints) without losing any terminal-parity controls. diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index 44d9da4f..c5f6e009 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -14,21 +14,21 @@ 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. -The active visual-quality target is [bevy-visual-floor.md](bevy-visual-floor.md): -make the default frame read as an intentional AI sensorium / clinical command +The implemented visual floor is [bevy-visual-floor.md](bevy-visual-floor.md): +the default frame reads as an intentional AI sensorium / clinical command surface rather than a debug map plus terminal dump, without adding Bevy-only facts. The 2.5D/near-3D direction is scoped separately in [bevy-digital-real-canvas.md](bevy-digital-real-canvas.md): digital and real share one canvas/framing, with digital as the AI's model/signal dialect and -real as the camera/material dialect. The first sensorium pass replaces the -stale humanoid process sprite with an amber cursor reticle, removes -purple/teal placeholder color from B1 runtime rendering, gives unknown space -deliberate sensor-dark texture, dims physical tile art into a learned-world -model, overlays earned device nodes and reach/topology traces, and adds minimal -sidebar/modal chrome. The first material pass is available behind F3: a -frontend-only HD-2D staging preview using the same sim facts, fog, anchors, UI, -and panels; the fuller graphical sidebar and final real/digital polish remain -in progress. +real as the camera/material dialect. The sensorium pass replaces the stale +humanoid process sprite with an amber cursor reticle, removes purple/teal +placeholder color from B1 runtime rendering, gives unknown space deliberate +sensor-dark texture, dims physical tile art into a learned-world model, +overlays earned device nodes and reach/topology traces, and turns the right +rail into graphical command cards with pinned status/nudge/footer. The first +material pass is available behind F3: a frontend-only HD-2D staging preview +using the same sim facts, fog, anchors, UI, and panels; final real/digital +polish remains in progress. ## What it renders @@ -41,24 +41,21 @@ in progress. - **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** — a fixed right-hand operations pane. The title/clock/run state, - render-mode label, contextual nudge, and scroll hint stay pinned at the top; - the detailed terminal-parity body scrolls independently with mouse wheel over - the pane, `PageUp`/`PageDown`, and `Home`/`End`. The body keeps the terminal - sidebar's sections, in the same order: INSPECT/FOCUS (cursor coordinate, fog - state, provenance-tagged facts), COMPUTE/CYCLE ROUTER (effective, efficiency, - machines/slush, the stacked four-channel allocation bar with per-channel - percent and effect, overhead, social pool), CORE/SELF HOST (host, sync - freshness, fallbacks), DETECTION/OBSERVER MODEL (Assurance plus field - observers, four-cell band meter with the band name printed beside it), DAY - JOB/COVER PROCESS (trust, attention, strikes when nonzero; the active job - with ticks to deadline, its band against the average delivered rate and the - projected outcome, the target with the delivered feed rate, and the - attended/unattended state — attended with the rate bonus while the cursor is - on the host rack, otherwise the standing policy the job runs at), device - graph, finance, key hints, and the log — every log line prefixed with the - tick it happened on. The pane is still a compact monospaced command surface; - fuller graphical cards remain part of the visual-floor work. +- **Sidebar** — a fixed right-hand command rail. The title/clock/run state, + render-mode label, priority nudge, and scroll hint stay pinned at the top; + a quiet controls footer is pinned at the bottom. The middle rail scrolls + independently with mouse wheel over the pane, `PageUp`/`PageDown`, and + `Home`/`End`, but scrolling is for secondary detail rather than basic + comprehension. The first-screen cards are ordered by player read path: + FOCUS (cursor coordinate, fog state, provenance-tagged facts), CYCLES + (usable/effective compute, overhead, machine/slush summary, graphical + four-channel allocation bar, per-channel percentages/effects, social pool), + COVER PROCESS (trust/attention and current job outcome projection), OBSERVER + MODEL (Assurance plus field observers as labeled four-cell graphical risk + rows), SELF HOST (host, sync freshness, fallbacks), NETWORK + MONEY (device + graph/feed and finance summary), and RECENT TRACE (tick-prefixed log lines). + Exhaustive controls still live in the modal panels and README; the rail is + the at-a-glance operations surface. - **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 @@ -70,11 +67,11 @@ in progress. sim state; the flat sensorium remains the default. - **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. +Text remains ASCII-only because Bevy's embedded default font carries a limited +glyph set, but the sidebar's key meters are Bevy UI rectangles/cells rather +than ASCII art. One meaning per color still holds: amber for machine presence +(title, overlays, active allocations), bone/chrome for data, crimson for danger, +and no information carried by color alone. ## Controls diff --git a/wiki/log/2026-07-07-bevy-sidebar-command-surface.md b/wiki/log/2026-07-07-bevy-sidebar-command-surface.md new file mode 100644 index 00000000..a51cd678 --- /dev/null +++ b/wiki/log/2026-07-07-bevy-sidebar-command-surface.md @@ -0,0 +1,48 @@ +# Bevy sidebar command surface + +``` +Type: log +``` + +## Intent + +Cameron reviewed the first scrollable-sidebar pass and said it was still hard +to read. The follow-up goal was not more text capacity; it was a more thoughtful +right rail that reads like a Bevy command surface while preserving the same +terminal-parity facts. + +## Changed + +- Widened the Bevy right rail to give the operations surface more room. +- Replaced the single scrollable monospaced sidebar dump with targeted Bevy UI + cards: pinned status/nudge header, scrollable middle cards, and a pinned + low-contrast controls footer. +- Added a first-screen read order: Focus, Cycles, Cover Process, Observer Model, + then secondary cards for Self Host, Network + Money, and Recent Trace below + the fold. +- Rendered compute allocation as a real stacked Bevy UI bar with per-channel + labels, percentages, and effects beside it. +- Rendered observer/detection risk as labeled rows with four graphical cells and + a printed band name, so risk is never color alone. +- Kept all old sidebar information available: cursor facts/provenance, compute + efficiency/overhead/machines/slush, social pool, core host/sync/fallbacks, + day job projection, observer model, device/finance summary, key hints, and + tick-prefixed log lines. +- Updated `bevy-visual-floor.md` from IN PROGRESS to IMPLEMENTED now that the + default Bevy frame has the sensorium map, non-humanoid cursor, frontend-only + material preview, and graphical sidebar surface required by the spec. + +## Defense + +This is frontend-only presentation work. The sim, save format, knowledge gates, +and terminal/agent behavior are unchanged. The right rail is still a compact +monospaced command surface where exact values matter, but the structure now +comes from Bevy cards, pinned affordances, and graphical bars/cells rather than +terminal separators and ASCII meters. + +## Checks + +- `cargo fmt` +- `cargo check --features bevy_ui --bin misaligned-bevy` +- `MISALIGNED_SHOT=flat MISALIGNED_SHOT_PATH=/tmp/misaligned-sidebar-thoughtful-4.png cargo run --features bevy_ui --bin misaligned-bevy` +- `./tools/check.sh` diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index c77392b8..3cff0439 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,26 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-07 - Bevy sidebar becomes a command surface + +- Intent: respond to Cameron's screenshot review that the scrollable right pane + was still too hard to read; revise the spec and implementation toward a + thoughtful graphical sidebar rather than a bigger terminal dump. +- Changed: widened the Bevy right rail; rebuilt it as a pinned status/nudge + header, scrollable card stack, and pinned quiet controls footer. Focus, + cycles, cover, observer model, self host, network/money, and recent trace are + separate cards. Compute allocation is a real stacked Bevy UI bar, and + detection is labeled four-cell graphical rows with band names. +- Design/spec impact: no sim/save changes; `bevy-visual-floor.md` now marks the + visual floor IMPLEMENTED, and `bevy.md`/README document the carded right rail. +- Defense: terminal-parity facts remain available, but the first screen answers + the immediate operational questions before secondary details scroll below the + fold. +- Checks: `cargo fmt`; `cargo check --features bevy_ui --bin misaligned-bevy`; + observed `MISALIGNED_SHOT=flat` screenshot at + `/tmp/misaligned-sidebar-thoughtful-4.png`; `./tools/check.sh`. +- Log: wiki/log/2026-07-07-bevy-sidebar-command-surface.md. + ## 2026-07-07 - Bevy right pane becomes scrollable - Intent: respond to Cameron's play/readability report that the right pane was diff --git a/wiki/process/specs.md b/wiki/process/specs.md index c85b24bf..b83870ca 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -80,6 +80,6 @@ acceptance criteria are stage-scoped; do not start B2/B3 work as B1. | [meta.md](meta.md) | The spec system itself | READY | | [wiki.md](wiki.md) | The wiki: one documentation tree, agent-navigable, renderable | IN PROGRESS | | [interface/terminal.md](../interface/terminal.md) | Terminal frontend: look, feel, act (the sterile style guide) | IMPLEMENTED | -| [interface/bevy-visual-floor.md](../interface/bevy-visual-floor.md) | Bevy visual floor: framing, cursor, fog treatment, sidebar chrome, panels | IN PROGRESS | +| [interface/bevy-visual-floor.md](../interface/bevy-visual-floor.md) | Bevy visual floor: framing, cursor, fog treatment, sidebar chrome, panels | IMPLEMENTED | | [interface/bevy-digital-real-canvas.md](../interface/bevy-digital-real-canvas.md) | Bevy digital/real canvas: shared 2.5D visual language for both representations | IN PROGRESS | | [interface/agent-play.md](../interface/agent-play.md) | Agent mode: command-clocked line-protocol drive of the terminal frontend | IMPLEMENTED |