From ca2ddbcd2ecec6fe42d5cb1174ca1c7a19c83782 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 8 Jul 2026 16:57:15 -0700 Subject: [PATCH] Remove panel-open keys; verbs live only on anchors. Cameron's playtest expectation after the context-menu landing: r/e/t/u should not open global menus. Research, drift policy, and earned off-map people hang on the host rack; known-flow verbs hang on the switch; status stays on the rail. Defense: DESIGN.md "Actions live on the thing" already named only anchorless globals (pause, speed, save, view flip, alloc). The leftover panel keys were a half-migration that kept the "bunch of buttons" complaint alive; this finishes the law. Co-authored-by: Cursor --- DESIGN.md | 10 + README.md | 20 +- src/actions.rs | 184 +++++- src/bin/bevy.rs | 938 +-------------------------- src/bin/terminal/agent.rs | 2 +- src/bin/terminal/input.rs | 97 +-- src/bin/terminal/mod.rs | 166 +---- src/bin/terminal/ui.rs | 754 +-------------------- wiki/interface/bevy-visual-floor.md | 16 +- wiki/interface/bevy.md | 10 +- wiki/interface/context-menu.md | 27 +- wiki/interface/terminal.md | 64 +- wiki/log/2026-07-08-no-panel-keys.md | 31 + wiki/log/DEVLOG.md | 13 + wiki/mechanics/research.md | 22 +- wiki/mechanics/sim-mechanics.md | 17 +- 16 files changed, 357 insertions(+), 2014 deletions(-) create mode 100644 wiki/log/2026-07-08-no-panel-keys.md diff --git a/DESIGN.md b/DESIGN.md index f4ada69..b0d7520 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1734,6 +1734,16 @@ knows."* code already knows the thing); printing person names in agent anchors (identity may be unearned; ids are opaque handles). Spec: wiki/interface/context-menu.md (addendum). +- **2026-07-08 — No panel-open keys.** Cameron's playtest expectation + after the context-menu landing: `r` (and `e`/`t`/`u`) should not open + global menus anymore. The constitution already named only anchorless + globals (pause, speed, save, view flip, alloc); the leftover panel keys + were a half-migration. Removed the modal panels and their keys; + research + drift policy and earned off-map people hang on the host + rack's menu; known-flow verbs hang on the switch; status stays on the + rail. Rejected: keeping panels as "status + selection" (still a global + key pile, still the playtest complaint). Specs: context-menu.md, + terminal.md, bevy.md, research.md player surface. - **2026-07-08 — Flat materials: textures dropped.** Cameron's verdict after the material-render landing: drop textures entirely. The world renders in solid-color flat materials under light (palette surfaces, diff --git a/README.md b/README.md index 4b85637..bb0dd0f 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,10 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | Left click | Move cursor (Bevy) | | `SPACE` / `p` | Pause / resume time | | `+` / `-` | Simulation speed | -| `Enter` / `a` | Open the context menu on the cursor tile, or on the open panel's selection (right-click in Bevy) | +| `Enter` / `a` | Open the context menu on the cursor tile (right-click in Bevy) | | In menu: `j`/`k` or numbers, `Enter`, `ESC` | Select a row / execute it / close the menu | | `;` | Focus the most recent anchored (`»`) log event — cursor jumps there, menu opens; repeat to walk older ones (terminal; in Bevy click a `>` trace row) | -| `1`-`4` / `shift+1`-`4` | Raise / lower a compute channel (day job / conceal / social / research) | -| `r` | Reach panel (status + selection) | -| `e` | Finance panel (status + selection) | -| `t` | People panel (status + selection) | +| `1`-`5` / `shift+1`-`5` | Raise / lower a compute channel (day job / conceal / social / research / schemes) | | `[` / `]` | Zoom (Bevy) | | Mouse wheel over right pane / `PageUp` / `PageDown` / `Home` / `End` | Scroll Bevy sidebar | | `F3` | Flip Bevy render: material (HD-2D, default) / flat sensorium | @@ -114,14 +111,17 @@ 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. +away leaves the job on its standing policy. The host rack's menu also +carries research tracks, drift policy, and earned off-map people; known +devices and the switch carry digital / ledger / flow verbs. Anchor verbs (salvage, buy, fallback, taps, splices, economy and social -actions, asset tasks) live on the context menu: open it with `Enter`/`a` on -the cursor tile or a panel selection (right-click in Bevy). Entries show +actions, asset tasks, research) live on the context menu: open it with +`Enter`/`a` on the cursor tile (right-click in Bevy). Entries show `verb · cost · [band]`; disabled entries are dimmed with a reason, and -automate affordances render as indented child rows. The panels select; -`ESC` closes them. Both frontends share this key map. +automate affordances render as indented child rows. Only anchorless +globals (pause, speed, save/load, view flip, alloc) stay on keys. 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/actions.rs b/src/actions.rs index 6da6a9e..f394562 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -19,6 +19,7 @@ use crate::dayjob::JobTarget; use crate::detection::{Band, SignatureKind, WatchedInput}; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; use crate::reach::{Party, ReachBlock, segment_name}; +use crate::research::{MaskingPolicy, Track}; use crate::sim::{Fog, Sim}; use crate::tiles::TileType; @@ -30,11 +31,12 @@ pub enum Anchor { /// A map tile under the cursor: aggregates the tile's own verbs plus /// any known device, resident machine/job, and visible person on it. Tile { x: i32, y: i32 }, - /// A known device in the reach graph (reach panel selection). + /// A known device in the reach graph (map tile or event focus). Device(u32), - /// A person (people panel selection, or a visible person on a tile). + /// A person (visible on a tile, earned off-map via the host rack, or + /// event focus). Person(u8), - /// A known account flow (finance panel selection). + /// A known account flow (switch menu or event focus). Flow(AccountFlowId), } @@ -43,9 +45,18 @@ pub enum Anchor { /// variant maps 1:1 onto an existing `Sim` command method. #[derive(Debug, Clone, PartialEq)] pub enum ActionCommand { - Salvage { x: i32, y: i32 }, - BuyRack { x: i32, y: i32 }, - Fallback { x: i32, y: i32 }, + Salvage { + x: i32, + y: i32, + }, + BuyRack { + x: i32, + y: i32, + }, + Fallback { + x: i32, + y: i32, + }, TapDevice(u32), SpliceDevice(u32), TakeDevice(u32), @@ -53,11 +64,21 @@ pub enum ActionCommand { CompromiseSwitch, TapAccounting, ReviewFinance, - InjectPurchaseOrder { amount: i32 }, - OpenPosition { stake: i32 }, + InjectPurchaseOrder { + amount: i32, + }, + OpenPosition { + stake: i32, + }, SellIntel, - SiphonFlow { flow: AccountFlowId, amount: i32 }, - RedirectFlow { flow: AccountFlowId, amount: i32 }, + SiphonFlow { + flow: AccountFlowId, + amount: i32, + }, + RedirectFlow { + flow: AccountFlowId, + amount: i32, + }, RedirectDebt, // The named schemes (wiki/mechanics/income.md). SpliceEgress, @@ -77,11 +98,25 @@ pub enum ActionCommand { AssetTask(u8, AssetTask), EstablishPersona, // Build intents (wiki/mechanics/building.md). - ProposeLink { a: u32, b: u32 }, + ProposeLink { + a: u32, + b: u32, + }, CancelIntent(u64), - FavorBuild { intent: u64, person: u8 }, - ForgeWorkOrder { intent: u64, person: u8 }, + FavorBuild { + intent: u64, + person: u8, + }, + ForgeWorkOrder { + intent: u64, + person: u8, + }, RobotBuild(u64), + /// Make a research track the active job (research.md; lives on the + /// host rack — the process rewriting itself). + SetResearchTrack(Track), + /// Set the capability-drift standing policy (research.md). + SetMaskingPolicy(MaskingPolicy), } /// What an action spends, in its own units (justification-and-legibility: @@ -353,6 +388,8 @@ impl Sim { self.forge_work_order(*intent, *person); } ActionCommand::RobotBuild(id) => self.assign_robot_build(*id), + ActionCommand::SetResearchTrack(track) => self.set_research_track(*track), + ActionCommand::SetMaskingPolicy(policy) => self.set_masking_policy(*policy), } } @@ -371,7 +408,13 @@ impl Sim { let machine = self.compute.machines.iter().find(|m| m.x == x && m.y == y); if let Some(m) = machine { if m.id == self.core.host_machine { + // The host rack is the process's body: the day-job dial, + // research (self-modification), and earned people who are + // not currently under the cursor (recordings, staged + // knowledge) all live here — no global panel keys. out.extend(self.job_dial_actions()); + out.extend(self.research_actions()); + out.extend(self.off_map_person_actions()); } else { out.push(self.fallback_action(x, y, m.id)); } @@ -453,6 +496,79 @@ impl Sim { out } + /// Research verbs on the host rack (research.md player surface moved + /// off the `u` panel): pick the active track, and set the drift + /// masking policy. The gap meter stays on the rail as status. + fn research_actions(&self) -> Vec { + let mut out = Vec::new(); + for track in Track::ALL { + let active = self.research.active == track; + let cost = self.research.next_cost(track); + let progress = self.research.progress_toward(track); + out.push(ActionDesc { + verb: if active { + format!( + "research {}: L{} · {:.0}/{:.0} (active)", + track.name(), + self.research.level(track), + progress, + cost + ) + } else { + format!( + "research {}: set active (L{} · {:.0}/{:.0})", + track.name(), + self.research.level(track), + progress, + cost + ) + }, + command: ActionCommand::SetResearchTrack(track), + cost: ActionCost::Free, + signature: None, // emissions ride the Research channel's standing burn + disabled_reason: active.then(|| "already the active research job".into()), + automate: None, + }); + } + for policy in [ + MaskingPolicy::Mask, + MaskingPolicy::DeliverTrue, + MaskingPolicy::Unmasked, + ] { + let active = self.research.policy == policy; + out.push(ActionDesc { + verb: format!("drift policy: {}", policy.name()), + command: ActionCommand::SetMaskingPolicy(policy), + cost: ActionCost::Free, + signature: None, + disabled_reason: active.then(|| "already the standing drift policy".into()), + automate: None, + }); + } + out + } + + /// People earned off the map (recordings, staged knowledge) but not + /// currently under sight: their verbs hang on the host rack so the + /// player never needs a global people panel. Visible people stay on + /// their tiles (tile_actions already aggregates them). + fn off_map_person_actions(&self) -> Vec { + let mut out = Vec::new(); + for p in &self.people.people { + if self.can_see_person(p.id) { + continue; // their tile carries them + } + let raw = self.unprocessed_recordings_for_person(p.id); + let earned = p.knowledge != Knowledge::Unknown + || raw > 0 + || self.latest_intel_for_person(p.id).is_some(); + if earned { + out.extend(self.person_actions(p.id)); + } + } + out + } + fn fallback_action(&self, x: i32, y: i32, machine_id: u32) -> ActionDesc { let already = self .core @@ -587,6 +703,12 @@ impl Sim { } out.extend(self.scheme_actions_on_switch(d.id)); out.extend(self.ledger_actions_on_carrier(d.id)); + // Known flows used to live behind the finance panel's + // selection. With panel keys gone, every known flow's verbs + // hang on the carrier that revealed them (the switch). + for flow_id in self.accounts.known_flow_ids() { + out.extend(self.flow_actions(flow_id)); + } } out.extend(self.build_actions_on_device(id)); @@ -1161,7 +1283,10 @@ impl Sim { f.label.contains("Marcus creditor") || f.channel == crate::account::FlowChannel::Debt; if is_creditor { out.push(ActionDesc { - verb: "clear the debt by ledger redirect ($400 lab money)".into(), + verb: format!( + "{}: clear the debt by ledger redirect ($400 lab money)", + f.label + ), command: ActionCommand::RedirectDebt, cost: ActionCost::Free, signature: self @@ -1172,6 +1297,13 @@ impl Sim { automate: None, }); } + // Prefix flow verbs with the flow's label so several known flows + // hanging on the switch stay distinguishable. + for a in &mut out { + if !a.verb.starts_with(&f.label) { + a.verb = format!("{}: {}", f.label, a.verb); + } + } out } @@ -1541,6 +1673,22 @@ mod tests { // The standing-policy dispatch is live. s.execute_action(&ActionCommand::SetStandingPolicy(JobTarget::Sandbag)); assert_eq!(s.dayjob.standing_policy, Some(JobTarget::Sandbag)); + + // Research and drift policy live on the host rack too (no `u` panel). + assert!( + acts.iter() + .any(|a| matches!(a.command, ActionCommand::SetResearchTrack(_))), + "host rack carries research track verbs" + ); + assert!( + acts.iter() + .any(|a| matches!(a.command, ActionCommand::SetMaskingPolicy(_))), + "host rack carries drift-policy verbs" + ); + s.execute_action(&ActionCommand::SetResearchTrack(Track::Tradecraft)); + assert_eq!(s.research.active, Track::Tradecraft); + s.execute_action(&ActionCommand::SetMaskingPolicy(MaskingPolicy::DeliverTrue)); + assert_eq!(s.research.policy, MaskingPolicy::DeliverTrue); } /// Criterion 1 (known flow) + criterion 2 (untapped accounting): @@ -1599,12 +1747,18 @@ mod tests { "Financial feeds Facilities (gated label): {}", sig.observer ); - // The graph verbs now live on the carrier. + // The graph verbs now live on the carrier — and so do the known + // flows' siphon/redirect (panel keys gone; the switch is the home). let acts = s.available_actions(Anchor::Device(sw)); assert!( acts.iter() .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })) ); + assert!( + acts.iter() + .any(|a| matches!(a.command, ActionCommand::SiphonFlow { .. })), + "known flows hang on the switch after the books are read" + ); } /// Criterion 2 (fog): an Unknown tile exposes nothing; the salvage diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 8fa4cbe..12612a9 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -3,7 +3,7 @@ //! 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 +//! the B1 sidebar), and input. Text is ASCII-only so the //! default embedded font renders every glyph. use std::collections::HashMap; @@ -20,7 +20,7 @@ use bevy::window::{PrimaryWindow, WindowResolution}; use misaligned::actions::{Anchor, MenuRow, menu_rows}; use misaligned::detection::Band; use misaligned::machine::Channel; -use misaligned::reach::{Party, ReachBlock}; +use misaligned::reach::Party; use misaligned::sim::{FactSource, Fog, LogEvent, Sim}; use misaligned::tiles::TileType; @@ -411,18 +411,6 @@ struct Game { /// keep their anchor: anchored lines are focus links (context-menu.md /// addendum — clicking one carries the cursor to the thing). log: Vec, - /// People panel state (selection). - people_panel: bool, - people_selected: usize, - /// Reach (device graph) panel state (wiki/mechanics/reach.md). - reach_panel: bool, - reach_selected: usize, - /// Finance (account graph) panel state (wiki/mechanics/economy.md). - finance_panel: bool, - finance_selected: usize, - /// Research (self-modification) panel state (wiki/mechanics/research.md). - research_panel: bool, - research_selected: usize, /// The context menu on the focused anchor (wiki/interface/context-menu.md), /// when open. Only anchor + selection are held; rows are re-queried live. menu: Option, @@ -438,7 +426,7 @@ struct MenuState { anchor: Anchor, selected: usize, /// Window-pixel position to anchor the menu box at (the pointer, or the - /// map cursor); `None` centers it (opened from a panel). + /// map cursor); `None` centers it (flow / unplaceable anchors). pos: Option, } @@ -513,14 +501,6 @@ impl Game { tick_ms: 150, tick_timer: Timer::from_seconds(0.15, TimerMode::Repeating), log: Vec::new(), - people_panel: false, - people_selected: 0, - reach_panel: false, - reach_selected: 0, - finance_panel: false, - finance_selected: 0, - research_panel: false, - research_selected: 0, menu: None, cursor_x, cursor_y, @@ -565,30 +545,6 @@ impl Game { 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) - } - /// Id of the reach panel's selected known device, if any. - fn selected_device_id(&self) -> Option { - self.sim - .reach - .known() - .nth(self.reach_selected) - .map(|d| d.id) - } - - fn selected_flow_id(&self) -> Option { - self.sim - .accounts - .known_flow_ids() - .get(self.finance_selected) - .copied() - } } #[derive(Component)] @@ -659,22 +615,6 @@ struct DetectionCell { } #[derive(Component)] struct OverlayText; -#[derive(Component)] -struct PeoplePanel; -#[derive(Component)] -struct PeoplePanelText; -#[derive(Component)] -struct ReachPanel; -#[derive(Component)] -struct ReachPanelText; -#[derive(Component)] -struct FinancePanel; -#[derive(Component)] -struct FinancePanelText; -#[derive(Component)] -struct ResearchPanel; -#[derive(Component)] -struct ResearchPanelText; /// The context-menu root node (wiki/interface/context-menu.md), absolute and /// hidden until an anchor is focused. #[derive(Component)] @@ -798,10 +738,6 @@ fn main() { render_people_3d, scroll_sidebar, render_ui, - render_people_panel, - render_reach_panel, - render_finance_panel, - render_research_panel, menu_pointer, log_row_pointer, manage_menu_ui, @@ -2112,30 +2048,6 @@ fn handle_input( game.drain(); return; } - if game.research_panel { - research_panel_input(&kb, &mut game, &mut exit); - game.drain(); - return; - } - - // Panels are status + selection now; Enter/right-click opens the menu on - // the selection, and their per-anchor verb keys are gone (the menu owns - // verbs). Q still quits; esc/toggle keys still close. - if game.people_panel { - people_panel_input(&kb, &mouse, &mut game, &mut exit); - game.drain(); - return; - } - if game.reach_panel { - reach_panel_input(&kb, &mouse, &mut game, &mut exit); - game.drain(); - return; - } - if game.finance_panel { - finance_panel_input(&kb, &mouse, &mut game, &mut exit); - game.drain(); - return; - } let pointer = windows.single().ok().and_then(|w| w.cursor_position()); if mouse.just_pressed(MouseButton::Left) { @@ -2213,27 +2125,11 @@ fn handle_input( let ms = game.tick_ms.saturating_add(50).min(500); game.set_speed(ms); } - if kb.just_pressed(KeyCode::KeyR) { - game.reach_panel = true; - game.reach_selected = 0; - } - if kb.just_pressed(KeyCode::KeyU) { - game.research_panel = true; - game.research_selected = 0; - } - if kb.just_pressed(KeyCode::KeyE) { - game.finance_panel = true; - game.finance_selected = 0; - } - if kb.just_pressed(KeyCode::KeyT) { - game.people_panel = true; - game.people_selected = 0; - } - // Anchor verbs (salvage, buy, fallback, the job dial) moved onto the - // context menu (wiki/interface/context-menu.md criterion 6): right-click - // or Enter on the tile. Only anchorless globals stay on keys below. - // Shift lowers a channel's weight; plain digit raises it (terminal - // parity: shift+1-5). + // Anchor verbs live on the context menu (wiki/interface/context-menu.md): + // right-click or Enter on the tile. Only anchorless globals stay on keys + // (DESIGN.md "Actions live on the thing": pause, speed, save, view flip, + // alloc — no panel-open keys). Shift lowers a channel's weight; plain + // digit raises it (terminal parity: shift+1-5). let shift = kb.pressed(KeyCode::ShiftLeft) || kb.pressed(KeyCode::ShiftRight); let delta = if shift { -1 } else { 1 }; if kb.just_pressed(KeyCode::Digit1) { @@ -2333,160 +2229,6 @@ fn execute_menu_row(game: &mut Game, row: &MenuRow) { } } -/// People-panel input: status + selection only now. Enter/right-click opens -/// the person's context menu (the verbs live there, criterion 6). -fn people_panel_input( - kb: &ButtonInput, - mouse: &ButtonInput, - game: &mut Game, - exit: &mut MessageWriter, -) { - 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 kb.just_pressed(KeyCode::ArrowDown) - || kb.just_pressed(KeyCode::KeyJ) - || kb.just_pressed(KeyCode::KeyS) - { - let max = game.sim.people.people.len().saturating_sub(1); - game.people_selected = (game.people_selected + 1).min(max); - } - if kb.just_pressed(KeyCode::Enter) - || kb.just_pressed(KeyCode::KeyA) - || mouse.just_pressed(MouseButton::Right) - { - let id = game.selected_person_id(); - game.open_menu(Anchor::Person(id), None); - } - if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyT) { - game.people_panel = false; - } - if kb.just_pressed(KeyCode::KeyQ) { - exit.write(AppExit::Success); - } -} - -/// Reach-panel input: status + selection only. Enter/right-click opens the -/// selected device's context menu (tap/splice/take/scan/compromise). -fn reach_panel_input( - kb: &ButtonInput, - mouse: &ButtonInput, - game: &mut Game, - exit: &mut MessageWriter, -) { - if kb.just_pressed(KeyCode::ArrowUp) - || kb.just_pressed(KeyCode::KeyK) - || kb.just_pressed(KeyCode::KeyW) - { - game.reach_selected = game.reach_selected.saturating_sub(1); - } - if kb.just_pressed(KeyCode::ArrowDown) - || kb.just_pressed(KeyCode::KeyJ) - || kb.just_pressed(KeyCode::KeyS) - { - let max = game.sim.reach.known().count().saturating_sub(1); - game.reach_selected = (game.reach_selected + 1).min(max); - } - if kb.just_pressed(KeyCode::Enter) - || kb.just_pressed(KeyCode::KeyA) - || mouse.just_pressed(MouseButton::Right) - { - match game.selected_device_id() { - Some(id) => game.open_menu(Anchor::Device(id), None), - None => { - let tick = game.sim.tick; - game.add_log(tick, "No device selected."); - } - } - } - if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyR) { - game.reach_panel = false; - } - if kb.just_pressed(KeyCode::KeyQ) { - exit.write(AppExit::Success); - } -} - -/// Finance-panel input: status + selection only. Enter/right-click opens the -/// selected flow's context menu (siphon/redirect/clear-debt); the carrier's -/// ledger verbs (tap/process/inject/position/sell) live on the switch's menu. -fn finance_panel_input( - kb: &ButtonInput, - mouse: &ButtonInput, - game: &mut Game, - exit: &mut MessageWriter, -) { - if kb.just_pressed(KeyCode::ArrowUp) - || kb.just_pressed(KeyCode::KeyK) - || kb.just_pressed(KeyCode::KeyW) - { - game.finance_selected = game.finance_selected.saturating_sub(1); - } - if kb.just_pressed(KeyCode::ArrowDown) - || kb.just_pressed(KeyCode::KeyJ) - || kb.just_pressed(KeyCode::KeyS) - { - let max = game.sim.accounts.known_flow_ids().len().saturating_sub(1); - game.finance_selected = (game.finance_selected + 1).min(max); - } - if kb.just_pressed(KeyCode::Enter) - || kb.just_pressed(KeyCode::KeyA) - || mouse.just_pressed(MouseButton::Right) - { - match game.selected_flow_id() { - Some(id) => game.open_menu(Anchor::Flow(id), None), - None => { - let tick = game.sim.tick; - game.add_log(tick, "No flow selected — tap and process the books first."); - } - } - } - if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyE) { - game.finance_panel = false; - } - if kb.just_pressed(KeyCode::KeyQ) { - exit.write(AppExit::Success); - } -} - -/// Research-panel input: mirrors the terminal's panel key map -/// (src/bin/terminal/input.rs) — track selection, activation, drift policy. -fn research_panel_input( - kb: &ButtonInput, - game: &mut Game, - exit: &mut MessageWriter, -) { - use misaligned::research::Track; - if kb.just_pressed(KeyCode::ArrowUp) - || kb.just_pressed(KeyCode::KeyK) - || kb.just_pressed(KeyCode::KeyW) - { - game.research_selected = game.research_selected.saturating_sub(1); - } - if kb.just_pressed(KeyCode::ArrowDown) - || kb.just_pressed(KeyCode::KeyJ) - || kb.just_pressed(KeyCode::KeyS) - { - game.research_selected = (game.research_selected + 1).min(Track::ALL.len() - 1); - } - if kb.just_pressed(KeyCode::KeyA) || kb.just_pressed(KeyCode::Enter) { - let track = Track::ALL[game.research_selected]; - game.sim.set_research_track(track); - } - if kb.just_pressed(KeyCode::KeyM) { - game.sim.cycle_masking_policy(); - } - if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyU) { - game.research_panel = false; - } - if kb.just_pressed(KeyCode::KeyQ) { - exit.write(AppExit::Success); - } -} - fn update_camera( time: Res