diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 114c1b27..c24fcf13 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -28,7 +28,8 @@ use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResizeConstraints, WindowResolution}; use misaligned::actions::{ - ActionCommand, ActionKind, Anchor, BuildRouteFamily, HumanMenuPage, HumanMenuRow, MenuRow, + ActionCommand, ActionKind, ActionReceiptRead, Anchor, BuildRouteFamily, HumanMenuPage, + HumanMenuRow, MenuRow, }; use misaligned::detection::Band; use misaligned::hall::{ @@ -695,6 +696,10 @@ struct Game { /// The context menu on the focused anchor (wiki/interface/context-menu.md), /// when open. Only anchor + selection are held; rows are re-queried live. menu: Option, + /// Frontend-only held beat after a local menu command. The simulation has + /// already committed the action; this preserves its core-authored receipt + /// at the acted-on anchor until the player explicitly dismisses it. + command_receipt: Option, /// Frontend-only attention cursor (wiki/mechanics/cursor.md). It is not /// saved and moving it never mutates sim state. cursor_x: i32, @@ -749,13 +754,25 @@ struct MenuState { pos: Option, } +/// The post-selection face of one local command beat. This is presentation +/// state only: `read` is captured from the same core ActionDesc that supplied +/// the pre-commit device explanation, never reconstructed from sim internals. +#[derive(Debug, Clone, PartialEq)] +struct CommandReceipt { + anchor: Anchor, + action: String, + read: Option, +} + impl Game { - /// A manual pause, the first-sense teaching lock, a held plot choice, and - /// the Operations dialogue stop Bevy's wall-clock-driven simulation. - /// Operations releases the clock when it closes without mutating the - /// player's explicit pause state. + /// A manual pause, local command beat, first-sense teaching lock, held plot + /// choice, and Operations dialogue stop Bevy's wall-clock-driven + /// simulation. Automatic holds release without mutating the player's + /// explicit pause state. fn clock_stopped(&self) -> bool { self.paused + || self.menu.is_some() + || self.command_receipt.is_some() || self.ops.is_some() || self.sim.teaching_lock_active() || self.sim.has_held_choice() @@ -764,7 +781,9 @@ impl Game { fn clock_label(&self) -> String { if self.paused { "PAUSED".into() - } else if self.ops.is_some() + } else if self.menu.is_some() + || self.command_receipt.is_some() + || self.ops.is_some() || self.sim.teaching_lock_active() || self.sim.has_held_choice() { @@ -865,6 +884,30 @@ impl Game { } } + /// Capture the exact pre-commit explanation for the selected bound row. + /// Nested route rows are still held even when they do not correspond to a + /// top-level ActionDesc; in that case the committed action label remains + /// as the honest receipt rather than inventing frontend consequence copy. + fn command_receipt_for(&self, row: &HumanMenuRow, action: &MenuRow) -> Option { + let anchor = self.menu.as_ref()?.anchor; + let read = self + .sim + .ui_projection(anchor, None, 0) + .actions + .into_iter() + .find(|desc| desc.command == action.command) + .map(|desc| desc.receipt_read()); + Some(CommandReceipt { + anchor, + action: row.display_text().to_ascii_uppercase(), + read, + }) + } + + fn dismiss_command_receipt(&mut self) { + self.command_receipt = None; + } + /// 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 { @@ -883,6 +926,7 @@ impl Game { /// slab affordance). A view command: no sim/save mutation, no tick. fn open_operations(&mut self) { self.menu = None; + self.command_receipt = None; let mut ops = self .ops_resume .take() @@ -895,6 +939,7 @@ impl Game { /// and strategic event links open their exact object). fn open_operations_at(&mut self, target: &OperationsTarget) { self.menu = None; + self.command_receipt = None; self.ops_resume = None; self.ops = Some(OperationsWorkspace::open_target(&self.sim, target)); } @@ -1064,6 +1109,7 @@ impl Game { notices_received: 0, notices_seen: 0, menu: None, + command_receipt: None, cursor_x, cursor_y, selected_machines: BTreeSet::new(), @@ -2845,6 +2891,26 @@ fn handle_input( return; } + // A committed local action leaves its attached explanation in place and + // keeps the same automatic clock hold. Dismissal is a separate physical + // input so the command cannot execute and disappear in one unread frame. + // F3 was routed above and therefore preserves this exact receipt. + if game.command_receipt.is_some() { + let dismiss = kb.just_pressed(KeyCode::Enter) + || kb.just_pressed(KeyCode::NumpadEnter) + || kb.just_pressed(KeyCode::Escape) + || mouse.just_pressed(MouseButton::Left) + || mouse.just_pressed(MouseButton::Right); + if dismiss { + game.dismiss_command_receipt(); + } + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); + } + game.drain(); + return; + } + // The Operations workspace owns input while open // (operations-workspace.md): its printed keys navigate views, objects, // actions, and the confirmation step. Opening the dialogue holds the @@ -3201,10 +3267,19 @@ fn menu_keyboard_input( game: &mut Game, exit: &mut MessageWriter, ) { + if menu_keyboard_controls(kb, game) { + exit.write(AppExit::Success); + } +} + +/// Deterministic core of the Bevy menu keyboard path. Pointer activation and +/// this path both terminate in `execute_menu_row`, so the held receipt cannot +/// differ by input device. +fn menu_keyboard_controls(kb: &ButtonInput, game: &mut Game) -> bool { let rows = game.menu_rows(); if rows.is_empty() { game.menu = None; - return; + return false; } if (kb.just_pressed(KeyCode::ArrowUp) || kb.just_pressed(KeyCode::KeyK)) && let Some(m) = &mut game.menu @@ -3258,13 +3333,12 @@ fn menu_keyboard_input( } } } - if kb.just_pressed(KeyCode::KeyQ) { - exit.write(AppExit::Success); - } + kb.just_pressed(KeyCode::KeyQ) } /// Run a menu row: open a dial, execute a command, or narrate a blocked -/// reason (justification-and-legibility). Closes the menu on a real execution. +/// reason (justification-and-legibility). A real execution closes the selector +/// into a held, attached receipt; it does not drop directly back into time. fn execute_menu_row(game: &mut Game, row: &HumanMenuRow) { if let Some(page) = row.child_page() { if let Some(m) = &mut game.menu { @@ -3276,8 +3350,10 @@ fn execute_menu_row(game: &mut Game, row: &HumanMenuRow) { let tick = game.sim.tick; game.add_log(tick, &format!("{}: {}", row.display_text(), reason)); } else { + let receipt = game.command_receipt_for(row, action); game.sim.execute_action(&action.command); game.menu = None; + game.command_receipt = receipt; } } } @@ -4995,6 +5071,21 @@ fn setup_ui(mut commands: Commands) { Visibility::Inherited, HoverVerbReceiptText::ObserverState, )); + line.spawn(( + Text::new(""), + TextFont { + font_size: 9.0, + ..default() + }, + TextColor(scaled(DIM, 0.54)), + TextLayout::new_with_justify(Justify::Left), + Node { + margin: UiRect::top(Val::Px(5.0)), + ..default() + }, + Visibility::Inherited, + HoverVerbReceiptText::Dismiss, + )); }); }); @@ -5281,7 +5372,36 @@ mod view_flip_tests { #[cfg(test)] mod menu_focus_tests { - use super::{Anchor, Game, rail_ui::collapse_read_lines}; + use super::{ + ActionCommand, Anchor, Game, HumanMenuPage, Interaction, KeyCode, MenuRowButton, MenuState, + MouseButton, Screen, menu_keyboard_controls, menu_pointer, rail_ui::collapse_read_lines, + }; + use bevy::input::ButtonInput; + use bevy::prelude::{App, Update}; + + fn local_action_game() -> (Game, usize) { + let mut game = Game::new(); + game.screen = Screen::Playing; + let (x, y) = game.sim.core_position(); + game.menu = Some(MenuState { + anchor: Anchor::Tile { x, y }, + selected: 0, + page: HumanMenuPage::Dial(misaligned::actions::DialId::Mode), + pos: None, + }); + let rows = game.menu_rows(); + let index = rows + .iter() + .position(|row| { + row.as_action().is_some_and(|action| { + matches!(action.command, ActionCommand::SetMachineMode { .. }) + && action.enabled() + }) + }) + .unwrap_or_else(|| panic!("host mode picker exposes a legal command: {rows:#?}")); + game.menu.as_mut().unwrap().selected = index; + (game, index) + } #[test] fn rail_read_collapses_same_cause_starving_sinks() { @@ -5346,6 +5466,78 @@ mod menu_focus_tests { Anchor::Tile { x, y } ); } + + #[test] + fn local_menu_and_nested_pages_hold_without_stealing_manual_pause() { + let (mut game, _) = local_action_game(); + assert!( + game.clock_stopped(), + "the root selector owns an automatic hold" + ); + assert_eq!(game.clock_label(), "HELD"); + + assert!(game.clock_stopped(), "nested pages retain the same hold"); + + game.menu = None; + assert!( + !game.clock_stopped(), + "closing resumes a previously running game" + ); + game.paused = true; + let (menu, _) = local_action_game(); + game.menu = menu.menu; + game.menu = None; + assert!( + game.paused, + "closing never clears the player's manual pause" + ); + assert!(game.clock_stopped()); + assert_eq!(game.clock_label(), "PAUSED"); + } + + #[test] + fn keyboard_commit_keeps_the_attached_receipt_until_explicit_dismissal() { + let (mut game, _) = local_action_game(); + let mut keys = ButtonInput::default(); + keys.press(KeyCode::Enter); + + assert!(!menu_keyboard_controls(&keys, &mut game)); + assert!(game.menu.is_none(), "the selector closes after commitment"); + let receipt = game + .command_receipt + .as_ref() + .expect("the acted-on anchor keeps a receipt"); + assert!(receipt.action.contains("WORK") || receipt.action.contains("THINK")); + assert!( + receipt.read.is_some(), + "the exact ActionDesc explanation survives" + ); + assert!(game.clock_stopped(), "the receipt owns the remaining hold"); + + game.dismiss_command_receipt(); + assert!( + !game.clock_stopped(), + "explicit dismissal releases running time" + ); + } + + #[test] + fn pointer_commit_uses_the_same_persistent_receipt_path() { + let (game, index) = local_action_game(); + let mut app = App::new(); + app.insert_resource(game); + app.insert_resource(ButtonInput::::default()); + app.world_mut() + .spawn((Interaction::Pressed, MenuRowButton { index })); + app.add_systems(Update, menu_pointer); + + app.update(); + + let game = app.world().resource::(); + assert!(game.menu.is_none()); + assert!(game.command_receipt.is_some()); + assert!(game.clock_stopped()); + } } /// The recording-review hotkeys (wiki/interface/context-menu.md R1-R3): @@ -5483,7 +5675,7 @@ mod ui_projection_parity_tests { pos: None, }); - let projection = game.sim.ui_projection(anchor, None, game.tick_ms); + let projection = game.sim.ui_projection(anchor, None, 0); assert_eq!(game.menu_rows(), projection.human_menu); let focus = sidebar_focus_text(&game); @@ -6187,6 +6379,26 @@ mod input_routing_tests { ); } + #[test] + fn f3_preserves_a_held_command_receipt() { + let mut kb = ButtonInput::default(); + kb.press(KeyCode::F3); + let mut game = Game::new(); + let (x, y) = game.sim.core_position(); + game.command_receipt = Some(CommandReceipt { + anchor: Anchor::Tile { x, y }, + action: "DELEGATE THINK".into(), + read: None, + }); + let before = game.command_receipt.clone(); + let mut mode = RenderMode::default(); + + assert!(view_flip_input(&kb, &mut game, &mut mode)); + assert!(mode.material); + assert_eq!(game.command_receipt, before); + assert!(game.clock_stopped()); + } + #[test] fn global_view_flip_precedes_every_post_opening_modal_input_branch() { let source = include_str!("main.rs"); diff --git a/crates/misaligned-bevy/src/world_annotations.rs b/crates/misaligned-bevy/src/world_annotations.rs index df2854f0..5e2d29f2 100644 --- a/crates/misaligned-bevy/src/world_annotations.rs +++ b/crates/misaligned-bevy/src/world_annotations.rs @@ -73,6 +73,7 @@ pub(super) enum HoverVerbReceiptText { Notice, Attention, ObserverState, + Dismiss, } #[derive(Component)] pub(super) struct HoverVerbReceipt; @@ -116,13 +117,19 @@ fn machine_verb_color(active: bool, control_hint: bool, label: &str) -> Color { struct HoverVerbTarget { machine: Option, device: Option, + /// A just-committed local action reuses the spacious attached receipt + /// field even when its anchor is a tile rather than a device. + receipt: bool, x: i32, y: i32, } impl HoverVerbTarget { fn width(self) -> f32 { - match (self.machine.is_some(), self.device.is_some()) { + match ( + self.machine.is_some(), + self.device.is_some() || self.receipt, + ) { (true, true) => MACHINE_VERB_BAR_WIDTH, (true, false) => MACHINE_VERB_BAR_WIDTH, (false, true) => DEVICE_VERB_BAR_WIDTH, @@ -131,7 +138,7 @@ impl HoverVerbTarget { } fn height(self) -> f32 { - if self.device.is_some() { + if self.device.is_some() || self.receipt { DEVICE_RECEIPT_HEIGHT } else { MACHINE_VERB_BAR_HEIGHT @@ -139,7 +146,11 @@ impl HoverVerbTarget { } fn clearance(self) -> f32 { - if self.device.is_some() { 18.0 } else { 26.0 } + if self.device.is_some() || self.receipt { + 18.0 + } else { + 26.0 + } } } @@ -520,8 +531,9 @@ pub(super) fn annotation_exclusion_tiles(game: &Game) -> HashSet<(i32, i32)> { } } // Device receipt field: opens up-left of its anchor with clearance. - if let Some(target) = device_hover_verb_target(game, None) - && target.device.is_some() + if let Some(target) = + command_receipt_target(game).or_else(|| device_hover_verb_target(game, None)) + && (target.device.is_some() || target.receipt) { tiles.extend(quadrant_tiles( (target.x, target.y), @@ -1332,6 +1344,7 @@ fn selected_machine_verb_target(game: &Game) -> Option { return Some(HoverVerbTarget { machine: Some(id), device: None, + receipt: false, x: machine.x, y: machine.y, }); @@ -1350,6 +1363,7 @@ fn device_hover_verb_target(game: &Game, pointer: Option<(i32, i32)>) -> Option< device.map(|device| HoverVerbTarget { machine: None, device: Some(device), + receipt: false, x, y, }) @@ -1362,6 +1376,24 @@ fn device_hover_verb_target(game: &Game, pointer: Option<(i32, i32)>) -> Option< device_at(game.cursor_x, game.cursor_y) } +/// Resolve the held post-command receipt to the same world anchor used by +/// the pre-commit explanation. The anchor position remains core-owned; this +/// merely selects the already-existing attached Bevy surface. +fn command_receipt_target(game: &Game) -> Option { + let receipt = game.command_receipt.as_ref()?; + let (x, y) = game.sim.anchor_position(receipt.anchor)?; + Some(HoverVerbTarget { + machine: None, + device: match receipt.anchor { + Anchor::Device(id) => Some(id), + _ => None, + }, + receipt: true, + x, + y, + }) +} + fn selected_machine_mode(game: &Game) -> Option { let mut machines = game.selected_machines.iter(); let first = game.sim.work_grid.mode(*machines.next()?)?; @@ -1471,7 +1503,11 @@ pub(super) fn render_operator_cue( return; }; let hide = |visibility: &mut Mut| **visibility = Visibility::Hidden; - if game.screen != Screen::Playing || game.menu.is_some() || game.ops.is_some() { + if game.screen != Screen::Playing + || game.menu.is_some() + || game.command_receipt.is_some() + || game.ops.is_some() + { hide(&mut visibility); return; } @@ -1598,7 +1634,9 @@ pub(super) fn render_hover_verb_bar( mouse_grid(&windows, &camera_q) }; let machine_target = selected_machine_verb_target(&game); - let device_target = device_hover_verb_target(&game, pointer); + let committed_receipt = game.command_receipt.as_ref(); + let device_target = + command_receipt_target(&game).or_else(|| device_hover_verb_target(&game, pointer)); let surfaces_visible = game.screen == Screen::Playing && game.menu.is_none() && game.ops.is_none(); let window_size = Vec2::new(window.width(), window.height()); @@ -1662,26 +1700,34 @@ pub(super) fn render_hover_verb_bar( } let current_mode = machine_target.and_then(|_| selected_machine_mode(&game)); - let device_verbs = device_target - .and_then(|target| target.device) - .map(|id| device_hover_verbs(&game.sim, id)) + let device_verbs = committed_receipt + .map(|receipt| vec![receipt.action.as_str()]) + .or_else(|| { + device_target + .and_then(|target| target.device) + .map(|id| device_hover_verbs(&game.sim, id)) + }) .unwrap_or_default(); // The renderer-neutral ActionDesc writes the plain-language explanation; // the GUI gives it more room than compact terminal/agent rows. - let receipt_read = device_target - .and_then(|target| target.device) - .and_then(|id| { - game.sim - .available_actions(Anchor::Device(id)) - .into_iter() - .find(|a| { - matches!( - a.command.kind(), - ActionKind::Tap | ActionKind::Untap | ActionKind::Take - ) + let receipt_read = committed_receipt + .and_then(|receipt| receipt.read.clone()) + .or_else(|| { + device_target + .and_then(|target| target.device) + .and_then(|id| { + game.sim + .available_actions(Anchor::Device(id)) + .into_iter() + .find(|a| { + matches!( + a.command.kind(), + ActionKind::Tap | ActionKind::Untap | ActionKind::Take + ) + }) }) - }) - .map(|action| action.receipt_read()); + .map(|action| action.receipt_read()) + }); for (part, mut receipt_text) in receipt.iter_mut() { receipt_text.0 = receipt_read .as_ref() @@ -1690,9 +1736,17 @@ pub(super) fn render_hover_verb_bar( HoverVerbReceiptText::Notice => read.notice.as_str(), HoverVerbReceiptText::Attention => read.attention.as_str(), HoverVerbReceiptText::ObserverState => read.observer_state.as_str(), + HoverVerbReceiptText::Dismiss => "", }) .map(ascii_ui) .unwrap_or_default(); + if *part == HoverVerbReceiptText::Dismiss { + receipt_text.0 = if committed_receipt.is_some() { + "ENTER / ESC / CLICK TO RETURN".into() + } else { + String::new() + }; + } } let mut device_slots = Vec::new(); for verb in device_verbs { @@ -1791,6 +1845,7 @@ pub(super) fn hover_identity_target( ) -> Option<((i32, i32), String)> { if game.screen != Screen::Playing || game.menu.is_some() + || game.command_receipt.is_some() || game.ops.is_some() || game.marquee.is_some() || game.sim.opening_choices_visible() @@ -2034,6 +2089,7 @@ mod hover_verb_bar_tests { let target = HoverVerbTarget { machine: None, device: Some(3), + receipt: false, x: 0, y: 0, }; diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index a0646594..f4417b79 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -17,7 +17,9 @@ use crossterm::{ execute, terminal::{self, ClearType}, }; -use misaligned::actions::{ActionCommand, Anchor, HumanMenuPage, HumanMenuRow}; +use misaligned::actions::{ + ActionCommand, ActionReceiptRead, Anchor, HumanMenuPage, HumanMenuRow, MenuRow, +}; use misaligned::operations_projection::OperationsTarget; use misaligned::origin::Origin; use misaligned::person::Knowledge; @@ -27,7 +29,7 @@ use misaligned::work_grid::{MachineIntensity, MachineMode}; use input::Command; use operations::{OperationsWorkspace, OpsSelect}; -use ui::UI; +use ui::{ClockState, UI}; #[derive(Debug, Clone, PartialEq)] enum Screen { @@ -71,6 +73,16 @@ struct MenuState { at_cursor: bool, } +/// Held post-selection face of one local command. Core supplies the receipt +/// copy; the terminal owns only its placement and dismissal lifetime. +#[derive(Debug, Clone, PartialEq)] +struct CommandReceipt { + anchor: Anchor, + at_cursor: bool, + action: String, + read: Option, +} + struct App { sim: Sim, ui: UI, @@ -82,6 +94,8 @@ struct App { view_mode: ViewMode, /// The context menu on the focused anchor, when open. menu: Option, + /// The attached receipt retained after a local menu action commits. + command_receipt: Option, /// The Operations workspace (operations-workspace.md), when open. /// Frontend attention state only: never saved, never a sim mutation. Its /// presence automatically holds the terminal clock until the dialogue closes. @@ -127,6 +141,7 @@ impl App { last_tick: Instant::now(), view_mode: ViewMode::default(), menu: None, + command_receipt: None, ops: None, ops_resume: None, cursor_x, @@ -155,7 +170,7 @@ impl App { } fn clock_stopped(&self) -> bool { - self.paused || self.ops.is_some() + self.paused || self.menu.is_some() || self.command_receipt.is_some() || self.ops.is_some() } fn move_cursor(&mut self, dx: i32, dy: i32) { @@ -195,7 +210,11 @@ impl App { /// Live human-menu rows (status dials). Agent mode keeps the flat dump. fn menu_rows(&self) -> Vec { - let tick_ms = if self.paused { 0 } else { self.tick_ms }; + let tick_ms = if self.clock_stopped() { + 0 + } else { + self.tick_ms + }; self.menu .as_ref() .map(|m| { @@ -230,10 +249,28 @@ impl App { } } + fn command_receipt_for(&self, row: &HumanMenuRow, action: &MenuRow) -> Option { + let menu = self.menu.as_ref()?; + let read = self + .sim + .ui_projection(menu.anchor, None, 0) + .actions + .into_iter() + .find(|desc| desc.command == action.command) + .map(|desc| desc.receipt_read()); + Some(CommandReceipt { + anchor: menu.anchor, + at_cursor: menu.at_cursor, + action: row.display_text().to_ascii_uppercase(), + read, + }) + } + /// Open the Operations workspace on one exact object (target-specific /// entries: person-detail routes and strategic event links). fn open_operations_at(&mut self, target: &OperationsTarget) { self.menu = None; + self.command_receipt = None; self.ops_resume = None; self.ops = Some(OperationsWorkspace::open_target(&self.sim, target)); } @@ -624,6 +661,9 @@ impl App { } } Command::MenuExecute => { + if self.command_receipt.take().is_some() { + return false; + } let rows = self.menu_rows(); let selected = self.menu.as_ref().map(|m| m.selected); if let Some(selected) = selected @@ -636,8 +676,10 @@ impl App { } } else if let Some(action) = row.as_action() { if action.enabled() { + let receipt = self.command_receipt_for(row, action); self.sim.execute_action(&action.command); self.menu = None; + self.command_receipt = receipt; } else if let Some(reason) = row.blocked_feedback() { self.ui .add_log(self.sim.tick, &format!("Cannot do that: {reason}.")); @@ -646,6 +688,9 @@ impl App { } } Command::MenuClose => { + if self.command_receipt.take().is_some() { + return false; + } // Esc backs out one explanatory level before closing. if let Some(m) = &mut self.menu { if let Some(parent) = m.page.parent() { @@ -662,6 +707,7 @@ impl App { // (closing the menu the previous press opened). Command::FocusEvent => { self.menu = None; + self.command_receipt = None; self.focus_event(); } @@ -686,6 +732,7 @@ impl App { // touches sim state; execution dispatches exact bound rows. Command::OpenOperations => { self.menu = None; + self.command_receipt = None; let mut ops = self .ops_resume .take() @@ -811,7 +858,7 @@ impl App { stdout.flush()?; return Ok(()); } - let progress = if self.paused { + let progress = if self.clock_stopped() { 1.0 } else { (self.last_tick.elapsed().as_millis() as f32 / self.tick_ms as f32) @@ -828,7 +875,13 @@ impl App { self.ui.render_sidebar( stdout, &self.sim, - self.paused, + if self.paused { + ClockState::Paused + } else if self.menu.is_some() || self.command_receipt.is_some() { + ClockState::Held + } else { + ClockState::Running + }, self.tick_ms, self.view_mode.is_real(), (self.cursor_x, self.cursor_y), @@ -854,6 +907,19 @@ impl App { )?; } } + if let Some(receipt) = self.command_receipt.as_ref() { + let at = receipt + .at_cursor + .then(|| self.sim.anchor_position(receipt.anchor)) + .flatten(); + self.ui.render_command_receipt( + stdout, + &receipt.action, + receipt.read.as_ref(), + at, + &self.sim, + )?; + } } Screen::GameOver => { self.ui.render_map( @@ -929,9 +995,11 @@ impl App { if self.sim.opening_choices_visible() { self.opening_received = Some(opening_key_receipt(key)); } - if let Some(cmd) = - input::handle_key(key, self.menu.is_some(), self.ops.is_some()) - && self.handle_command(cmd) + if let Some(cmd) = input::handle_key( + key, + self.menu.is_some() || self.command_receipt.is_some(), + self.ops.is_some(), + ) && self.handle_command(cmd) { break; } @@ -1288,6 +1356,29 @@ mod view_flip_tests { hasher.finish() } + fn local_action_app() -> App { + let mut app = App::with_seed(19); + let (x, y) = app.sim.core_position(); + app.menu = Some(MenuState { + anchor: Anchor::Tile { x, y }, + selected: 0, + page: HumanMenuPage::Dial(misaligned::actions::DialId::Mode), + at_cursor: true, + }); + let rows = app.menu_rows(); + let selected = rows + .iter() + .position(|row| { + row.as_action().is_some_and(|action| { + matches!(action.command, ActionCommand::SetMachineMode { .. }) + && action.enabled() + }) + }) + .unwrap_or_else(|| panic!("host mode picker exposes a legal command: {rows:#?}")); + app.menu.as_mut().unwrap().selected = selected; + app + } + #[test] fn terminal_cursor_reaches_every_tile_without_mutating_simulation() { let mut app = App::with_seed(53); @@ -1349,6 +1440,37 @@ mod view_flip_tests { assert_eq!(app.menu, menu_before); } + #[test] + fn terminal_local_command_holds_through_receipt_and_preserves_manual_pause() { + let mut app = local_action_app(); + assert!(app.clock_stopped(), "the nested local menu owns the hold"); + assert!(!app.paused, "automatic hold is not a manual pause"); + + assert!(!app.handle_command(Command::MenuExecute)); + assert!(app.menu.is_none()); + let receipt = app + .command_receipt + .as_ref() + .expect("commit retains the core-authored receipt"); + assert!(receipt.read.is_some()); + assert!(app.clock_stopped()); + + let receipt_before = app.command_receipt.clone(); + assert!(!app.handle_command(Command::ToggleView)); + assert_eq!(app.command_receipt, receipt_before, "F3 preserves the beat"); + assert!(!app.handle_command(Command::MenuClose)); + assert!(app.command_receipt.is_none()); + assert!(!app.clock_stopped(), "dismissal resumes a running game"); + + app.paused = true; + let menu = local_action_app().menu; + app.menu = menu; + assert!(!app.handle_command(Command::MenuExecute)); + assert!(!app.handle_command(Command::MenuClose)); + assert!(app.paused, "dismissal cannot clear a prior manual pause"); + assert!(app.clock_stopped()); + } + #[test] fn terminal_menu_is_the_shared_ui_projection() { let mut app = App::with_seed(19); @@ -1363,7 +1485,7 @@ mod view_flip_tests { assert_eq!( app.menu_rows(), - app.sim.ui_projection(anchor, None, app.tick_ms).human_menu + app.sim.ui_projection(anchor, None, 0).human_menu ); } @@ -1444,6 +1566,10 @@ mod view_flip_tests { app.menu.as_mut().unwrap().selected = 1; assert!(!app.handle_command(Command::MenuExecute)); assert!(app.menu.is_none()); + assert!( + app.command_receipt.is_some(), + "an exact destination command becomes a held receipt" + ); let ActionCommand::ProposeLink { a, b } = selected else { unreachable!() }; diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 3eb80162..62eff41a 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -10,7 +10,7 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor}; use crossterm::{cursor, queue, style, terminal}; -use misaligned::actions::Anchor; +use misaligned::actions::{ActionReceiptRead, Anchor}; use misaligned::detection::Band; use misaligned::hall::{HallRowState, RackSite}; use misaligned::intents::BuildGhostGeometry; @@ -38,6 +38,21 @@ const SIDEBAR_W: i32 = 34; /// Usable text width inside the sidebar. const SIDEBAR_TEXT_W: usize = (SIDEBAR_W - 1) as usize; +/// Human terminal clock presentation. A held command stops the same wall +/// clock as manual pause without pretending it changed the player's pause bit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClockState { + Running, + Paused, + Held, +} + +impl ClockState { + fn is_stopped(self) -> bool { + !matches!(self, Self::Running) + } +} + /// The sterile palette. One meaning per color, everywhere. mod pal { use crossterm::style::Color; @@ -1190,7 +1205,7 @@ impl UI { &mut self, stdout: &mut Stdout, sim: &Sim, - paused: bool, + clock_state: ClockState, tick_ms: u64, real_view: bool, cursor: (i32, i32), @@ -1212,7 +1227,7 @@ impl UI { // Crown metric first (clinical-frame.md): effective ops/sec — how // fast the brain is going. Large bold number, quiet unit. - let ops = if paused { + let ops = if clock_state.is_stopped() { 0.0 } else { sim.effective_ops_per_sec(tick_ms) @@ -1247,15 +1262,21 @@ impl UI { &format!("day {} · tick {}", 1 + sim.tick / 400, sim.tick), pal::TEXT, )?; - if paused { - line(stdout, &mut row, "PAUSED — space resumes", pal::AMBER)?; - } else { - line( - stdout, - &mut row, - &format!("running · {tick_ms} ms/tick"), - pal::DIM, - )?; + match clock_state { + ClockState::Paused => { + line(stdout, &mut row, "PAUSED — space resumes", pal::AMBER)?; + } + ClockState::Held => { + line(stdout, &mut row, "HELD — finish the command", pal::AMBER)?; + } + ClockState::Running => { + line( + stdout, + &mut row, + &format!("running · {tick_ms} ms/tick"), + pal::DIM, + )?; + } } // The objective name returns with the ordinary world frame. No // progress ontology is projected ahead of the systems that define it. @@ -1956,6 +1977,71 @@ impl UI { Ok(()) } + /// Retain the core-authored action explanation at the acted-on world + /// anchor after a local command commits. Unlike the live selector this has + /// no selectable rows: Enter/Esc explicitly dismiss it and release time. + pub fn render_command_receipt( + &mut self, + stdout: &mut Stdout, + action: &str, + read: Option<&ActionReceiptRead>, + at: Option<(i32, i32)>, + sim: &Sim, + ) -> std::io::Result<()> { + let (max_x, max_y) = terminal::size()?; + let mut lines = vec![action.to_string()]; + if let Some(read) = read { + lines.push(read.cost.clone()); + lines.push("WHAT THIS RISKS".into()); + lines.push(read.notice.clone()); + if !read.attention.is_empty() { + lines.push(read.attention.clone()); + } + if !read.observer_state.is_empty() { + lines.push(read.observer_state.clone()); + } + } + lines.push("ENTER / ESC TO RETURN".into()); + let widest = lines + .iter() + .map(|line| line.chars().count()) + .max() + .unwrap_or(10) + .max("COMMAND".len() + 4); + let w = (widest + 4).clamp(28, max_x as usize - 2) as u16; + let inner = (w - 4) as usize; + let wrapped: Vec<_> = lines.iter().flat_map(|line| wrap(line, inner)).collect(); + let h = (wrapped.len() + 2).clamp(3, max_y as usize - 2) as u16; + let (ox, oy) = if let Some((wx, wy)) = at { + let view_w = (max_x as i32 - SIDEBAR_W - 1).min(sim.map().width); + let view_h = (max_y as i32 - 9).min(sim.map().height); + let origin_x = (wx - view_w / 2).clamp(0, (sim.map().width - view_w).max(0)); + let origin_y = (wy - view_h / 2).clamp(0, (sim.map().height - view_h).max(0)); + let sx = (wx - origin_x + 2).max(0) as u16; + let sy = (wy - origin_y + 1).max(0) as u16; + ( + sx.min(max_x.saturating_sub(w)), + sy.min(max_y.saturating_sub(h)), + ) + } else { + ((max_x.saturating_sub(w)) / 2, (max_y.saturating_sub(h)) / 2) + }; + frame(stdout, ox, oy, w, h, "COMMAND")?; + for (index, line) in wrapped.iter().enumerate() { + let color = if index == 0 { + pal::TEXT + } else if line == "WHAT THIS RISKS" { + pal::FAINT + } else if line == "ENTER / ESC TO RETURN" { + pal::AMBER_DIM + } else { + pal::DIM + }; + put(stdout, ox + 2, oy + 1 + index as u16, line, color)?; + } + Ok(()) + } + pub fn render_log(&mut self, stdout: &mut Stdout) -> std::io::Result<()> { let (max_x, max_y) = terminal::size()?; let log_w = (max_x as i32 - SIDEBAR_W - 1).max(10) as usize; diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index d725f6da..21a402f9 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -244,6 +244,12 @@ the next earned anchor rather than the next tile. leading `>` and bone text, never a filled yellow button. The menu is one `MenuPanel` root: a rebuild despawns all children (`despawn_related::`) then respawns title and rows once — chrome never stacks across opens. + A non-empty menu holds the simulation at the visible tick through every + nested page. Executing an enabled row replaces the selector at that same + anchor with a persistent attached receipt; Enter, Esc, or click-away + dismisses it. The menu-owned hold never changes the manual pause bit, so a + menu opened from PAUSED returns to PAUSED while an ordinarily running game + resumes only after dismissal. Click a row — or select with `j`/`k` or number keys and press Enter — to execute; `esc` or a click away closes. Local verbs (salvage, buy, fallback, tap/untap/take, scan/compromise, connect to the outside, host review/research) live on diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 2f7c2360..6e462f40 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -28,6 +28,11 @@ Status note: IMPLEMENTED. Current state: no panic exit or attention recommendation recolors a second mode. - **Events link to anchors.** Drained events carry an anchor; `focus last` and clickable trace rows jump to it; an empty menu narrates the miss. + - **One held command beat.** Opening a non-empty human context menu holds + world time through every nested page. Committing an enabled action replaces + the selector at that same anchor with an attached consequence receipt; + explicit dismissal returns to play without rewriting a pre-existing manual + pause. Direct machine controls remain immediate and menu-free. - **Epistemic honesty.** Person and observer names go through `person_label` / `observer_label` (role silhouettes until earned). Social verbs stay absent until intel processing stages the knowledge, then appear @@ -115,6 +120,19 @@ wire or host that transports them. flattens its `actions`. The projection is a read model, never a second state store, and its conformance tests compare one seeded simulation snapshot rather than separately constructed fixtures. +- **One held command beat (DECIDED 2026-08-02).** A non-empty human spatial + menu is a deliberate decision, not a live tooltip. Opening it by any human + path holds world time at the visible tick. The hold persists through dial, + destination, and build-route pages. Executing an enabled action replaces the + selector in place with one attached receipt carrying the committed verb and + the renderer-neutral consequence/risk read already owned by that action; + Enter, Esc, or a click-away dismisses the receipt and releases the menu hold. + This frontend attention state never mutates the save or the player's explicit + pause bit: a menu opened while manually paused closes back into PAUSED, not a + resumed world. An empty or unearned anchor that opens no menu creates no + hold. Agent mode is already command-clocked. Direct `1`-`3`, `5`-`9`, `r`, + `R`, and intensity controls remain immediate because their established value + is precisely avoiding the menu sequence. - **Epistemic honesty:** the query never returns a verb the player has not earned — unearned anchors expose nothing, and provenance rules (cursor.md) govern what the menu may name. Verb text that mentions a @@ -201,7 +219,9 @@ wire or host that transports them. at the cursor (or the primary selected machine when a selection is active); j/k or numbers select; Enter executes (or opens a core-projected explanatory page such as a dial picker or build route sheet); esc backs out - one page at a time, then closes the menu. Rows render currently available + one page at a time, then closes the menu. A non-empty menu holds the human + terminal clock for its complete lifetime; an executed action leaves its + attached receipt in that overlay until Enter/Esc dismisses it. Rows render currently available verbs only; illegal actions do not occupy rows or selection slots. Dials and other controls use the shared amber-dim treatment without a literal copy tag; the selector carries no repeated key-help footer. Rows @@ -214,7 +234,10 @@ wire or host that transports them. - **Bevy:** right-click moves focus to the pointed map tile and opens its menu; Enter/`e` opens the keyboard cursor's tile without moving focus. It has the same content, order, and nested-page chrome; click to execute or open a page; - esc/click-away closes. + esc/click-away closes. A non-empty menu holds the clock at the visible tick. + An enabled commit replaces that exact anchored selector with its consequence + receipt; Enter, Esc, or click-away dismisses it and returns to the prior + running/manual-pause state. The compact selector is 300px wide, has no instructional footer, and shares the same verb-only rows as terminal. Selection is one amber left stroke over a quiet gunmetal field with a leading `>`; it is never a filled yellow @@ -390,6 +413,28 @@ made a three-word decision look dirty, slow, and administrative. 10. `5`-`9` count committed action rows only. A newly added control cannot silently shift quick-action numbering. +### Held-command-beat criteria (DECIDED 2026-08-02) + +C1. Opening a non-empty human context menu through right-click, Enter, `e`, or + an event-focus link holds the visible simulation tick. Nested pages retain + the same hold. Empty/unearned anchors that open no selector do not stop + time, and agent mode remains command-clocked rather than acquiring UI state. +C2. Executing an enabled row replaces the selector at the same anchor with one + attached receipt. It names the committed verb and uses existing + renderer-neutral action truth; neither human frontend invents consequences + from strings or reconstructs simulation rules. +C3. The receipt persists until explicit Enter, Esc, or pointer dismissal. No + world tick advances while either selector or receipt owns the command beat. +C4. Dismissal removes only the menu-owned hold. If the player opened the menu + while manually paused, closing the selector or receipt leaves the game + manually paused; otherwise ordinary cadence resumes. +C5. F3 preserves the exact anchor, nested page or receipt, selection, visible + tick, and manual-pause state. Menu hold never changes save bytes. +C6. Direct frequent controls remain immediate and do not synthesize a held + receipt. Focused regression tests cover pointer and keyboard open, nested + pages, commit, dismissal, pre-paused restoration, and view flip in both + human frontends where the frontend owns a wall clock. + ### Operations boundary criteria (IMPLEMENTED 2026-07-12) O1. The switch/device context menu retains only actions on that spatial body or diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index 2a3c50cd..d5c0b664 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -156,7 +156,10 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): the tick it happened on; newest bone, older gunmetal. - **Context menu** — a compact overlay anchored at the cursor, listing the focused spatial body's available local actions. It never aggregates people, - accounts, intel sales, schemes, or plots onto their carrier. + accounts, intel sales, schemes, or plots onto their carrier. A non-empty + menu holds the human terminal clock through its nested pages and attached + post-commit receipt; dismissal returns to the prior running/manual-pause + state. - **Operations workspace** — uppercase `I` replaces the retired People/Reach/ Finance panel family with one full-frame split workspace. Its header shows current day/tick and the objective/threat/`now:` spine, then `OPERATIONS` @@ -209,7 +212,8 @@ At terminal size ≥ 70×22 (hard minimum; below it, a plain size warning): appear on the switch. Persistent local settings and policy rows use the amber-dim control treatment; committed actions do not. STUB registry entries never reach the menu. In the menu, `j`/`k` or number - keys select, Enter executes, `esc` closes. + keys select and Enter executes. An enabled commit replaces the selector at + the same anchor with its attached receipt; Enter or `esc` dismisses it. - **Every command is discoverable on screen.** All bindings appear in the pinned hint block — including save/load. A key that works but is hinted nowhere is a violation. Anchor verbs are discoverable through the diff --git a/wiki/log/2026-08-02-paused-context-command.md b/wiki/log/2026-08-02-paused-context-command.md new file mode 100644 index 00000000..9ba30877 --- /dev/null +++ b/wiki/log/2026-08-02-paused-context-command.md @@ -0,0 +1,44 @@ +# 2026-08-02 — Local menus hold one readable command beat + +``` +Type: log +``` + +## Intent + +Cameron adopted the first recommendation from the GUI game-feel playtest: +right-clicking a local action surface should pause time, and the principle +should persist through the menus. The existing action explanation could also +disappear when focus moved with the pointer, making a committed click feel as +though nothing had happened. + +## Change + +- A non-empty human context menu holds the visible tick through root and + nested pages. +- An enabled action commit replaces the selector at the same anchor with a + persistent consequence receipt. Explicit dismissal releases the menu hold + without altering a pre-existing manual pause. +- Direct frequent controls remain immediate, and agent mode remains + command-clocked. +- Focused frontend regressions pin pointer/keyboard entry, nested-page hold, + commit/dismissal, manual-pause restoration, and F3 preservation. + +## Follow-up finding + +The rapid fragments Cameron sees around the world are not primarily rows from +NOTICES. `ReadSentence::Intel` exists only while `processed_tick == sim.tick`, +and routed-record sentences move with their persisted hop every tick. At the +selectable 150 ms default and 20 ms fastest cadences, a truthful sentence can +therefore be unreadable to a human. The findings queue records a separate +temporal-hierarchy pass; notice coalescing alone would not fix it. + +## Defense + +`wiki/vision/simulation-laws.md#actions-live-on-the-thing` now makes a local +action one held, anchored choice/consequence beat. +`wiki/interface/context-menu.md` criteria C1-C6 require the exact frontend +clock, receipt, dismissal, pause-restoration, view-preservation, and parity +behavior. `wiki/interface/superhuman-operability.md#consequence-before-context` +requires the result to stay on the object rather than flash as a detached +toast. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index bd14d799..61adefcb 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-08-02 - Local menus hold one readable command beat + +- Intent: Cameron adopted the first recommendation from the GUI game-feel playtest: right-clicking a local action surface should pause time, and the principle should persist through the menus. The existing action explanation could also disappear when focus moved with the pointer, making... +- Log: [wiki/log/2026-08-02-paused-context-command.md](2026-08-02-paused-context-command.md) + ## 2026-08-01 - Keep one autonomous outcome visible across task boundaries - Intent: (see session log) diff --git a/wiki/log/decisions.md b/wiki/log/decisions.md index 0065739e..f65119fd 100644 --- a/wiki/log/decisions.md +++ b/wiki/log/decisions.md @@ -26,6 +26,7 @@ adopted, rejected, reopened, or proposed; current `Type: law` and - [2026-07-26](decisions/2026-07-26.md) - [2026-07-29](decisions/2026-07-29.md) - [2026-08-01](decisions/2026-08-01.md) +- [2026-08-02](decisions/2026-08-02.md) Append new decisions to the current date's volume. Never rewrite an older volume; supersede it in current law/spec and record the newer decision. diff --git a/wiki/log/decisions/2026-08-02.md b/wiki/log/decisions/2026-08-02.md new file mode 100644 index 00000000..66f5ecbe --- /dev/null +++ b/wiki/log/decisions/2026-08-02.md @@ -0,0 +1,47 @@ +# Decisions — 2026-08-02 + +``` +Type: log +``` + +## Local context menus are held command beats + +### DECIDED + +- Opening a non-empty local context menu holds world time. The rule applies + throughout the menu rather than only to the right-click frame: keyboard + entry and nested pages own the same hold. +- Committing an enabled action replaces the selector at the same world anchor + with one persistent attached receipt. Explicit dismissal ends the command + beat and resumes ordinary cadence. +- The menu does not borrow the player's manual pause bit. A menu opened while + PAUSED closes back into PAUSED; one opened while running resumes only after + its selector or receipt is dismissed. +- Frequent direct machine controls remain immediate. Their point is to avoid a + menu sequence for judgment the player has already learned. + +### OPEN + +- The notification/read model still needs one principled temporal hierarchy. + Typed NOTICES, one-tick processed-intel callouts, and hop-by-hop routed-record + sentences are distinct surfaces today; coalescing the drawer alone will not + make the fast world text human-readable. + +### DEFERRED + +- Additional animation and sound juice waits until the held command beat and + transient-text hierarchy are clear. Motion should reinforce a consequence, + not compensate for an unreadable one. + +### REJECTED + +- **Let the simulation continue behind the selector.** This preserves nominal + real-time pressure by making the player race a 150 ms clock while reading a + decision. +- **Dismiss the receipt automatically.** A timer cannot know when a human has + understood the result and recreates the fast-flash failure that prompted the + change. + +Owner: [context-menu.md](../../interface/context-menu.md), constrained by +[simulation-laws.md](../../vision/simulation-laws.md) and +[superhuman-operability.md](../../interface/superhuman-operability.md). diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index c53c9696..95f3c3ca 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -107,3 +107,5 @@ Format: `- YYYY-MM-DD · type · slice · one-line statement of the finding`. Types are the five from [tick.md](tick.md): violation, contradiction, question, bug, insecurity — plus `gate` for a checker owed to the recurrence-promotes-to-the-gate rule. + +- 2026-08-02 · insecurity · `wiki/interface/digital-read.md` + Bevy world callout dwell · newly processed intel is projected only for its exact processing tick and routed-record prose moves one hop per tick, so meaningful text can flash for 20-150 ms or jump before a human can read it; define a renderer-neutral dwell/grouping hierarchy distinct from NOTICES coalescing diff --git a/wiki/vision/simulation-laws.md b/wiki/vision/simulation-laws.md index 60069412..e7a9b195 100644 --- a/wiki/vision/simulation-laws.md +++ b/wiki/vision/simulation-laws.md @@ -200,11 +200,14 @@ social, financial, and strategic act whose traffic happens to cross it. Immediate action on a spatial body uses a **context menu on the focused world anchor**. Put your attention on a rack and get the rack's verbs; on a switch, get only verbs that change or use that network body and its outside connection. -The human menu is a short choice list, not a receipt: it names the verb and omits cost, -signature forecast, inline blocked explanation, control tags, and repeated key -help. Known-but-illegal choices stay dim; attempting one narrates its reason in -the trace. Agent output may retain the full descriptor for planning and -tooling. +Before commitment the human menu is a short choice list, not a diagnostic +receipt: it names the verb and omits cost, signature forecast, inline blocked +explanation, control tags, and repeated key help. Opening that real choice +surface holds world time. Commitment replaces the choice in place with one +attached consequence receipt on the same anchor; dismissing that receipt +releases the hold without changing whether the player had manually paused. +Known-but-illegal choices stay dim; attempting one narrates its reason in the +trace. Agent output may retain the full descriptor for planning and tooling. Durable strategic objects use the **Operations workspace**: processed intel, people dossiers, accounts and flows, schemes, and active plots. This is still