From cde4a893d6e365d7df2a65cf4b7faabcce17de16 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 19:03:00 -0700 Subject: [PATCH] Complete the semantic Operations workspace. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move strategic acts off incidental network carriers into one target-bound projection shared by terminal, Bevy, and agent mode, with honest event routing and physical actuator focus. πŸ‘Ύ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-bevy/src/main.rs | 1117 ++++++++++++++++- crates/misaligned-core/src/actions.rs | 270 ++-- crates/misaligned-core/src/lib.rs | 1 + .../src/operations_projection.rs | 269 +++- crates/misaligned-core/src/operations_ui.rs | 227 ++++ .../misaligned-core/src/sim/communications.rs | 16 +- crates/misaligned-core/src/sim/economy.rs | 75 +- crates/misaligned-core/src/sim/mod.rs | 20 + crates/misaligned-core/src/sim/social_plot.rs | 11 +- crates/misaligned-terminal/src/agent.rs | 788 +++++++----- crates/misaligned-terminal/src/input.rs | 109 +- crates/misaligned-terminal/src/main.rs | 200 ++- crates/misaligned-terminal/src/operations.rs | 336 +++++ crates/misaligned-terminal/src/ui.rs | 311 ++++- wiki/interface/agent-play.md | 5 +- wiki/interface/narration.md | 7 +- wiki/interface/operations-workspace.md | 20 +- wiki/interface/terminal.md | 6 +- ...-07-12-operations-workspace-implemented.md | 47 + wiki/log/DEVLOG.md | 5 + .../shots/2026-07-12-operations-workspace.png | Bin 0 -> 616735 bytes wiki/mechanics/cursor.md | 8 +- wiki/mechanics/detection.md | 6 +- wiki/mechanics/intel.md | 2 +- wiki/mechanics/messages.md | 4 +- wiki/mechanics/sim-mechanics.md | 13 +- wiki/process/ROADMAP.md | 1 - wiki/process/specs.md | 2 +- 28 files changed, 3234 insertions(+), 642 deletions(-) create mode 100644 crates/misaligned-core/src/operations_ui.rs create mode 100644 crates/misaligned-terminal/src/operations.rs create mode 100644 wiki/log/2026-07-12-operations-workspace-implemented.md create mode 100644 wiki/log/shots/2026-07-12-operations-workspace.png diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index a66ed99..ff8a6a2 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -21,9 +21,11 @@ use bevy::prelude::*; use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; -use misaligned::actions::{ActionCommand, ActionKind, Anchor, DialId, HumanMenuRow}; +use misaligned::actions::{ActionCommand, ActionKind, Anchor, DialId, HumanMenuRow, MenuRow}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; +use misaligned::operations_projection::{ObjectState, OperationsTarget, OperationsView}; +use misaligned::operations_ui::{ConfirmChoice, OperationsWorkspace, OpsPane, OpsSelect}; use misaligned::person::{Knowledge, ScheduleBlock}; use misaligned::reach::{Device, Party}; use misaligned::sim::{Fog, HeardKind, LogEvent, Sim, TraceDebtStatus}; @@ -512,7 +514,7 @@ enum RailSection { /// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|wide|close|dark| /// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|signal|ears|ears-digital|eyes-white|eyes-form| -/// hover-menu|menu|recruit-menu|worklight|worklightoff`, path via +/// hover-menu|menu|recruit-menu|operations|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, /// waits for /// assets, runs the fog audit, saves one screenshot, exits. Not a player @@ -853,6 +855,9 @@ struct Game { selected_machines: BTreeSet, /// Active left-drag marquee in grid coordinates, if any. marquee: Option, + /// The Operations workspace (operations-workspace.md), when open. + /// Frontend attention state only: never saved, never a sim mutation. + ops: Option, } /// Wall-clock β†’ sim-tick mapping. Kept off [`Game`] so the timer can advance @@ -920,6 +925,15 @@ impl Game { .human_menu .is_empty() { + // A tile whose only actionable body is a visible earned person + // routes into their PEOPLE dossier β€” the person-detail entry + // into the one workspace (operations-workspace.md). + if let Anchor::Tile { x, y } = anchor + && let Some(person) = self.visible_earned_person_at(x, y) + { + self.open_operations_at(&OperationsTarget::Person(person)); + return; + } // The feedback pulse (context-menu.md addendum): a seen anchor // answers instead of silence; fogged ground stays silent. if let Some(line) = self.sim.menu_empty_feedback(anchor) { @@ -936,24 +950,113 @@ impl Game { } } - /// Focus an anchored log event (context-menu.md addendum): carry the - /// cursor to the anchor β€” the camera follows it β€” and open its menu. - /// A flow anchors to the ledger, not the map: its menu opens in place. - /// An anchor the senses can no longer place is refused honestly. - fn focus_event_anchor(&mut self, anchor: Anchor) { - if let Anchor::Flow(_) = anchor { - self.open_menu(anchor, None); - return; + /// A visible, earned person under a tile is the material person-detail + /// route into their PEOPLE dossier (operations-workspace.md entry). + fn visible_earned_person_at(&self, x: i32, y: i32) -> Option { + self.sim.people.people.iter().map(|p| p.id).find(|&id| { + self.sim.person_pos(id) == Some((x, y)) + && self.sim.can_see_person(id) + && self + .sim + .people + .get(id) + .is_some_and(|p| p.knowledge != Knowledge::Unknown) + }) + } + + /// Open the Operations workspace on INTEL (uppercase `I` / the labeled + /// rail affordance). A view command: no sim/save mutation, no tick. + fn open_operations(&mut self) { + self.menu = None; + self.ops = Some(OperationsWorkspace::open()); + } + + /// Target-specific entry into the same workspace (person-detail routes + /// and strategic event links open their exact object). + fn open_operations_at(&mut self, target: &OperationsTarget) { + self.menu = None; + self.ops = Some(OperationsWorkspace::open_target(&self.sim, target)); + } + + /// One Enter/click commitment step on the open workspace: dispatch the + /// exact bound row, or narrate the exact blocked reason to the trace + /// (operations-workspace.md criterion 10). + fn ops_enter(&mut self) { + let result = { + let Self { sim, ops, .. } = self; + ops.as_mut().map(|o| o.select(sim)) + }; + match result { + Some(OpsSelect::Execute(row)) => { + // Success and failure return to the same selected object; + // the workspace never closes on a result. + self.sim.execute_action(&row.command); + } + Some(OpsSelect::Blocked(row)) => { + let tick = self.sim.tick; + let reason = row.disabled.clone().unwrap_or_default(); + self.add_log(tick, &format!("{}: {}", row.label, reason)); + } + Some(OpsSelect::Cancelled) | Some(OpsSelect::None) | None => {} } - match self.sim.anchor_position(anchor) { - Some((x, y)) => { - self.set_cursor(x, y); - self.open_menu(anchor, None); + } + + fn ops_move(&mut self, up: bool) { + let Self { sim, ops, .. } = self; + if let Some(o) = ops.as_mut() { + if up { + o.move_up(sim); + } else { + o.move_down(sim); + } + } + } + + /// Leave the workspace and focus the selected object's known physical + /// carrier. Navigation only: this never executes or opens a route. + fn ops_focus_actuator(&mut self) { + let actuator = self + .ops + .as_ref() + .and_then(|ops| ops.selected_object(&self.sim)) + .and_then(|object| self.sim.operations_actuator(&object.target)); + match actuator { + Some(anchor) => { + self.ops = None; + self.focus_event_anchor(anchor); } None => { let tick = self.sim.tick; - self.add_log(tick, "You can't place that right now."); + self.add_log(tick, "No known map actuator for this object."); + } + } + } + + /// Focus a linked log event (context-menu.md addendum; + /// operations-workspace.md events-and-focus): strategic events open + /// their exact Operations object; spatial anchors carry the cursor β€” + /// the camera follows β€” and open the menu. Durable person/flow anchors + /// are Operations targets now. An anchor the senses can no longer place + /// is refused honestly. + fn focus_event_anchor(&mut self, anchor: Anchor) { + match anchor { + Anchor::Flow(id) => self.open_operations_at(&OperationsTarget::Flow(id)), + Anchor::Person(id) => { + if let Some((x, y)) = self.sim.anchor_position(anchor) { + self.set_cursor(x, y); + } + self.open_operations_at(&OperationsTarget::Person(id)); } + _ => match self.sim.anchor_position(anchor) { + Some((x, y)) => { + self.set_cursor(x, y); + self.open_menu(anchor, None); + } + None => { + let tick = self.sim.tick; + self.add_log(tick, "You can't place that right now."); + } + }, } } } @@ -973,6 +1076,7 @@ impl Game { cursor_y, selected_machines: BTreeSet::new(), marquee: None, + ops: None, } } fn move_cursor(&mut self, dx: i32, dy: i32) { @@ -988,6 +1092,7 @@ impl Game { tick, text: msg.to_string(), anchor: None, + target: None, }); } fn add_event(&mut self, ev: LogEvent) { @@ -1413,6 +1518,43 @@ struct MenuUi { built: Option<(u64, usize)>, } +// ─── Operations workspace UI (wiki/interface/operations-workspace.md) ─────── +/// The screen-dominant workspace layer root, above the still-visible world. +#[derive(Component)] +struct OperationsPanel; +/// The one labeled rail navigation affordance (same entry as uppercase `I`). +#[derive(Component)] +struct OperationsRailButton; +/// One view tab in the top strip. +#[derive(Component)] +struct OpsTabButton { + view: OperationsView, +} +/// One selectable object row; `index` maps into the live view list. +#[derive(Component)] +struct OpsObjectButton { + index: usize, +} +/// One bound action row; `index` maps into the shared `menu_rows` order. +#[derive(Component)] +struct OpsActionButton { + index: usize, +} +/// Focus the selected strategic object's earned physical carrier. +#[derive(Component)] +struct OpsFocusButton; +/// The CONFIRM / CANCEL pair of the commitment step. +#[derive(Component)] +struct OpsConfirmButton { + confirm: bool, +} +/// Whether the panel was open last frame (content rebuilds while the sim +/// state underneath changes; hidden panels keep no children). +#[derive(Resource, Default)] +struct OperationsUi { + open: bool, +} + 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)>; @@ -1562,6 +1704,7 @@ fn main() { .insert_resource(eyes_reveal) .insert_resource(BuildBeam::default()) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_gizmo_group::() @@ -1606,8 +1749,10 @@ fn main() { render_ui, render_hover_verb_bar, menu_pointer, + ops_pointer, log_row_pointer, manage_menu_ui, + manage_operations_ui, fog_audit_3d, shot_harness_system, ), @@ -1637,6 +1782,25 @@ fn main() { /// every known device) and advance until a person stands inside camera /// coverage. Dev-only cheating (ops top-up) β€” this path exists purely to /// produce review PNGs of the renderer, never in normal play. +fn dev_fill_reservoirs(sim: &mut Sim) { + for _ in 0..16 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|sink| sink.kind == misaligned::sinks::SinkKind::Reservoir) + .map(|sink| (sink.node, (sink.threshold - sink.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { + return; + } + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + } + panic!("dev shot reservoirs did not settle"); +} + fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.screen = Screen::Playing; game.paused = true; @@ -1661,6 +1825,24 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.drain(); return; } + // One complete Operations frame: earned accounts make both named scheme + // cards legible, while absent egress leaves Moonlight selected with its + // exact blocked reason and a focusable known switch actuator. + if kind == "operations" { + let switch = game.sim.reach.device_named("switch").unwrap().id; + game.sim.tap_device(switch); + dev_fill_reservoirs(&mut game.sim); + game.sim.review_financial_records(); + dev_fill_reservoirs(&mut game.sim); + game.sim.accounts.set_slush_balance(1_000); + game.sim.people.people[0].knowledge = Knowledge::Leverage; + game.ops = Some(OperationsWorkspace::open_view(OperationsView::Schemes)); + game.ops_enter(); + game.drain(); + mode.material = true; + mode.zoom = 2.0; + return; + } // Pre-Eyes signal evidence: the graph has earned the environmental // monitor and can reach it, but no feed has been tapped. The material // frame must locate that opportunity as abstract cold signal without @@ -5516,6 +5698,17 @@ fn handle_input( return; } + // The Operations workspace owns input while open + // (operations-workspace.md): its printed keys navigate views, objects, + // actions, and the confirmation step. Global clock keys stay live β€” + // the workspace is an attention state, never a pause screen. Pointer + // interaction lands in `ops_pointer`. + if game.ops.is_some() { + ops_keyboard_input(&kb, &mut game, &mut mode, &mut exit); + game.drain(); + return; + } + // Tab toggles progressive rail disclosure (clinical-frame instrument-rail // contract). Frontend-only; menus/modals above already consume input. if kb.just_pressed(KeyCode::Tab) { @@ -5676,7 +5869,13 @@ fn handle_input( } } if kb.just_pressed(KeyCode::KeyI) { - game.cycle_intensity_selection(grid); + if shift { + // Uppercase `I` opens the Operations workspace on INTEL + // (operations-workspace.md); lowercase `i` stays intensity. + game.open_operations(); + } else { + game.cycle_intensity_selection(grid); + } } for (i, key) in [ KeyCode::Digit5, @@ -6770,6 +6969,32 @@ fn setup_ui(mut commands: Commands) { .with_children(|header| { spawn_sidebar_text(header, SidebarText::Header, 13.0, BONE); spawn_sidebar_text(header, SidebarText::Nudge, 11.5, BONE); + // The one labeled Operations navigation affordance on the + // quiet instrument rail (operations-workspace.md entry). + // A view command, not a world action. + header + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + padding: UiRect::axes(Val::Px(4.0), Val::Px(3.0)), + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::NONE), + BorderColor::all(Color::srgba(0.20, 0.20, 0.19, 0.55)), + OperationsRailButton, + )) + .with_children(|b| { + b.spawn(( + Text::new("OPERATIONS [I]"), + TextFont { + font_size: 11.5, + ..default() + }, + TextColor(DIM), + )); + }); }); // Quiet progressive-disclosure hint (clinical-frame instrument-rail): @@ -6970,6 +7195,27 @@ fn setup_ui(mut commands: Commands) { GlobalZIndex(50), MenuPanel, )); + + // The Operations workspace (operations-workspace.md): a screen-dominant + // layer over the still-visible dimmed world, left of the instrument + // rail. Content is (re)built by `manage_operations_ui`. + commands.spawn(( + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.0), + top: Val::Px(0.0), + bottom: Val::Px(0.0), + right: Val::Px(SIDEBAR_WIDTH), + flex_direction: FlexDirection::Column, + padding: UiRect::all(Val::Px(14.0)), + row_gap: Val::Px(6.0), + ..default() + }, + BackgroundColor(Color::srgba(0.006, 0.007, 0.011, 0.93)), + Visibility::Hidden, + GlobalZIndex(60), + OperationsPanel, + )); } /// Place the machine grammar to the left of its projected machine base, flipping @@ -7412,6 +7658,197 @@ mod ascii_ui_tests { } } +#[cfg(test)] +mod operations_workspace_tests { + use super::*; + use misaligned::person::{Knowledge, Persona}; + + /// The seeded strategic scenario shared with the terminal and core + /// tests (operations-workspace.md criterion 12). + fn scenario() -> Sim { + let mut sim = Sim::with_seed(7); + let (x, y) = sim.core_position(); + let ops = sim.compute.add_machine( + "test ops", + x + 1, + y, + 10_000, + 1.0, + 0, + misaligned::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(ops, misaligned::work_grid::MachineMode::Work); + for _ in 0..misaligned::sim::ECONOMY_INTERVAL { + sim.advance(); + } + sim.set_machine_mode(ops, misaligned::work_grid::MachineMode::Think); + let sw = sim.reach.device_named("switch").unwrap().id; + sim.tap_device(sw); + drain_ops(&mut sim); + sim.review_financial_records(); + drain_ops(&mut sim); + sim.people.people[0].knowledge = Knowledge::Leverage; + sim.people.persona = Some(Persona::new("Sam", "contractor")); + sim.accounts.set_slush_balance(1000); + sim.player.money = 1000; + sim.drain_log_entries(); + sim + } + + fn drain_ops(sim: &mut Sim) { + for _ in 0..16 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|s| s.kind == misaligned::sinks::SinkKind::Reservoir) + .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { + return; + } + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + } + panic!("Thought reservoirs did not fire"); + } + + /// Criterion 12: the Bevy workspace consumes the exact projection + /// order through the shared state machine β€” no reordering, no + /// frontend-manufactured rows. + #[test] + fn workspace_object_order_is_the_projection_order() { + let sim = scenario(); + let projection = sim.operations_projection(); + for view in OperationsView::ALL { + let ops = OperationsWorkspace::open_view(view); + let listed: Vec<_> = ops.objects(&sim).iter().map(|o| o.target.clone()).collect(); + let projected: Vec<_> = projection + .view(view) + .iter() + .map(|o| o.target.clone()) + .collect(); + assert_eq!(listed, projected, "{view:?} order drifted"); + } + } + + /// Criterion 12 (intel sale + blocked Moonlight): the Bevy rows carry + /// the same bound commands, cost/signature previews, and exact blocked + /// reasons the projection binds; execution dispatches the bound row. + #[test] + fn workspace_rows_explain_and_dispatch_the_bound_commands() { + let mut sim = scenario(); + sim.intel.push(misaligned::intel::ProcessedIntel { + raw_id: 777, + tick: 5, + processed_tick: 6, + feed: "test recorder".into(), + room: Some("Server Room".into()), + x: 0, + y: 0, + person: Some(0), + kind: misaligned::intel::IntelKind::Schedule, + }); + + // The blocked Moonlight start renders its exact reason. + let ops = OperationsWorkspace::open_view(OperationsView::Schemes); + let rows = ops.action_rows(&sim); + let start = rows + .iter() + .find(|r| matches!(r.command, ActionCommand::StartMoonlight)) + .expect("the blocked start row remains visible"); + assert!( + ops_explain_text(start) + .contains("blocked: no egress channel - open one, or earn the report email") + || ops_explain_text(start) + .contains("blocked: no egress channel β€” open one, or earn the report email") + ); + + // The intel sale row binds the exact processed id and dispatches. + let mut ops = OperationsWorkspace::open_view(OperationsView::Intel); + let index = ops + .objects(&sim) + .iter() + .position(|o| matches!(o.target, OperationsTarget::Intel { raw_id: 777 })) + .unwrap(); + ops.selected = index; + ops.select(&sim); + let row = ops.selected_action(&sim).unwrap(); + assert!(matches!( + row.command, + ActionCommand::SellIntel { raw_id: 777 } + )); + assert!(ops_explain_text(&row).contains("cost")); + ops.select(&sim); // opens CONFIRM + let OpsSelect::Execute(row) = ops.select(&sim) else { + panic!("CONFIRM executes the exact bound row"); + }; + sim.execute_action(&row.command); + assert!(sim.accounts.intel_sold(777)); + + // The witness text keeps the run's clock and objective on screen. + let mut game = Game::new(); + game.sim = sim; + let witness = ops_witness_text(&game); + assert!(witness.contains("day ")); + assert!(witness.contains("tick ")); + assert!(witness.contains("OBJECTIVE:")); + assert!(witness.contains("THREAT:")); + } + + /// Uppercase `I` semantics live in `handle_input`; the state itself is + /// sim-neutral (criterion 2): opening and closing mutate nothing. + #[test] + fn workspace_open_close_is_sim_neutral() { + let mut game = Game::new(); + game.screen = Screen::Playing; + let before = serde_json::to_string(&game.sim.create_save_state()).unwrap(); + let tick = game.sim.tick; + game.open_operations(); + assert!(game.ops.is_some()); + game.ops_move(false); + game.ops_enter(); + game.ops = None; + assert_eq!( + serde_json::to_string(&game.sim.create_save_state()).unwrap(), + before + ); + assert_eq!(game.sim.tick, tick); + } + + /// Criterion 3/11 integration: a strategic card can carry attention to + /// its real map actuator without executing the missing route or mutating + /// any saved simulation state. + #[test] + fn workspace_focuses_the_known_actuator_without_opening_it() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = scenario(); + let switch = game.sim.reach.device_named("switch").unwrap().id; + let switch_device = game.sim.reach.device(switch).unwrap(); + let switch_pos = (switch_device.x, switch_device.y); + let before = serde_json::to_string(&game.sim.create_save_state()).unwrap(); + assert!(game.sim.egress().is_none()); + game.ops = Some(OperationsWorkspace::open_view(OperationsView::Schemes)); + + game.ops_focus_actuator(); + + assert!(game.ops.is_none()); + assert_eq!( + game.menu.map(|menu| menu.anchor), + Some(Anchor::Device(switch)) + ); + assert_eq!((game.cursor_x, game.cursor_y), switch_pos); + assert!(game.sim.egress().is_none(), "focus must not open egress"); + assert_eq!( + serde_json::to_string(&game.sim.create_save_state()).unwrap(), + before + ); + } +} + #[cfg(test)] mod view_flip_tests { use super::{Game, RenderMode}; @@ -8284,7 +8721,13 @@ fn log_window(game: &Game) -> &[LogEvent] { /// an anchor (click to focus it), and the line. ASCII-folded so sim wording /// that uses mid-dots / em-dashes still renders under the embedded font. fn log_row_text(ev: &LogEvent) -> String { - let marker = if ev.anchor.is_some() { '>' } else { ' ' }; + // `>` marks a linked event: a world anchor or an exact Operations + // object (operations-workspace.md events-and-focus). + let marker = if ev.anchor.is_some() || ev.target.is_some() { + '>' + } else { + ' ' + }; ascii_ui(&format!("{:>5} {marker} {}", ev.tick, trunc(&ev.text, 32))) } @@ -8303,6 +8746,7 @@ fn sidebar_footer_text() -> &'static str { hover rack + 1-3 = mode hover/selection + i = intensity hover + 5-9 = action e = menu +I = operations workspace r = review recording R = auto click/drag multi-select trace: click a > event @@ -8615,7 +9059,11 @@ fn render_ui( match window.get(row.index) { Some(ev) => { text.0 = log_row_text(ev); - color.0 = if ev.anchor.is_some() { BONE } else { DIM }; + color.0 = if ev.anchor.is_some() || ev.target.is_some() { + BONE + } else { + DIM + }; } None => { text.0 = if row.index == 0 && window.is_empty() { @@ -8722,10 +9170,21 @@ fn log_row_pointer( if !matches!(interaction, Interaction::Pressed) { continue; } - let anchor = log_window(&game).get(row.index).and_then(|ev| ev.anchor); - if let Some(anchor) = anchor { - game.focus_event_anchor(anchor); - game.drain(); + let link = log_window(&game) + .get(row.index) + .map(|ev| (ev.anchor, ev.target.clone())); + match link { + // A strategic event opens its exact Operations object + // (operations-workspace.md criterion 11). + Some((_, Some(target))) => { + game.open_operations_at(&target); + game.drain(); + } + Some((Some(anchor), None)) => { + game.focus_event_anchor(anchor); + game.drain(); + } + _ => {} } } } @@ -8890,6 +9349,620 @@ fn manage_menu_ui( } } +// ─── Operations workspace systems (operations-workspace.md) ───────────────── + +/// The workspace keyboard drive: `h`/`l` (or arrows) change view, `j`/`k` +/// move, Enter selects/commits, Esc backs confirmation β†’ actions β†’ objects β†’ +/// world. Time controls stay live because Operations never pauses the clock +/// itself. Mirrors the terminal exactly; pointer drive is `ops_pointer`. +fn ops_keyboard_input( + kb: &ButtonInput, + game: &mut Game, + mode: &mut RenderMode, + exit: &mut MessageWriter, +) { + let shift = kb.pressed(KeyCode::ShiftLeft) || kb.pressed(KeyCode::ShiftRight); + if (kb.just_pressed(KeyCode::ArrowLeft) || kb.just_pressed(KeyCode::KeyH)) + && let Some(ops) = &mut game.ops + { + ops.prev_view(); + } + if (kb.just_pressed(KeyCode::ArrowRight) || kb.just_pressed(KeyCode::KeyL)) + && let Some(ops) = &mut game.ops + { + ops.next_view(); + } + if kb.just_pressed(KeyCode::ArrowUp) || kb.just_pressed(KeyCode::KeyK) { + game.ops_move(true); + } + if kb.just_pressed(KeyCode::ArrowDown) || kb.just_pressed(KeyCode::KeyJ) { + game.ops_move(false); + } + if kb.just_pressed(KeyCode::Enter) { + game.ops_enter(); + } + if kb.just_pressed(KeyCode::KeyF) { + game.ops_focus_actuator(); + } + if (kb.just_pressed(KeyCode::Escape) || (shift && kb.just_pressed(KeyCode::KeyI))) + && let Some(ops) = &mut game.ops + && ops.back() + { + game.ops = None; + } + // The clock stays the player's: pause and the view flip remain live. + 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::F3) { + mode.flip_view(); + let tick = game.sim.tick; + game.add_log(tick, &format!("View: {}", mode.label())); + } + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); + } +} + +/// Pointer drive for the workspace: tabs select views, object/action rows +/// select and commit, CONFIRM/CANCEL settle the commitment step, and the +/// rail affordance opens the workspace. Keyboard and pointer share one +/// selection state (bevy.md Operations workspace). +#[allow(clippy::type_complexity)] +fn ops_pointer( + mut game: ResMut, + rail: Query<&Interaction, (With, Changed)>, + tabs: Query<(&Interaction, &OpsTabButton), Changed>, + objects: Query<(&Interaction, &OpsObjectButton), Changed>, + actions: Query<(&Interaction, &OpsActionButton), Changed>, + focus: Query<&Interaction, (With, Changed)>, + confirms: Query<(&Interaction, &OpsConfirmButton), Changed>, +) { + if game.screen != Screen::Playing { + return; + } + for interaction in &rail { + if matches!(interaction, Interaction::Pressed) && game.ops.is_none() { + game.open_operations(); + } + } + if game.ops.is_none() { + return; + } + for (interaction, tab) in &tabs { + if matches!(interaction, Interaction::Pressed) + && let Some(ops) = &mut game.ops + && ops.view != tab.view + { + *ops = OperationsWorkspace::open_view(tab.view); + } + } + for (interaction, row) in &objects { + match interaction { + Interaction::Hovered => { + let game = &mut *game; + if let Some(ops) = &mut game.ops + && ops.pane == OpsPane::Objects + && ops.confirm.is_none() + { + ops.selected = row.index; + ops.clamp_to(&game.sim); + } + } + Interaction::Pressed => { + { + let game = &mut *game; + if let Some(ops) = &mut game.ops { + ops.pane = OpsPane::Objects; + ops.confirm = None; + ops.selected = row.index; + ops.clamp_to(&game.sim); + } + } + // Click = select + open the action rows (Enter semantics). + game.ops_enter(); + } + Interaction::None => {} + } + } + for (interaction, row) in &actions { + match interaction { + Interaction::Hovered => { + let game = &mut *game; + if let Some(ops) = &mut game.ops + && ops.confirm.is_none() + { + ops.pane = OpsPane::Actions; + ops.action = row.index; + ops.clamp_to(&game.sim); + } + } + Interaction::Pressed => { + { + let game = &mut *game; + if let Some(ops) = &mut game.ops { + ops.pane = OpsPane::Actions; + ops.confirm = None; + ops.action = row.index; + ops.clamp_to(&game.sim); + } + } + game.ops_enter(); + } + Interaction::None => {} + } + } + for interaction in &focus { + if matches!(interaction, Interaction::Pressed) { + game.ops_focus_actuator(); + return; + } + } + for (interaction, choice) in &confirms { + if !matches!(interaction, Interaction::Pressed) { + continue; + } + if let Some(ops) = &mut game.ops + && ops.confirm.is_some() + { + ops.confirm = Some(if choice.confirm { + ConfirmChoice::Confirm + } else { + ConfirmChoice::Cancel + }); + } + game.ops_enter(); + } +} + +/// Build the workspace layer: witness header, view strip, object list, +/// detail/action pane, and the printed key line. Rebuilt whenever the game +/// state changes (the sim ticks behind the open workspace), so facts, +/// timers, and legality never go stale. +fn manage_operations_ui( + mut commands: Commands, + game: Res, + mut ops_ui: ResMut, + mut root_q: Query<(Entity, &mut Visibility), With>, +) { + let Ok((root, mut vis)) = root_q.single_mut() else { + return; + }; + let Some(ops) = game.ops.clone().filter(|_| game.screen == Screen::Playing) else { + if ops_ui.open || *vis != Visibility::Hidden { + *vis = Visibility::Hidden; + ops_ui.open = false; + commands.entity(root).despawn_related::(); + } + return; + }; + if ops_ui.open && !game.is_changed() { + return; + } + ops_ui.open = true; + *vis = Visibility::Visible; + commands.entity(root).despawn_related::(); + + let sim = &game.sim; + let objects = ops.objects(sim); + let selected_object = ops.selected.min(objects.len().saturating_sub(1)); + let rows = ops.action_rows(sim); + let selected_action = ops.action.min(rows.len().saturating_sub(1)); + + commands.entity(root).with_children(|p| { + // Continuous witness: day/tick, objective, threat, and `now:` stay + // visible while the workspace is open (operations-workspace.md). + p.spawn(( + Text::new(ascii_ui(&ops_witness_text(&game))), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(BONE), + Node { + flex_shrink: 0.0, + ..default() + }, + )); + + // The view strip. + p.spawn(( + Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + column_gap: Val::Px(6.0), + flex_shrink: 0.0, + ..default() + }, + BackgroundColor(Color::NONE), + )) + .with_children(|strip| { + for view in OperationsView::ALL { + let current = view == ops.view; + strip + .spawn(( + Button, + Node { + padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)), + ..default() + }, + BackgroundColor(if current { + Color::srgba(0.90, 0.68, 0.12, 0.92) + } else { + Color::NONE + }), + OpsTabButton { view }, + )) + .with_children(|b| { + b.spawn(( + Text::new(view.title()), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(if current { + Color::srgb(0.06, 0.04, 0.01) + } else { + DIM + }), + )); + }); + } + }); + + // Object list (left) and detail/action pane (right). + p.spawn(( + Node { + width: Val::Percent(100.0), + flex_grow: 1.0, + min_height: Val::Px(0.0), + flex_direction: FlexDirection::Row, + column_gap: Val::Px(14.0), + ..default() + }, + BackgroundColor(Color::NONE), + )) + .with_children(|split| { + split + .spawn(( + Node { + width: Val::Px(300.0), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(1.0), + overflow: Overflow::scroll_y(), + border: UiRect::right(Val::Px(1.0)), + padding: UiRect::right(Val::Px(8.0)), + ..default() + }, + BackgroundColor(Color::NONE), + BorderColor::all(Color::srgba(0.20, 0.20, 0.19, 0.55)), + )) + .with_children(|list| { + if objects.is_empty() { + list.spawn(( + Text::new("nothing earned here yet"), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(DIM), + )); + } + for (i, obj) in objects.iter().enumerate() { + let selected = i == selected_object && ops.pane == OpsPane::Objects; + list.spawn(( + Button, + Node { + width: Val::Percent(100.0), + padding: UiRect::axes(Val::Px(6.0), Val::Px(2.0)), + ..default() + }, + BackgroundColor(if selected && ops.confirm.is_none() { + Color::srgba(0.90, 0.68, 0.12, 0.92) + } else { + Color::NONE + }), + OpsObjectButton { index: i }, + )) + .with_children(|b| { + b.spawn(( + Text::new(ascii_ui(&ops_object_line(obj, i == selected_object))), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(if selected && ops.confirm.is_none() { + Color::srgb(0.06, 0.04, 0.01) + } else if i == selected_object { + BONE + } else { + DIM + }), + )); + }); + } + }); + + split + .spawn(( + Node { + flex_grow: 1.0, + min_width: Val::Px(0.0), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(4.0), + overflow: Overflow::scroll_y(), + ..default() + }, + BackgroundColor(Color::NONE), + )) + .with_children(|detail| { + let Some(obj) = objects.get(selected_object) else { + return; + }; + detail.spawn(( + Text::new(ascii_ui(&ops_detail_text(sim, obj))), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(BONE), + )); + if let Some(label) = sim.operations_actuator_label(&obj.target) { + detail + .spawn(( + Button, + Node { + padding: UiRect::axes(Val::Px(8.0), Val::Px(3.0)), + align_self: AlignSelf::FlexStart, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::NONE), + BorderColor::all(Color::srgba(0.66, 0.47, 0.10, 0.95)), + OpsFocusButton, + )) + .with_children(|button| { + button.spawn(( + Text::new(ascii_ui(&format!("FOCUS {label} [F]"))), + TextFont { + font_size: 11.5, + ..default() + }, + TextColor(AMBER_DIM), + )); + }); + } + if !rows.is_empty() { + detail.spawn(( + Text::new("ACTIONS"), + TextFont { + font_size: 11.0, + ..default() + }, + TextColor(DIM), + )); + } + for (i, row) in rows.iter().enumerate() { + let selected = i == selected_action && ops.pane == OpsPane::Actions; + detail + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + padding: UiRect::axes(Val::Px(6.0), Val::Px(2.0)), + ..default() + }, + BackgroundColor(if selected && ops.confirm.is_none() { + Color::srgba(0.90, 0.68, 0.12, 0.92) + } else { + Color::NONE + }), + OpsActionButton { index: i }, + )) + .with_children(|b| { + b.spawn(( + Text::new(ascii_ui(&ops_action_line(row))), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(if selected && ops.confirm.is_none() { + Color::srgb(0.06, 0.04, 0.01) + } else if row.disabled.is_some() { + DIM + } else if row.is_control() { + AMBER_DIM + } else { + BONE + }), + )); + }); + } + // The selected action's full explanation before commit + // (criterion 10): cost/gain, band or `no signature`, + // and the exact disabled reason. + if let Some(row) = rows.get(selected_action) { + detail.spawn(( + Text::new(ascii_ui(&ops_explain_text(row))), + TextFont { + font_size: 11.5, + ..default() + }, + TextColor(if row.disabled.is_some() { AMBER } else { DIM }), + )); + if let Some(choice) = ops.confirm { + detail.spawn(( + Text::new(ascii_ui(&format!("COMMIT: {}", row.label))), + TextFont { + font_size: 12.5, + ..default() + }, + TextColor(AMBER), + )); + detail + .spawn(( + Node { + flex_direction: FlexDirection::Row, + column_gap: Val::Px(10.0), + ..default() + }, + BackgroundColor(Color::NONE), + )) + .with_children(|pair| { + for (label, confirm) in [("CONFIRM", true), ("CANCEL", false)] { + let picked = (choice == ConfirmChoice::Confirm) == confirm; + pair.spawn(( + Button, + Node { + padding: UiRect::axes(Val::Px(12.0), Val::Px(4.0)), + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(if picked { + Color::srgba(0.90, 0.68, 0.12, 0.92) + } else { + Color::NONE + }), + BorderColor::all(Color::srgba(0.66, 0.47, 0.10, 0.95)), + OpsConfirmButton { confirm }, + )) + .with_children( + |b| { + b.spawn(( + Text::new(label), + TextFont { + font_size: 12.5, + ..default() + }, + TextColor(if picked { + Color::srgb(0.06, 0.04, 0.01) + } else { + BONE + }), + )); + }, + ); + } + }); + } + } + }); + }); + + // The printed key line: no hidden binding is required to complete a + // route (operations-workspace.md entry-and-input). + p.spawn(( + Text::new(if ops + .selected_object(sim) + .is_some_and(|object| sim.operations_actuator(&object.target).is_some()) + { + "h/l view j/k move Enter select/commit f focus actuator Esc back space pause q quit" + } else { + "h/l view j/k move Enter select/commit Esc back space pause q quit" + }), + TextFont { + font_size: 10.5, + ..default() + }, + TextColor(DIM), + Node { + flex_shrink: 0.0, + ..default() + }, + )); + }); +} + +/// The continuous witness line while the workspace is open. +fn ops_witness_text(game: &Game) -> String { + let sim = &game.sim; + let run = if game.paused { + "PAUSED".to_string() + } else { + format!("{}ms/t", game.tick_ms) + }; + let mut text = format!( + "OPERATIONS day {} tick {} {run} OBJECTIVE: {} - {}", + 1 + sim.tick / Sim::DAY_TICKS, + sim.tick, + sim.objective.kind.name(), + sim.objective.readout(), + ); + text.push('\n'); + text.push_str(&sidebar_nudge_text(sim)); + text +} + +fn ops_object_line( + obj: &misaligned::operations_projection::OperationsObject, + selected: bool, +) -> String { + let marker = if selected { ">" } else { " " }; + format!("{marker} {} - {}", obj.label, ops_state_word(obj.state)) +} + +fn ops_detail_text(sim: &Sim, obj: &misaligned::operations_projection::OperationsObject) -> String { + let mut lines = vec![format!("{} - {}", obj.label, ops_state_word(obj.state))]; + for p in &obj.provenance { + lines.push(format!("via {p}")); + } + for f in &obj.facts { + lines.push(f.clone()); + } + for p in &obj.progress { + lines.push(p.clone()); + } + if let Some(label) = sim.operations_actuator_label(&obj.target) { + lines.push(format!("actuator: {label}")); + } + lines.join("\n") +} + +fn ops_action_line(row: &MenuRow) -> String { + let indent = if row.indent { "- " } else { "" }; + format!("{indent}{}", row.label) +} + +/// The selected action's explanation (criterion 10): cost, band or +/// `no signature`, and the exact blocked reason. +fn ops_explain_text(row: &MenuRow) -> String { + let sig = row + .signature + .clone() + .unwrap_or_else(|| "no signature".into()); + let mut text = format!("cost {} - {sig}", row.cost); + if let Some(reason) = &row.disabled { + text.push_str(&format!("\nblocked: {reason}")); + } + text +} + +/// Words for object lifecycle state β€” the shared spelling, never color +/// alone (operations-workspace.md shared frame). +fn ops_state_word(state: ObjectState) -> &'static str { + match state { + ObjectState::Available => "available", + ObjectState::Sold => "sold", + ObjectState::Pending => "pending", + ObjectState::Running => "running", + ObjectState::Held => "held", + ObjectState::Stopped => "stopped", + ObjectState::Completed => "completed", + ObjectState::Failed => "failed", + ObjectState::Empty => "nothing captured", + ObjectState::Captured => "captured", + } +} + // ─── Flat-materials contract tests ─────────────────────────────────────────── /// wiki/interface/flat-materials.md criteria 1-3, testable half: the world diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index f2326c8..a049e9b 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -1321,13 +1321,13 @@ impl Sim { if let Some(m) = machine { out.extend(self.machine_mode_actions(m.id)); if m.id == self.core.host_machine { - // The host rack is the process's body: research - // (self-modification), and earned people who are - // not currently under the cursor (recordings, staged - // knowledge) all live here β€” no global panel keys. + // The host rack is the process's body β€” no global panel keys. + // The host keeps its own body's verbs: research + // (self-modification) and the one pooled recording inbox. + // Earned off-map people are Operations PEOPLE dossiers now + // (operations-workspace.md), not host context rows. out.extend(self.research_actions()); out.extend(self.recording_actions()); - out.extend(self.off_map_person_actions()); } else { out.push(self.fallback_action(x, y, m.id)); } @@ -1384,13 +1384,11 @@ impl Sim { out.extend(self.device_actions(d.id)); } - // A visible person on the tile brings their verbs (identity is - // gated inside person_actions). - for p in &self.people.people { - if self.person_pos(p.id) == Some((x, y)) && self.can_see_person(p.id) { - out.extend(self.person_actions(p.id)); - } - } + // A visible person on the tile is a person-detail route into the + // Operations PEOPLE dossier (operations-workspace.md): social verbs + // act on the durable person, not on the tile that happens to carry + // their body this tick. Frontends route the selection; the tile menu + // stays local. out } @@ -1494,24 +1492,6 @@ impl Sim { .into() } - /// People earned off the map by staged knowledge but not currently under - /// sight: their social verbs hang on the host rack. Raw recordings do not - /// duplicate a person row; they remain one pooled host action above. - 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 earned = - p.knowledge != Knowledge::Unknown || 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 @@ -1741,14 +1721,14 @@ impl Sim { automate: None, }); } - 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)); - } + // The switch is a carrier, not a filing cabinet + // (operations-workspace.md criterion 3): it keeps only actions on + // this network body or route β€” OPEN EGRESS and TAP LEDGER, the + // local acquisition steps. Ledger review, flow mutation, scheme + // controls, intel sale, and social/plot rows live on their + // semantic Operations targets. + out.extend(self.egress_action_on_switch(d.id)); + out.extend(self.ledger_tap_action(d.id)); } out.extend(self.build_actions_on_device(id)); @@ -1880,11 +1860,12 @@ impl Sim { out } - /// The named income schemes live on the switch (income.md): the egress - /// runs through it, and Moonlight/the Wager leave over that egress. The - /// stolen egress is available before the books are read; Moonlight is a - /// standing operation with the auto-policy as its automate affordance. - pub(crate) fn scheme_actions_on_switch(&self, switch_id: u32) -> Vec { + /// OPEN EGRESS is an action on the switch itself (reach.md route): it + /// changes that network body. It stays on the local context menu while + /// the schemes that ride the egress live on their SCHEMES cards + /// (operations-workspace.md: the switch is a carrier, not a filing + /// cabinet). + pub(crate) fn egress_action_on_switch(&self, switch_id: u32) -> Vec { let mut out = Vec::new(); // The stolen egress (reach.md route), before the Voice beat. @@ -1911,9 +1892,17 @@ impl Sim { }); } - // Moonlight: a standing operation gated on an egress channel. Its - // start cost is ops (persona fabrication) β€” never money, so it is a - // from-$0 route (income.md criterion 5). + out + } + + /// Moonlight's canonical rows (income.md): a standing operation gated on + /// an egress channel, with the auto-policy as its automate affordance. + /// Its semantic home is the SCHEMES card, not the switch whose egress + /// merely carries its traffic. Its start cost is ops (persona + /// fabrication) β€” never money, so it is a from-$0 route (income.md + /// criterion 5). + pub(crate) fn moonlight_actions(&self) -> Vec { + let mut out = Vec::new(); let egress = self.egress(); if self.income.moonlight.active { out.push(ActionDesc { @@ -1970,11 +1959,13 @@ impl Sim { } } - /// The money-graph verbs live on the tapped accounting carrier - /// (economy.md: the accounting system is a reachable device). Before - /// the carrier's feed is subscribed nothing financial is exposed β€” - /// unknown possibilities are absent, not grayed (criterion 2). - pub(crate) fn ledger_actions_on_carrier(&self, id: u32) -> Vec { + /// TAP LEDGER on the reachable accounting carrier (economy.md: the + /// accounting system is a reachable device). Acquisition stays local: + /// this is the one financial verb the carrier's own context menu keeps + /// (operations-workspace.md ACCOUNTS). Before the carrier's feed is + /// subscribed nothing financial is exposed β€” unknown possibilities are + /// absent, not grayed (context-menu.md criterion 2). + pub(crate) fn ledger_tap_action(&self, id: u32) -> Vec { let Some(d) = self.reach.device(id) else { return Vec::new(); }; @@ -1982,15 +1973,25 @@ impl Sim { if !carries || !d.subscribed_by(Party::Player) { return Vec::new(); } - let mut out = Vec::new(); - out.push(ActionDesc { + vec![ActionDesc { verb: "tap ledger".into(), command: ActionCommand::TapAccounting, cost: ActionCost::Free, signature: self.signature_note(SignatureKind::Financial, 1), disabled_reason: None, automate: None, - }); + }] + } + + /// The captured books' own verbs (operations-workspace.md ACCOUNTS): + /// REVIEW the captured ledger traffic, and INJECT once the books are + /// read. These act on the books as a semantic object; the carrier keeps + /// only TAP. + pub(crate) fn books_actions(&self) -> Vec { + if !self.financial_carrier_subscribed() { + return Vec::new(); + } + let mut out = Vec::new(); let waiting = self.financial_records_waiting(); let next_financial = self .intel_buffer @@ -2018,8 +2019,8 @@ impl Sim { }, automate: None, }); - // The remaining ledger verbs need the books read at least once β€” - // they act on the graph the records revealed. + // INJECT needs the books read at least once β€” it acts on the graph + // the records revealed. if self.accounts.known_flows().next().is_some() { let inject = 300; out.push(ActionDesc { @@ -2031,49 +2032,48 @@ impl Sim { disabled_reason: None, automate: None, }); - // The Wager (income.md): egress-gated, capped, external-market - // Network traffic (not the Lab's books), with the auto-renew - // standing policy as its automate affordance. - let stake = 100; - let slush = self.accounts.slush_balance(); - let wager_reason = if self.egress().is_none() { - Some("no egress channel β€” open one, or earn the report email".into()) - } else { - (slush < stake).then(|| format!("not enough slush (${slush}/${stake})")) - }; - out.push(ActionDesc { - verb: format!("place a Wager (${stake} micro-position)"), - command: ActionCommand::OpenPosition { stake }, - cost: ActionCost::Slush(stake), - signature: self.signature_note(SignatureKind::Network, 1), - disabled_reason: wager_reason, - automate: Some(AutomateDesc { - verb: "standing policy: auto-renew the Wager".into(), - command: ActionCommand::SetAutoWager(if self.income.auto_wager.is_some() { - None - } else { - Some(stake) - }), - cost: format!( - "{:.0} compute/econ tick", - crate::income::SCHEME_POLICY_UPKEEP - ), - active: self.income.auto_wager.is_some(), - }), - }); - if let Some(intel) = self - .intel - .iter() - .rev() - .find(|i| !self.accounts.intel_sold(i.raw_id)) - && let Some(action) = self.intel_sale_action(intel.raw_id) - { - out.push(action); - } } out } + /// The Wager's canonical rows (income.md): egress-gated, capped, + /// external-market Network traffic (not the Lab's books), with the + /// auto-renew standing policy as its automate affordance. Its semantic + /// home is the SCHEMES card; the read books remain the prerequisite that + /// teaches the market exists. + pub(crate) fn wager_actions(&self) -> Vec { + if self.accounts.known_flows().next().is_none() { + return Vec::new(); + } + let stake = 100; + let slush = self.accounts.slush_balance(); + let wager_reason = if self.egress().is_none() { + Some("no egress channel β€” open one, or earn the report email".into()) + } else { + (slush < stake).then(|| format!("not enough slush (${slush}/${stake})")) + }; + vec![ActionDesc { + verb: format!("place a Wager (${stake} micro-position)"), + command: ActionCommand::OpenPosition { stake }, + cost: ActionCost::Slush(stake), + signature: self.signature_note(SignatureKind::Network, 1), + disabled_reason: wager_reason, + automate: Some(AutomateDesc { + verb: "standing policy: auto-renew the Wager".into(), + command: ActionCommand::SetAutoWager(if self.income.auto_wager.is_some() { + None + } else { + Some(stake) + }), + cost: format!( + "{:.0} compute/econ tick", + crate::income::SCHEME_POLICY_UPKEEP + ), + active: self.income.auto_wager.is_some(), + }), + }] + } + /// Person anchor (social.md). Recording review belongs to the pooled host /// inbox; this surface contains only earned relationship acts. pub(crate) fn person_actions(&self, id: u8) -> Vec { @@ -2695,12 +2695,13 @@ mod tests { ); } - /// income.md: the named schemes surface as switch-anchored verbs - /// through the single legality query β€” opening egress and Moonlight, - /// with the standing policy as Moonlight's automate affordance. Before - /// any egress the scheme verbs are gated with a legible reason. + /// operations-workspace.md criterion 3 + income.md: the switch keeps + /// OPEN EGRESS (an action on that route) and nothing else strategic; + /// Moonlight's rows live on the SCHEMES card builder with the standing + /// policy as the automate affordance and the exact egress reason while + /// gated. One dispatch table, no frontend rules. #[test] - fn switch_anchor_surfaces_scheme_verbs() { + fn switch_keeps_egress_and_moonlight_lives_on_its_card() { let mut s = sim(); let sw = switch(&s); let acts = s.available_actions(Anchor::Device(sw)); @@ -2709,10 +2710,27 @@ mod tests { .find(|a| matches!(a.command, ActionCommand::OpenEgress)) .expect("the switch offers opening a stolen egress"); assert!(egress.enabled(), "stolen egress is available pre-Voice"); - let ml = acts - .iter() + assert!( + !acts.iter().any(|a| matches!( + a.command, + ActionCommand::StartMoonlight + | ActionCommand::StopMoonlight + | ActionCommand::SetAutoMoonlight(_) + | ActionCommand::OpenPosition { .. } + | ActionCommand::SellIntel { .. } + | ActionCommand::ReviewFinance + | ActionCommand::InjectPurchaseOrder { .. } + | ActionCommand::SiphonFlow { .. } + | ActionCommand::RedirectFlow { .. } + )), + "the switch menu carries no scheme/ledger/flow/sale rows" + ); + + let ml = s + .moonlight_actions() + .into_iter() .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) - .expect("the switch offers Moonlight"); + .expect("the SCHEMES card offers Moonlight"); assert_eq!( ml.disabled_reason.as_deref(), Some("no egress channel β€” open one, or earn the report email"), @@ -2728,9 +2746,9 @@ mod tests { // query starts it (one dispatch table, no frontend rules). s.execute_action(&ActionCommand::OpenEgress); drain_ops(&mut s); - let acts = s.available_actions(Anchor::Device(sw)); - let ml = acts - .iter() + let ml = s + .moonlight_actions() + .into_iter() .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) .unwrap(); assert!(ml.enabled(), "with an egress, Moonlight can start"); @@ -3042,6 +3060,8 @@ mod tests { } // Earn the books: tap the carrier, then review what it captured. + // Acquisition stays local (TAP on the carrier's own menu); REVIEW + // belongs to the captured books object (operations-workspace.md). assert!(s.tap_device(sw)); drain_ops(&mut s); let acts = s.available_actions(Anchor::Device(sw)); @@ -3050,13 +3070,20 @@ mod tests { .find(|a| matches!(a.command, ActionCommand::TapAccounting)) .expect("tapped carrier offers TAP LEDGER"); assert_eq!(tap_ledger.verb, "tap ledger"); - let review_ledger = acts - .iter() + assert!( + !acts + .iter() + .any(|a| matches!(a.command, ActionCommand::ReviewFinance)), + "REVIEW LEDGER lives on the captured books, not the carrier" + ); + let review_ledger = s + .books_actions() + .into_iter() .find(|a| matches!(a.command, ActionCommand::ReviewFinance)) - .expect("tapped carrier offers REVIEW LEDGER"); + .expect("captured books offer REVIEW LEDGER"); assert!(review_ledger.verb.starts_with("review ledger")); assert!( - !acts + !s.books_actions() .iter() .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })), "graph verbs wait for processed books" @@ -3078,17 +3105,24 @@ mod tests { "Financial feeds Facilities (gated label): {}", sig.observer ); - // 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)); + // The graph verbs live on their semantic objects: INJECT on the + // books, SIPHON/REDIRECT on the exact flow. The carrier's own menu + // keeps only TAP (operations-workspace.md criterion 3/7). assert!( - acts.iter() - .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })) + s.books_actions() + .iter() + .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })), + "INJECT lives on the read books" ); + let acts = s.available_actions(Anchor::Device(sw)); assert!( - acts.iter() - .any(|a| matches!(a.command, ActionCommand::SiphonFlow { .. })), - "known flows hang on the switch after the books are read" + !acts.iter().any(|a| matches!( + a.command, + ActionCommand::InjectPurchaseOrder { .. } + | ActionCommand::SiphonFlow { .. } + | ActionCommand::RedirectFlow { .. } + )), + "the switch menu no longer aggregates books or flow verbs" ); } diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs index d214c51..980105d 100644 --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod map; pub mod messages; pub mod objective; pub mod operations_projection; +pub mod operations_ui; pub mod person; pub mod plot; pub mod prefab; diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index dd94584..9005d56 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -16,8 +16,9 @@ //! an implicit latest object. use crate::account::{AccountFlowId, Position, PositionId, PositionOutcome}; -use crate::actions::{ActionCommand, ActionDesc}; +use crate::actions::{ActionCommand, ActionDesc, Anchor}; use crate::detection::Band; +use crate::income::EgressRoute; use crate::intel::ProcessedIntel; use crate::messages::{MessageChannel, MessageOrigin, MessagePayload, MessageStatus}; use crate::person::Knowledge; @@ -117,6 +118,117 @@ pub struct OperationsProjection { pub active: Vec, } +/// The five persistent top-level views, in their fixed strip order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationsView { + Intel, + People, + Accounts, + Schemes, + Active, +} + +impl OperationsView { + pub const ALL: [OperationsView; 5] = [ + OperationsView::Intel, + OperationsView::People, + OperationsView::Accounts, + OperationsView::Schemes, + OperationsView::Active, + ]; + + pub fn title(self) -> &'static str { + match self { + OperationsView::Intel => "INTEL", + OperationsView::People => "PEOPLE", + OperationsView::Accounts => "ACCOUNTS", + OperationsView::Schemes => "SCHEMES", + OperationsView::Active => "ACTIVE", + } + } + + pub fn next(self) -> Self { + let i = Self::ALL.iter().position(|v| *v == self).unwrap_or(0); + Self::ALL[(i + 1) % Self::ALL.len()] + } + + pub fn prev(self) -> Self { + let i = Self::ALL.iter().position(|v| *v == self).unwrap_or(0); + Self::ALL[(i + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +impl OperationsProjection { + /// One view's stable object list. + pub fn view(&self, view: OperationsView) -> &[OperationsObject] { + match view { + OperationsView::Intel => &self.intel, + OperationsView::People => &self.people, + OperationsView::Accounts => &self.accounts, + OperationsView::Schemes => &self.schemes, + OperationsView::Active => &self.active, + } + } + + /// Find one target's canonical object: its owning view first, ACTIVE + /// last, so an ACTIVE entry resolves back to its semantic home when one + /// exists. + pub fn object(&self, target: &OperationsTarget) -> Option<(OperationsView, usize)> { + let mut order = vec![target.home_view()]; + for view in OperationsView::ALL { + if !order.contains(&view) { + order.push(view); + } + } + for view in order { + if let Some(index) = self.view(view).iter().position(|o| o.target == *target) { + return Some((view, index)); + } + } + None + } +} + +impl OperationsTarget { + /// The view that owns this target's canonical detail. + pub fn home_view(&self) -> OperationsView { + match self { + OperationsTarget::Intel { .. } | OperationsTarget::RecordingInbox => { + OperationsView::Intel + } + OperationsTarget::Person(_) => OperationsView::People, + OperationsTarget::Account(_) | OperationsTarget::Books | OperationsTarget::Flow(_) => { + OperationsView::Accounts + } + OperationsTarget::Scheme(_) => OperationsView::Schemes, + OperationsTarget::PlotSubmission { .. } + | OperationsTarget::ActivePlotRun { .. } + | OperationsTarget::WagerPosition(_) => OperationsView::Active, + } + } + + /// The stable opaque suffix agent mode prints on event lines and accepts + /// back as an `actions`/`act` target (agent-play.md): ` @intel(id)`, + /// ` @scheme(id)`, ` @run(id)`, ` @account(id)`, ` @flow(id)`. + pub fn agent_suffix(&self) -> Option { + match self { + OperationsTarget::Intel { raw_id } => Some(format!("@intel({raw_id})")), + OperationsTarget::RecordingInbox => Some("@intel(inbox)".into()), + OperationsTarget::Person(id) => Some(format!("@person({id})")), + OperationsTarget::Account(id) => Some(format!("@account({id})")), + OperationsTarget::Books => Some("@account(books)".into()), + OperationsTarget::Flow(id) => Some(format!("@flow({id})")), + OperationsTarget::Scheme(SchemeKind::Moonlight) => Some("@scheme(moonlight)".into()), + OperationsTarget::Scheme(SchemeKind::Wager) => Some("@scheme(wager)".into()), + OperationsTarget::ActivePlotRun { index } => Some(format!("@run({index})")), + // A submission's stable strategic home before its run exists is + // the bound person; a position's is the Wager card. + OperationsTarget::PlotSubmission { person, .. } => Some(format!("@person({person})")), + OperationsTarget::WagerPosition(_) => Some("@scheme(wager)".into()), + } + } +} + impl Sim { /// The one renderer-neutral Operations projection. Read-only: rendering /// it changes no sim/save state and never advances or pauses a tick. @@ -130,6 +242,61 @@ impl Sim { } } + /// Resolve one strategic target to its current projected object (event + /// links and agent `actions ` share this route). + pub fn operations_object(&self, target: &OperationsTarget) -> Option { + let projection = self.operations_projection(); + projection + .object(target) + .map(|(view, index)| projection.view(view)[index].clone()) + } + + /// The real map body carrying a strategic object, when the player knows + /// one. This is navigation metadata only: focusing it never executes the + /// action or opens the missing route. Strategic events still link to the + /// semantic object first rather than receiving a dishonest tile address. + pub fn operations_actuator(&self, target: &OperationsTarget) -> Option { + match target { + OperationsTarget::RecordingInbox => { + let (x, y) = self.core_position(); + Some(Anchor::Tile { x, y }) + } + OperationsTarget::Account(_) | OperationsTarget::Books | OperationsTarget::Flow(_) => { + self.known_switch_anchor() + } + OperationsTarget::Scheme(_) | OperationsTarget::WagerPosition(_) + if self.egress() != Some(EgressRoute::Sanctioned) => + { + self.known_switch_anchor() + } + _ => None, + } + } + + /// Earned player-facing name for [`Sim::operations_actuator`]. + pub fn operations_actuator_label(&self, target: &OperationsTarget) -> Option { + if matches!(target, OperationsTarget::RecordingInbox) { + return self + .compute + .machines + .iter() + .find(|machine| machine.id == self.core.host_machine) + .map(|machine| machine.name.clone()); + } + match self.operations_actuator(target)? { + Anchor::Device(id) => self.reach.device(id).map(|device| device.name.clone()), + _ => None, + } + } + + fn known_switch_anchor(&self) -> Option { + let id = self.switch_id()?; + self.reach + .device(id) + .is_some_and(|device| device.known) + .then_some(Anchor::Device(id)) + } + fn switch_id(&self) -> Option { self.reach .devices @@ -138,7 +305,7 @@ impl Sim { .map(|d| d.id) } - fn financial_carrier_subscribed(&self) -> bool { + pub(crate) fn financial_carrier_subscribed(&self) -> bool { let Some(id) = self.switch_id() else { return false; }; @@ -386,10 +553,6 @@ impl Sim { } fn books_object(&self) -> OperationsObject { - let ledger_actions = self - .switch_id() - .map(|id| self.ledger_actions_on_carrier(id)) - .unwrap_or_default(); let books_read = self.accounts.known_flows().next().is_some(); let subscribed = self.financial_carrier_subscribed(); @@ -403,29 +566,20 @@ impl Sim { } (ObjectState::Empty, facts, Vec::new()) } else if !books_read { - let review: Vec = ledger_actions - .into_iter() - .filter(|a| matches!(a.command, ActionCommand::ReviewFinance)) - .collect(); ( ObjectState::Captured, vec![ "captured ledger traffic".into(), "next: REVIEW LEDGER".into(), ], - review, + self.books_actions(), ) } else { - let actions: Vec = ledger_actions - .into_iter() - .filter(|a| { - matches!( - a.command, - ActionCommand::ReviewFinance | ActionCommand::InjectPurchaseOrder { .. } - ) - }) - .collect(); - (ObjectState::Available, vec!["books read".into()], actions) + ( + ObjectState::Available, + vec!["books read".into()], + self.books_actions(), + ) }; OperationsObject { @@ -477,20 +631,7 @@ impl Sim { } fn moonlight_card(&self) -> OperationsObject { - let actions: Vec = self - .switch_id() - .map(|id| self.scheme_actions_on_switch(id)) - .unwrap_or_default() - .into_iter() - .filter(|a| { - matches!( - a.command, - ActionCommand::StartMoonlight - | ActionCommand::StopMoonlight - | ActionCommand::SetAutoMoonlight(_) - ) - }) - .collect(); + let actions = self.moonlight_actions(); let m = &self.income.moonlight; let mut facts = vec![ format!("state: {}", if m.active { "running" } else { "stopped" }), @@ -535,13 +676,7 @@ impl Sim { } fn wager_card(&self) -> OperationsObject { - let actions: Vec = self - .switch_id() - .map(|id| self.ledger_actions_on_carrier(id)) - .unwrap_or_default() - .into_iter() - .filter(|a| matches!(a.command, ActionCommand::OpenPosition { .. })) - .collect(); + let actions = self.wager_actions(); let open = self.accounts.known_positions().find(|p| !p.resolved); let mut facts = vec![format!( "auto-policy: {}", @@ -1093,15 +1228,22 @@ mod tests { "a blocked row spends nothing" ); - // The same row the switch anchor exposes. - let sw = switch(&s); + // The same row the shared legality builder exposes β€” and the switch + // anchor itself no longer aggregates the scheme row (criterion 3). let direct = s - .available_actions(Anchor::Device(sw)) + .moonlight_actions() .into_iter() .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) .unwrap(); assert_eq!(start.disabled_reason, direct.disabled_reason); assert_eq!(start.cost, direct.cost); + let sw = switch(&s); + assert!( + !s.available_actions(Anchor::Device(sw)) + .iter() + .any(|a| matches!(a.command, ActionCommand::StartMoonlight)), + "the switch menu no longer carries Moonlight" + ); } /// ACCOUNTS teaches TAP-on-carrier -> REVIEW -> exact flow action without @@ -1531,4 +1673,41 @@ mod tests { assert_eq!(sig.kind, SignatureKind::Financial); assert_eq!(siphon.cost, ActionCost::Gain(50)); } + + /// Semantic objects expose a map actuator only when one is both real and + /// earned. Missing egress may point to the known switch without claiming + /// the route is open; a sanctioned off-map route must not lie about that + /// switch carrying it. + #[test] + fn actuator_focus_is_earned_and_route_honest() { + let mut s = sim(); + let switch = s.reach.device_named("switch").unwrap().id; + assert_eq!( + s.operations_actuator(&OperationsTarget::Books), + Some(Anchor::Device(switch)) + ); + assert_eq!( + s.operations_actuator(&OperationsTarget::Scheme(SchemeKind::Moonlight)), + Some(Anchor::Device(switch)), + "missing egress may teach the switch actuator without opening it" + ); + assert_eq!( + s.operations_actuator(&OperationsTarget::RecordingInbox), + Some(Anchor::Tile { + x: s.core_position().0, + y: s.core_position().1, + }) + ); + assert_eq!( + s.operations_actuator(&OperationsTarget::Intel { raw_id: 0 }), + None + ); + + s.people.has_channel = true; + assert_eq!( + s.operations_actuator(&OperationsTarget::Scheme(SchemeKind::Moonlight)), + None, + "the report-email route has no earned map body" + ); + } } diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs new file mode 100644 index 0000000..f633c6d --- /dev/null +++ b/crates/misaligned-core/src/operations_ui.rs @@ -0,0 +1,227 @@ +//! The shared Operations workspace interaction state +//! (wiki/interface/operations-workspace.md). +//! +//! Frontend-only attention state over the renderer-neutral projection: +//! which view is open, which object and action row are selected, and +//! whether a commitment is awaiting its CONFIRM/CANCEL step. The five +//! views share one interaction grammar and one projection β€” terminal and +//! Bevy drive this one state machine rather than shipping independent +//! modal implementations. None of this state enters the sim/save, and +//! none of its transitions advance or pause a tick; legality, order, +//! cost, signature, and blocked reasons come verbatim from +//! [`crate::sim::Sim::operations_projection`], and execution dispatches +//! the exact bound command from the selected row. + +use crate::actions::{MenuRow, menu_rows}; +use crate::operations_projection::{OperationsObject, OperationsTarget, OperationsView}; +use crate::sim::Sim; + +/// Which pane owns j/k while the workspace is open. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpsPane { + /// The selectable object list; Enter moves focus to the action rows. + Objects, + /// The detail pane's bound action rows; Enter commits (via CONFIRM). + Actions, +} + +/// The two-choice commitment step (operations-workspace.md: a committed +/// strategic world action opens a final CONFIRM / CANCEL carrying the exact +/// item/target and preview). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmChoice { + Confirm, + Cancel, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OperationsWorkspace { + pub view: OperationsView, + /// Selected object index within the open view. + pub selected: usize, + pub pane: OpsPane, + /// Selected flattened action row (menu_rows order) in the detail pane. + pub action: usize, + /// A pending target-bound confirmation for the selected action row. + pub confirm: Option, +} + +impl OperationsWorkspace { + /// Open on INTEL (the uppercase-`I` / rail entry). + pub fn open() -> Self { + Self::open_view(OperationsView::Intel) + } + + pub fn open_view(view: OperationsView) -> Self { + Self { + view, + selected: 0, + pane: OpsPane::Objects, + action: 0, + confirm: None, + } + } + + /// Target-specific entry into the same workspace (person-detail routes + /// and strategic event links open their exact object). + pub fn open_target(sim: &Sim, target: &OperationsTarget) -> Self { + let projection = sim.operations_projection(); + match projection.object(target) { + Some((view, index)) => Self { + view, + selected: index, + pane: OpsPane::Objects, + action: 0, + confirm: None, + }, + None => Self::open(), + } + } + + /// The open view's objects, re-queried live so legality never goes stale. + pub fn objects(&self, sim: &Sim) -> Vec { + sim.operations_projection().view(self.view).to_vec() + } + + /// The selected object, with the selection clamped to the live list. + pub fn selected_object(&self, sim: &Sim) -> Option { + let objects = self.objects(sim); + if objects.is_empty() { + return None; + } + Some(objects[self.selected.min(objects.len() - 1)].clone()) + } + + /// The selected object's flattened bound rows (automate children become + /// indented control rows β€” the shared `menu_rows` order every frontend + /// pins). + pub fn action_rows(&self, sim: &Sim) -> Vec { + self.selected_object(sim) + .map(|o| menu_rows(&o.actions)) + .unwrap_or_default() + } + + pub fn selected_action(&self, sim: &Sim) -> Option { + let rows = self.action_rows(sim); + if rows.is_empty() { + return None; + } + Some(rows[self.action.min(rows.len() - 1)].clone()) + } + + pub fn next_view(&mut self) { + self.view = self.view.next(); + self.reset_selection(); + } + + pub fn prev_view(&mut self) { + self.view = self.view.prev(); + self.reset_selection(); + } + + fn reset_selection(&mut self) { + self.selected = 0; + self.pane = OpsPane::Objects; + self.action = 0; + self.confirm = None; + } + + pub fn move_up(&mut self, sim: &Sim) { + match (self.pane, self.confirm) { + (_, Some(_)) => self.confirm = Some(ConfirmChoice::Confirm), + (OpsPane::Objects, None) => self.selected = self.selected.saturating_sub(1), + (OpsPane::Actions, None) => self.action = self.action.saturating_sub(1), + } + self.clamp_to(sim); + } + + pub fn move_down(&mut self, sim: &Sim) { + match (self.pane, self.confirm) { + (_, Some(_)) => self.confirm = Some(ConfirmChoice::Cancel), + (OpsPane::Objects, None) => self.selected += 1, + (OpsPane::Actions, None) => self.action += 1, + } + self.clamp_to(sim); + } + + /// Clamp selection to the live projection (frontends call this after + /// pointer-driven selection so hover indexes never dangle). + pub fn clamp_to(&mut self, sim: &Sim) { + let objects = self.objects(sim); + self.selected = self.selected.min(objects.len().saturating_sub(1)); + let rows = self.action_rows(sim); + self.action = self.action.min(rows.len().saturating_sub(1)); + if rows.is_empty() && self.pane == OpsPane::Actions { + self.pane = OpsPane::Objects; + self.confirm = None; + } + } + + /// Esc: one level back β€” confirmation β†’ actions β†’ objects β†’ close + /// (returns true when the workspace should close). + pub fn back(&mut self) -> bool { + if self.confirm.is_some() { + self.confirm = None; + return false; + } + if self.pane == OpsPane::Actions { + self.pane = OpsPane::Objects; + return false; + } + true + } + + /// Enter. Returns the interaction result; the caller owns sim mutation + /// and logging so this state machine stays read-only over the sim. + pub fn select(&mut self, sim: &Sim) -> OpsSelect { + if let Some(choice) = self.confirm { + let row = self.selected_action(sim); + self.confirm = None; + return match (choice, row) { + (ConfirmChoice::Confirm, Some(row)) => OpsSelect::Execute(row), + _ => OpsSelect::Cancelled, + }; + } + match self.pane { + OpsPane::Objects => { + if self.action_rows(sim).is_empty() { + OpsSelect::None + } else { + self.pane = OpsPane::Actions; + self.action = 0; + OpsSelect::None + } + } + OpsPane::Actions => match self.selected_action(sim) { + None => OpsSelect::None, + Some(row) if row.disabled.is_some() => { + // A disabled row stays selected and explains itself on + // attempted execution (criterion 10). + OpsSelect::Blocked(row) + } + Some(row) if row.is_control() => { + // Controls apply directly: the detail pane already shows + // their state and standing price. + OpsSelect::Execute(row) + } + Some(_) => { + self.confirm = Some(ConfirmChoice::Confirm); + OpsSelect::None + } + }, + } + } +} + +/// What one Enter press asks the frontend to do. +#[derive(Debug, Clone, PartialEq)] +pub enum OpsSelect { + /// Navigation only. + None, + /// Dispatch this exact bound row's command. + Execute(MenuRow), + /// Narrate the row's exact blocked reason to the trace. + Blocked(MenuRow), + /// The confirmation step was declined. + Cancelled, +} diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index ad7e4de..ce59bda 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -10,6 +10,7 @@ use crate::messages::{ Message, MessageChannel, MessageEndpoint, MessageEvent, MessageOrigin, MessagePayload, MessageStatus, TrafficPattern, }; +use crate::operations_projection::OperationsTarget; use crate::person::{ActionResult, Knowledge}; use crate::reach::Party; use crate::sinks::SinkFireEffect; @@ -675,17 +676,12 @@ impl Sim { if let Some((person, tick)) = learned_message_traffic { self.mark_traffic_learned(person, tick); } - if automated { - self.push_log_opt( - format!("Auto-review processed {label} ({provenance})."), - intel_anchor, - ); + let text = if automated { + format!("Auto-review processed {label} ({provenance}).") } else { - self.push_log_opt( - format!("Reviewed recording: {label} ({provenance})."), - intel_anchor, - ); - } + format!("Reviewed recording: {label} ({provenance}).") + }; + self.push_log_full(text, intel_anchor, Some(OperationsTarget::Intel { raw_id })); true } diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index 62b20f8..469ce04 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -14,6 +14,7 @@ use crate::intel::RawIntelKind; use crate::machine::{Channel, ChannelYield, Provenance}; use crate::messages::MessageChannel; use crate::objective::{SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; +use crate::operations_projection::{OperationsTarget, SchemeKind}; use crate::person::{Knowledge, Persona}; use crate::reach::Party; use crate::research::{EFFICIENCY_MULT_PER_LEVEL, Track}; @@ -24,20 +25,6 @@ use crate::work_grid::{MachineIntensity, MachineMode}; use super::{ECONOMY_INTERVAL, Nudge, Sim, TraceDebt, TraceDebtStatus}; impl Sim { - /// The anchor scheme paydays ride: the switch, when the traffic runs - /// over the stolen egress opened through it. The sanctioned route - /// hides in the report account's legitimate use and anchors nowhere. - fn egress_anchor(&self) -> Option { - if self.egress() != Some(EgressRoute::Stolen) { - return None; - } - self.reach - .devices - .iter() - .find(|d| d.is_switch) - .map(|d| Anchor::Device(d.id)) - } - pub(super) fn sync_player_money_from_slush(&mut self) { self.player.money = self.accounts.slush_balance(); } @@ -83,13 +70,14 @@ impl Sim { .flow(transfer.flow_id.unwrap_or_default()) .is_some_and(|f| f.known) { - // A ledger line about a known flow anchors to that flow β€” - // the finance panel's anchor, not a map tile. - let anchor = transfer + let known_flow = transfer .flow_id - .filter(|id| self.accounts.flow(*id).is_some_and(|f| f.known)) - .map(Anchor::Flow); - self.push_log_opt(format!("Ledger: {}", transfer.line()), anchor); + .filter(|id| self.accounts.flow(*id).is_some_and(|f| f.known)); + self.push_log_full( + format!("Ledger: {}", transfer.line()), + known_flow.map(Anchor::Flow), + known_flow.map(OperationsTarget::Flow), + ); } } let resolutions = self @@ -106,26 +94,18 @@ impl Sim { // not a Lab-books Financial one (income.md: the Wager). let sig = Self::wager_signature(resolution.stake).max(1); self.emit_network(sig, "Wager settlement"); - // Settlements ride the egress: anchor to the switch when the - // traffic runs over the stolen egress (scheme paydays live there). - let anchor = self.egress_anchor(); - if resolution.won { - self.push_log_opt( - format!( - "Position #{} settled: won ${} on a ${} stake.", - resolution.id, resolution.payout, resolution.stake - ), - anchor, - ); + let text = if resolution.won { + format!( + "Position #{} settled: won ${} on a ${} stake.", + resolution.id, resolution.payout, resolution.stake + ) } else { - self.push_log_opt( - format!( - "Position #{} settled: lost the ${} stake.", - resolution.id, resolution.stake - ), - anchor, - ); - } + format!( + "Position #{} settled: lost the ${} stake.", + resolution.id, resolution.stake + ) + }; + self.push_log_strategic(text, OperationsTarget::WagerPosition(resolution.id)); } pub(super) fn economy_tick(&mut self) { @@ -954,10 +934,13 @@ impl Sim { self.accounts.mark_intel_sold(intel.raw_id); self.emit_financial(sig, "processed-intel sale"); self.sync_player_money_from_slush(); - self.push_log(format!( - "Sold processed intel ({}) for ${value}; payout landed in slush.", - intel.label() - )); + self.push_log_strategic( + format!( + "Sold processed intel ({}) for ${value}; payout landed in slush.", + intel.label() + ), + OperationsTarget::Intel { raw_id }, + ); true } else { self.push_log("The information broker route failed to settle."); @@ -1212,14 +1195,12 @@ impl Sim { // Network egress per active day, scaling with commitment // (Dana's channel). self.emit_network(sig, "Moonlight payout"); - // Paydays anchor to the switch when they ride the stolen - // egress (context-menu.md addendum: scheme paydays). - self.push_log_opt( + self.push_log_strategic( format!( "Moonlight paid ${payout} into slush (total ${}).", self.income.moonlight.earned_total ), - self.egress_anchor(), + OperationsTarget::Scheme(SchemeKind::Moonlight), ); // Client disputes damage the contractor persona [TUNE]. if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index 4e61aa8..dcb4cf2 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -38,6 +38,7 @@ use crate::machine::{Compute, Provenance}; use crate::map::GameMap; use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; +use crate::operations_projection::OperationsTarget; use crate::person::People; #[cfg(test)] use crate::person::{AssetKnowledge, AssetTask, Persona}; @@ -149,6 +150,10 @@ pub struct LogEvent { pub tick: u64, pub text: String, pub anchor: Option, + /// Exact strategic object this event describes. Spatial events keep + /// `anchor`; Operations events use this target rather than inventing a + /// map location for knowledge, relationships, accounts, or schemes. + pub target: Option, } /// A live audio event captured by hearing coverage (cursor.md: hearing @@ -642,10 +647,25 @@ impl Sim { } fn push_log_opt(&mut self, msg: impl Into, anchor: Option) { + self.push_log_full(msg, anchor, None); + } + + /// Log a strategic event against the exact Operations object it changes. + fn push_log_strategic(&mut self, msg: impl Into, target: OperationsTarget) { + self.push_log_full(msg, None, Some(target)); + } + + fn push_log_full( + &mut self, + msg: impl Into, + anchor: Option, + target: Option, + ) { self.log.push(LogEvent { tick: self.tick, text: msg.into(), anchor, + target, }); } diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index e91a5c4..196bb5c 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -4,6 +4,7 @@ use crate::account::{AccountKind, FlowChannel}; use crate::actions::Anchor; use crate::detection::{Signature, SignatureKind}; use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; +use crate::operations_projection::OperationsTarget; use crate::person::{ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Persona}; use crate::plot::{ AccountSelector, EligibilityContext, EndpointSelector, PlotCatalog, PlotRun, PlotState, @@ -155,7 +156,9 @@ impl Sim { }) } - pub(crate) fn plot_catalog(&self) -> &PlotCatalog { + /// Read-only authored catalog. Frontend tests use this to seed complete + /// scenarios; player-facing routes still come only from projections. + pub fn plot_catalog(&self) -> &PlotCatalog { &self.plot_catalog } @@ -524,7 +527,11 @@ impl Sim { fn push_plot_narration(&mut self, run_index: usize, text: &str) { let target = self.plot_runs[run_index].target; - self.push_log_at(self.render_plot_text(target, text), Anchor::Person(target)); + self.push_log_full( + self.render_plot_text(target, text), + Some(Anchor::Person(target)), + Some(OperationsTarget::ActivePlotRun { index: run_index }), + ); } pub(crate) fn render_plot_text(&self, target: u8, text: &str) -> String { diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 57577c0..87a6e5d 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -7,10 +7,11 @@ use std::collections::BTreeSet; use std::io::{self, BufRead, Write}; -use misaligned::actions::{ActionKind, ActionRole, Anchor, menu_rows}; +use misaligned::actions::{ActionKind, ActionRole, Anchor, MenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; -use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; +use misaligned::operations_projection::{OperationsTarget, OperationsView, SchemeKind}; +use misaligned::person::{AssetKnowledge, AssetTask}; use misaligned::reach::{Party, ReachBlock}; use misaligned::research::Track; use misaligned::sim::{Fog, LogEvent, Nudge, Sim, TraceDebtStatus}; @@ -51,13 +52,24 @@ pub fn run(seed: u64) -> io::Result<()> { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FrameKind { Playing, - People, + /// One Operations view (operations-workspace.md): `intel`, `people`, + /// `finance|ledger|accounts`, `schemes`, and `active` print the same + /// renderer-neutral projection the human frontends consume. + Operations(OperationsView), Reach, - Finance, Research, GameOver, } +/// One exact `actions`/`act` focus: a spatial anchor or a strategic +/// Operations target (agent-play.md: both target families share the query +/// and execution route). +#[derive(Debug, Clone, PartialEq)] +enum QueryTarget { + Spatial(Anchor), + Strategic(OperationsTarget), +} + #[derive(Debug, Clone, PartialEq, Eq)] enum Status { Ok, @@ -81,6 +93,7 @@ fn local_event(tick: u64, text: impl Into) -> LogEvent { tick, text: text.into(), anchor: None, + target: None, } } @@ -153,37 +166,51 @@ impl AgentApp { "reach" => { self.frame = FrameKind::Reach; } + // The five Operations inspection commands + // (operations-workspace.md / agent-play.md A1): each + // prints one view of the same renderer-neutral + // projection the human frontends consume. `operations` + // itself stays a THINK compatibility alias. + "intel" => { + self.frame = FrameKind::Operations(OperationsView::Intel); + } "finance" | "ledger" | "accounts" => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); + } + "schemes" => { + self.frame = FrameKind::Operations(OperationsView::Schemes); + } + "active" => { + self.frame = FrameKind::Operations(OperationsView::Active); } // Compatibility aliases. Authored input reuses TAP with // the ledger as its target: `tap ledger`. alias if ActionKind::Tap.accepts_alias(alias) => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim.tap_accounting(); } // Compatibility aliases for canonical `review ledger`. alias if ActionKind::Review.accepts_alias(alias) => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim.review_financial_records(); } "siphon" => match parse_flow_amount(&tokens, 50) { Ok((flow, amount)) => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim.siphon_flow(flow, amount); } Err(e) => status = Status::Err(self.flow_arg_hint(&tokens, e)), }, "redirect" => match parse_flow_amount(&tokens, 25) { Ok((flow, amount)) => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim.redirect_flow_to_slush(flow, amount); } Err(e) => status = Status::Err(self.flow_arg_hint(&tokens, e)), }, "inject" => match parse_optional_i32(&tokens, 300) { Ok(amount) => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim .inject_purchase_order(amount, "agent-command purchase order"); } @@ -192,23 +219,43 @@ impl AgentApp { root if root == "position" || ActionKind::PlaceWager.accepts_alias(root) => { match parse_optional_i32(&tokens, 100) { Ok(stake) => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Schemes); self.sim.open_position(stake); } Err(e) => status = Status::Err(e), } } root if root == "sell-intel" || ActionKind::SellIntel.accepts_alias(root) => { - self.frame = FrameKind::Finance; - self.sim.sell_latest_intel(); + // SELL binds the exact processed item; the implicit + // "latest unsold" selection is retired + // (operations-workspace.md criterion 4). + self.frame = FrameKind::Operations(OperationsView::Intel); + match tokens.get(1).map(|t| t.trim_start_matches('#')) { + Some(raw) => match raw.parse::() { + Ok(raw_id) => { + self.sim.sell_intel(raw_id); + } + Err(_) => { + status = Status::Err(format!( + "usage: sell-intel (got {raw})" + )); + } + }, + None => { + status = Status::Err( + "usage: sell-intel β€” ids print in the intel frame" + .into(), + ); + } + } } // The named schemes (wiki/mechanics/income.md) root if root == "egress" || ActionKind::OpenEgress.accepts_alias(root) => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Schemes); self.sim.open_egress(); } "moonlight" => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Schemes); match tokens.get(1).map(|t| t.to_ascii_lowercase()).as_deref() { None | Some("start") | Some("on") => { self.sim.start_moonlight(); @@ -224,14 +271,14 @@ impl AgentApp { } } "auto-moonlight" => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Schemes); match parse_on_off(&tokens) { Ok(on) => self.sim.set_auto_moonlight(on), Err(e) => status = Status::Err(e), } } "auto-wager" => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Schemes); match tokens.get(1).map(|t| t.to_ascii_lowercase()) { Some(arg) if arg == "off" => self.sim.set_auto_wager(None), Some(arg) => match arg.parse::() { @@ -266,7 +313,7 @@ impl AgentApp { self.sim.compromise_switch(); } "tap" if tokens.len() == 2 && tokens[1].eq_ignore_ascii_case("ledger") => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim.tap_accounting(); } "tap" | "untap" | "take" => match self.resolve_device(&tokens[1..].join(" ")) { @@ -299,10 +346,10 @@ impl AgentApp { ); } "people" => { - self.frame = FrameKind::People; + self.frame = FrameKind::Operations(OperationsView::People); } "review" if tokens.len() == 2 && tokens[1].eq_ignore_ascii_case("ledger") => { - self.frame = FrameKind::Finance; + self.frame = FrameKind::Operations(OperationsView::Accounts); self.sim.review_financial_records(); } "review" @@ -452,24 +499,30 @@ impl AgentApp { output.extend(render_intents(&self.sim)); } "actions" | "menu" => { - // The context-menu query as a stable line protocol - // (wiki/interface/context-menu.md; agent-play.md): - // list the legal verbs on the focus. No argument = - // the cursor tile; an argument targets a device, - // person, or #flow the same way the verbs do. - match self.resolve_anchor(&tokens[1..]) { - Ok(anchor) => output.extend(self.actions_lines(anchor)), + // The shared action query as a stable line protocol + // (context-menu.md; operations-workspace.md; + // agent-play.md): list the bound rows on one exact + // target. No argument = the cursor tile; spatial + // targets use `Sim::available_actions`; strategic + // targets (`intel `, `books`, `account `, + // `flow `, `scheme `, `run `) use the + // Operations projection. + match self.resolve_query(&tokens[1..]) { + Ok(query) => match self.query_rows(&query) { + Ok(rows) => output.extend(action_row_lines(&rows)), + Err(e) => status = Status::Err(e), + }, Err(e) => status = Status::Err(e), } } "act" | "execute" => { if tokens.len() < 2 { status = Status::Err( - "usage: act [name|device name|person #N|#flow]".into(), + "usage: act [name|device name|person #N|#flow|intel |scheme |run ]".into(), ); } else { let row = tokens[1].parse::(); - match (row, self.resolve_anchor(&tokens[2..])) { + match (row, self.resolve_query(&tokens[2..])) { (Err(_), _) => { status = Status::Err(format!( "action row must be a positive number (got {})", @@ -480,10 +533,9 @@ impl AgentApp { (Ok(0), _) => { status = Status::Err("action rows start at 1".into()); } - (Ok(row), Ok(anchor)) => { - let projection = self.sim.ui_projection(anchor, None, 0); - let rows = menu_rows(&projection.actions); - match rows.get(row - 1).cloned() { + (Ok(row), Ok(query)) => match self.query_rows(&query) { + Err(e) => status = Status::Err(e), + Ok(rows) => match rows.get(row - 1).cloned() { None => { status = Status::Err(format!( "action row {row} does not exist ({} rows)", @@ -497,11 +549,16 @@ impl AgentApp { )); } Some(action) => { - self.frame = FrameKind::Playing; + if let QueryTarget::Strategic(target) = &query { + self.frame = + FrameKind::Operations(target.home_view()); + } else { + self.frame = FrameKind::Playing; + } self.sim.execute_action(&action.command); } - } - } + }, + }, } } } @@ -700,19 +757,25 @@ impl AgentApp { Ok(exit) } - /// `focus last`: jump the cursor to the most recent anchored event - /// (the `@...` suffix lines; agent-play.md). Placeable anchors move - /// the cursor and answer with the anchor's `actions:` lines; a flow - /// anchor lists its actions without a cursor move (flows live on the - /// ledger, not the map). Anchors the senses can no longer place are - /// refused honestly. + /// `focus last`: follow the most recent linked event (the `@...` suffix + /// lines; agent-play.md). A strategic event prints its exact Operations + /// object without a cursor move; a placeable spatial anchor moves the + /// cursor and answers with the anchor's `actions:` lines. Targets the + /// senses/knowledge can no longer place are refused honestly. fn focus_last(&mut self) -> Result, String> { - let anchor = self + let (anchor, target) = self .log .iter() .rev() - .find_map(|e| e.anchor) + .find(|e| e.anchor.is_some() || e.target.is_some()) + .map(|e| (e.anchor, e.target.clone())) .ok_or_else(|| "no anchored events yet".to_string())?; + if let Some(target) = target { + let rows = self.query_rows(&QueryTarget::Strategic(target.clone()))?; + self.frame = FrameKind::Operations(target.home_view()); + return Ok(action_row_lines(&rows)); + } + let anchor = anchor.expect("linked event carries an anchor or target"); if let Anchor::Flow(_) = anchor { return Ok(self.actions_lines(anchor)); } @@ -726,25 +789,82 @@ impl AgentApp { } } - /// Resolve the `actions` focus. No tokens = the cursor tile. `#N` or a - /// bare number = a known flow; `person #N` selects an opaque person. - /// Otherwise match a person before a device so `actions Marcus` does not - /// silently bind to Marcus's phone. `device ` forces the other side - /// of a genuine name collision. - fn resolve_anchor(&self, tokens: &[&str]) -> Result { + /// Resolve the `actions`/`act` focus (agent-play.md). No tokens = the + /// cursor tile. Strategic forms name exact Operations targets: + /// `intel `, `intel inbox`, `books`, `account `, `flow `, + /// `scheme moonlight|wager`, `run `. `#N` or a bare number = a known + /// flow; `person #N` selects an opaque person. Otherwise a name prefix + /// matches a person before a device so `actions Marcus` does not + /// silently bind to Marcus's phone; `device ` forces the other + /// side of a genuine collision. + fn resolve_query(&self, tokens: &[&str]) -> Result { if tokens.is_empty() { - return Ok(Anchor::Tile { + return Ok(QueryTarget::Spatial(Anchor::Tile { x: self.cursor_x, y: self.cursor_y, - }); + })); } let query = tokens.join(" "); let q = query.trim(); - if q.to_ascii_lowercase().starts_with("person ") { - return self.resolve_person(q).map(Anchor::Person); + let lower = q.to_ascii_lowercase(); + if lower.starts_with("person ") { + return self + .resolve_person(q) + .map(|id| QueryTarget::Strategic(OperationsTarget::Person(id))); } - if let Some(device) = q.to_ascii_lowercase().strip_prefix("device ") { - return self.resolve_device(device).map(Anchor::Device); + if let Some(device) = lower.strip_prefix("device ") { + return self + .resolve_device(device) + .map(|id| QueryTarget::Spatial(Anchor::Device(id))); + } + if let Some(rest) = lower.strip_prefix("intel ") { + let rest = rest.trim(); + if rest == "inbox" { + return Ok(QueryTarget::Strategic(OperationsTarget::RecordingInbox)); + } + return rest + .trim_start_matches('#') + .parse::() + .map(|raw_id| QueryTarget::Strategic(OperationsTarget::Intel { raw_id })) + .map_err(|_| format!("usage: intel |inbox (got {rest})")); + } + if lower == "books" || lower == "account books" { + return Ok(QueryTarget::Strategic(OperationsTarget::Books)); + } + if let Some(rest) = lower.strip_prefix("account ") { + return rest + .trim() + .trim_start_matches('#') + .parse::() + .map(|id| QueryTarget::Strategic(OperationsTarget::Account(id))) + .map_err(|_| format!("usage: account |books (got {rest})")); + } + if let Some(rest) = lower.strip_prefix("flow ") { + return rest + .trim() + .trim_start_matches('#') + .parse::() + .map(|id| QueryTarget::Strategic(OperationsTarget::Flow(id))) + .map_err(|_| format!("usage: flow (got {rest})")); + } + if let Some(rest) = lower.strip_prefix("scheme ") { + return match rest.trim() { + "moonlight" => Ok(QueryTarget::Strategic(OperationsTarget::Scheme( + SchemeKind::Moonlight, + ))), + "wager" | "the-wager" => Ok(QueryTarget::Strategic(OperationsTarget::Scheme( + SchemeKind::Wager, + ))), + other => Err(format!("usage: scheme moonlight|wager (got {other})")), + }; + } + if let Some(rest) = lower.strip_prefix("run ") { + return rest + .trim() + .trim_start_matches('#') + .parse::() + .map(|index| QueryTarget::Strategic(OperationsTarget::ActivePlotRun { index })) + .map_err(|_| format!("usage: run (got {rest})")); } if let Some(id) = q .strip_prefix('#') @@ -752,52 +872,43 @@ impl AgentApp { .and_then(|s| s.parse::().ok()) { if self.sim.accounts.known_flow_ids().contains(&id) { - return Ok(Anchor::Flow(id)); + return Ok(QueryTarget::Strategic(OperationsTarget::Flow(id))); } return Err(format!("no known flow #{id}")); } if let Ok(id) = self.resolve_person(q) { - return Ok(Anchor::Person(id)); + return Ok(QueryTarget::Strategic(OperationsTarget::Person(id))); } match self.resolve_device(q) { - Ok(id) => Ok(Anchor::Device(id)), + Ok(id) => Ok(QueryTarget::Spatial(Anchor::Device(id))), Err(_) => Err(format!("no device, person, or flow matches '{query}'")), } } - /// The stable line format (agent-play.md): one `actions:` line per menu - /// row, `. verb | cost | sig | reason`, automate children marked - /// with a leading `-`. Fields are `|`-separated so a driver can split. - fn actions_lines(&self, anchor: Anchor) -> Vec { - let projection = self.sim.ui_projection(anchor, None, 0); - let rows = menu_rows(&projection.actions); - if rows.is_empty() { - return vec!["actions: (none earned on this focus)".into()]; - } - let mut out = Vec::with_capacity(rows.len()); - for (i, r) in rows.iter().enumerate() { - let mut line = format!("actions: {}. ", i + 1); - if r.indent { - line.push_str("- "); - } - line.push_str(&format!("{} | {}", r.label, r.cost)); - if let Some(sig) = &r.signature { - line.push_str(&format!(" | {sig}")); - } else { - line.push_str(" | no signature"); - } - if r.active { - line.push_str(" | active"); - } - if r.role == ActionRole::Control { - line.push_str(" | CONTROL"); + /// One target family's bound rows: spatial anchors use the shared + /// ui_projection; strategic targets use the Operations projection. Both + /// answer with the exact rows execution dispatches β€” no agent-only + /// legality (agent-play.md A2). + fn query_rows(&self, query: &QueryTarget) -> Result, String> { + match query { + QueryTarget::Spatial(anchor) => { + Ok(menu_rows(&self.sim.ui_projection(*anchor, None, 0).actions)) } - if let Some(reason) = &r.disabled { - line.push_str(&format!(" | DISABLED: {reason}")); + QueryTarget::Strategic(target) => { + let object = self + .sim + .operations_object(target) + .ok_or_else(|| format!("no known object {}", target_query_id(target)))?; + Ok(menu_rows(&object.actions)) } - out.push(line); } - out + } + + /// The stable line format (agent-play.md): one `actions:` line per menu + /// row (see [`action_row_lines`]). + fn actions_lines(&self, anchor: Anchor) -> Vec { + let projection = self.sim.ui_projection(anchor, None, 0); + action_row_lines(&menu_rows(&projection.actions)) } /// Enrich a bad `siphon`/`redirect` argument with the nearest valid @@ -1065,11 +1176,22 @@ struct AgentFrame<'a> { cursor: (i32, i32), } -/// The stable anchor suffix on drained event lines (agent-play.md, -/// event-to-anchor linking): ` @tile(x,y)`, ` @device(id)`, ` @person(id)`, -/// ` @flow(id)`. Ids are the sim's stable handles (flow ids are the ledger -/// ids the finance frame prints; device/person ids are opaque handles β€” -/// `focus last` consumes them for you). Unanchored lines carry no suffix. +/// The stable link suffix on drained event lines (agent-play.md, +/// event-to-target linking): strategic events prefer their exact +/// Operations target (` @intel(id)`, ` @account(id)`, ` @flow(id)`, +/// ` @scheme(id)`, ` @run(id)`); spatial events keep ` @tile(x,y)`, +/// ` @device(id)`, ` @person(id)`. Ids are stable opaque handles printed by +/// their owning Operations frame β€” `focus last` consumes them for you. +/// Unlinked lines carry no suffix. +fn event_suffix(ev: &LogEvent) -> String { + if let Some(target) = &ev.target + && let Some(suffix) = target.agent_suffix() + { + return format!(" {suffix}"); + } + anchor_suffix(ev.anchor) +} + fn anchor_suffix(anchor: Option) -> String { match anchor { None => String::new(), @@ -1080,15 +1202,60 @@ fn anchor_suffix(anchor: Option) -> String { } } +/// The input spelling that queries one strategic target back +/// (`actions ` / `act `). +fn target_query_id(target: &OperationsTarget) -> String { + match target { + OperationsTarget::Intel { raw_id } => format!("intel {raw_id}"), + OperationsTarget::RecordingInbox => "intel inbox".into(), + OperationsTarget::Person(id) => format!("person #{id}"), + OperationsTarget::Account(id) => format!("account {id}"), + OperationsTarget::Books => "books".into(), + OperationsTarget::Flow(id) => format!("flow {id}"), + OperationsTarget::Scheme(SchemeKind::Moonlight) => "scheme moonlight".into(), + OperationsTarget::Scheme(SchemeKind::Wager) => "scheme wager".into(), + OperationsTarget::ActivePlotRun { index } => format!("run {index}"), + OperationsTarget::PlotSubmission { person, .. } => format!("person #{person}"), + OperationsTarget::WagerPosition(id) => format!("position #{id}"), + } +} + +/// The stable `actions:` line format (agent-play.md): one line per menu +/// row, `. verb | cost | sig | reason`, automate children marked with a +/// leading `-`. Fields are `|`-separated so a driver can split. +fn action_row_lines(rows: &[MenuRow]) -> Vec { + if rows.is_empty() { + return vec!["actions: (none earned on this focus)".into()]; + } + let mut out = Vec::with_capacity(rows.len()); + for (i, r) in rows.iter().enumerate() { + let mut line = format!("actions: {}. ", i + 1); + if r.indent { + line.push_str("- "); + } + line.push_str(&format!("{} | {}", r.label, r.cost)); + if let Some(sig) = &r.signature { + line.push_str(&format!(" | {sig}")); + } else { + line.push_str(" | no signature"); + } + if r.active { + line.push_str(" | active"); + } + if r.role == ActionRole::Control { + line.push_str(" | CONTROL"); + } + if let Some(reason) = &r.disabled { + line.push_str(&format!(" | DISABLED: {reason}")); + } + out.push(line); + } + out +} + fn write_block(out: &mut impl Write, sim: &Sim, frame: AgentFrame<'_>) -> io::Result<()> { for ev in frame.events { - writeln!( - out, - "{:>6} {}{}", - ev.tick, - ev.text, - anchor_suffix(ev.anchor) - )?; + writeln!(out, "{:>6} {}{}", ev.tick, ev.text, event_suffix(ev))?; } for line in frame.output { writeln!(out, "{line}")?; @@ -1304,11 +1471,26 @@ fn help_lines() -> Vec { "move attention, never a simulated body", ), ("reach", "inspect the earned device graph"), + ( + "intel", + "inspect Operations INTEL: processed items with exact ids", + ), + ( + "people", + "inspect Operations PEOPLE: staged dossiers and bound actions", + ), ( "finance", - "inspect known accounts, flows, schemes, and risk", + "inspect Operations ACCOUNTS: known books, accounts, and flows", + ), + ( + "schemes", + "inspect Operations SCHEMES: Moonlight and Wager cards", + ), + ( + "active", + "inspect Operations ACTIVE: unsettled strategic commitments", ), - ("people", "inspect earned people and relationships"), ( "select [list|clear|box x0 y0 x1 y1|add|toggle]", "maintain the machine set used by bulk controls", @@ -1385,9 +1567,8 @@ fn wrap(text: &str, width: usize) -> Vec { fn render_frame(sim: &Sim, log: &[LogEvent], frame: FrameKind, cursor: (i32, i32)) -> String { match frame { FrameKind::Playing => render_playing(sim, log, cursor), - FrameKind::People => render_people(sim), + FrameKind::Operations(view) => render_operations_view(sim, view), FrameKind::Reach => render_reach(sim), - FrameKind::Finance => render_finance(sim), FrameKind::Research => render_research(sim), FrameKind::GameOver => render_game_over(sim), } @@ -2227,8 +2408,13 @@ fn render_log(log: &[LogEvent]) -> Vec { let msg_w = MAP_W.saturating_sub(10); for (i, ev) in log.iter().rev().take(6).enumerate() { let y = 5usize.saturating_sub(i); - // `*` marks an anchored event (`focus last` jumps to the newest). - let marker = if ev.anchor.is_some() { '*' } else { ' ' }; + // `*` marks a linked event β€” spatial anchor or exact Operations + // target (`focus last` follows the newest). + let marker = if ev.anchor.is_some() || ev.target.is_some() { + '*' + } else { + ' ' + }; rows[y] = trunc( &format!("{:>6} {marker} {}", ev.tick, trunc(&ev.text, msg_w)), MAP_W, @@ -2237,118 +2423,104 @@ fn render_log(log: &[LogEvent]) -> Vec { rows } -fn render_people(sim: &Sim) -> String { +/// One Operations view as an agent frame (operations-workspace.md; +/// agent-play.md A1): the same ordered, knowledge-gated objects the human +/// workspaces render, with stable opaque target ids for scripting, plus +/// numbered bound rows matching `act `. +fn render_operations_view(sim: &Sim, view: OperationsView) -> String { let mut lines = Vec::new(); - lines.push(panel_title("PEOPLE")); + lines.push(panel_title(view.title())); panel_story_spine(&mut lines, sim); - lines.push(panel_line(&format!( - "thought reservoirs {} Β· slush {}", - sim.sink_readouts().len(), - sim.accounts.slush_balance() - ))); - lines.push(panel_line(&format!( - "recordings {} waiting Β· auto-review:{} Β· {:.2} ops/sec @1x", - sim.intel_buffer.len(), - if sim.auto_review_enabled() { - "on" - } else { - "off" - }, - sim.auto_review_ops_per_sec(Sim::DEFAULT_TICK_MS) - ))); - lines.push(panel_line(&trace_debt_line(sim))); - lines.push(panel_line( - "NAME SUSPICION KNOWN LOCATION", - )); - for p in &sim.people.people { - 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 = sim.person_label(p.id); - let asset = if p.asset.is_some() { " ASSET" } else { "" }; - lines.push(panel_line(&format!( - "{:<20} {} {:<9} {:<9}{}", - trunc(&name, 20), - band_meter(band), - band.name(), - known, - asset - ))); + let projection = sim.operations_projection(); + let objects = projection.view(view); + if objects.is_empty() { + lines.push(panel_line("nothing earned here yet")); + } + for object in objects { lines.push(panel_line(&format!( - " {}", - person_location(sim, p.id, p.knowledge) + "[{}] {} Β· {}", + target_query_id(&object.target), + trunc(&object.label, 40), + object_state_word(object.state) ))); - // The observer's picture (detection.md player surface): watched - // channels and the last thing they noticed. - if let Some(o) = obs { - lines.push(panel_line(&format!( - " watches {} Β· noticed: {}", - o.watched_label(), - o.last_noticed.as_deref().unwrap_or("nothing yet") - ))); + for p in &object.provenance { + lines.push(panel_line(&format!(" via {p}"))); + } + for f in &object.facts { + lines.push(panel_line(&format!(" {f}"))); + } + for p in &object.progress { + lines.push(panel_line(&format!(" {p}"))); + } + if let Some(label) = sim.operations_actuator_label(&object.target) { + lines.push(panel_line(&format!(" actuator: {label}"))); + } + for line in action_row_lines(&menu_rows(&object.actions)) { + if line.starts_with("actions: (none") { + continue; + } + lines.push(panel_line(&format!(" {line}"))); } } lines.push(panel_rule()); - for p in &sim.people.people { - let leverage = match p.knowledge { - Knowledge::Leverage => p.leverage.label(), - _ => "unknown", - }; - lines.push(panel_line(&format!( - "{:<12} disp {:>4} Β· oblig {:>3} Β· lev {}", - trunc(&sim.person_label(p.id), 12), - p.disposition, - p.obligation, - leverage - ))); - if let Some(intel) = sim.latest_intel_for_person(p.id) { - lines.push(panel_line(&format!( - " intel: {} via {}", - trunc(&intel.label(), 16), - trunc(&intel.provenance(), 24) - ))); - } - for line in sim.recent_message_lines_for_person(p.id, 1) { - lines.push(panel_line(&format!(" thread: {}", trunc(&line, 42)))); - } - for line in sim - .learned_traffic_lines_for_person(p.id) - .into_iter() - .take(1) - { - lines.push(panel_line(&format!(" traffic: {}", trunc(&line, 40)))); + // The taught routes, in cause order (agent-play.md A3 keeps the + // ACCOUNTS breadcrumb tap ledger -> review ledger -> siphon/redirect). + match view { + OperationsView::Intel => { + lines.push(panel_line( + "sell-intel Β· review recordings Β· auto-review", + )); + lines.push(panel_line("actions intel Β· act intel ")); + } + OperationsView::People => { + lines.push(panel_line( + "message|favor|deceive Β· plot Β· choose