diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index a79a7954..c72484fd 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -929,6 +929,9 @@ impl Game { let page_key = match self.menu.as_ref().map(|menu| &menu.page) { None | Some(HumanMenuPage::Root) => 0, Some(HumanMenuPage::Dial(dial)) => 1 + *dial as u64, + Some(HumanMenuPage::ProcedureDormant { host_machine }) => { + 0xa24b_aed4_963e_e407u64.wrapping_mul(*host_machine as u64 + 1) + } Some(HumanMenuPage::LinkDestinations { source }) => { 0xd1b5_4a32_d192_ed03u64.wrapping_mul(*source as u64 + 1) } @@ -1107,6 +1110,11 @@ impl Game { return; } self.sim.execute_action(&command); + // Pointer acts arrive after the global input system, so they + // cannot rely on that earlier system's drain. Keep the trace + // current at the act itself; idle Operations frames can then + // remain true read-only frames instead of dirtying `Game`. + self.drain(); if let Some(ops) = &mut self.ops { ops.clamp_to(&self.sim); } @@ -1249,6 +1257,7 @@ impl Game { .and_then(|ops| ops.selected_action(&self.sim)) .map(|row| row.command); self.sim.execute_action(command); + self.drain(); let Self { sim, ops, .. } = self; if let Some(ops) = ops.as_mut() { ops.clamp_to(sim); @@ -3055,7 +3064,10 @@ fn handle_input( // The representation flip preserves the exact modal attention state. // Route it before held choices, Operations, and local context menus claim // their own key grammars. - if view_flip_input(&kb, &mut game, &mut mode) { + // Avoid taking either mutable resource on idle frames. A mutable deref is + // itself a Bevy change, even when the helper immediately returns; that + // false change used to rebuild modal UI before its pointer event arrived. + if kb.just_pressed(KeyCode::F3) && view_flip_input(&kb, &mut game, &mut mode) { game.drain(); return; } @@ -3109,8 +3121,14 @@ fn handle_input( // the player's explicit pause state. Pointer interaction lands in // `ops_pointer`. if game.ops.is_some() { - ops_keyboard_input(&kb, &mut typed, &mut game, &mut exit); - game.drain(); + // Taking `&mut Game` marks the resource changed. Do that only when + // Operations actually received input: the panel rebuilds on a changed + // game, and rebuilding idle buttons every frame destroys the entity + // continuity Bevy needs to deliver Hovered -> Pressed pointer events. + if kb.get_just_pressed().next().is_some() { + ops_keyboard_input(&kb, &mut typed, &mut game, &mut exit); + game.drain(); + } return; } typed.clear(); diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index 4c286f8a..a414682b 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -631,6 +631,22 @@ mod knowledge_use_receipt_tests { fn channel_scenario() -> Sim { let mut sim = scenario(); sim.people.has_channel = true; + if sim.hearing_threshold_active() { + let monitor = sim + .reach + .devices + .iter() + .find(|device| { + device.camera_dormant + && sim + .reach + .subscribed_by(device.id, misaligned::reach::Party::Player) + }) + .map(|device| device.id) + .expect("the hearing threshold names its environmental monitor"); + sim.reach.tap_dormant_camera(monitor); + sim.recompute_senses(); + } sim } @@ -1093,6 +1109,200 @@ mod knowledge_use_receipt_tests { row.disabled ); } + + /// Rebuilt action rows report Hovered under a stationary pointer. Once + /// that exact semantic row is selected, the repeated event must be a true + /// no-op; otherwise `manage_operations_ui` rebuilds the button every frame + /// and no Pressed event can ever reach it. + #[test] + fn repeated_action_hover_does_not_dirty_the_game_resource() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = channel_scenario(); + game.ops = Some(debt_settled_workspace(&game.sim)); + assert!( + !game.sim.hearing_threshold_active(), + "the mature Operations pointer surface must not be behind the opening overlay" + ); + let selected = game + .ops + .as_ref() + .unwrap() + .selected_action_index(&game.sim) + .unwrap(); + + let mut app = App::new(); + app.insert_resource(game) + .init_resource::() + .add_systems(Update, ops_pointer); + app.world_mut().clear_trackers(); + app.world_mut() + .spawn((Interaction::Hovered, OpsActionButton { index: selected })); + + app.update(); + + assert!( + !app.world().resource_ref::().is_changed(), + "hovering the already-selected rebuilt row must not trigger another rebuild" + ); + } + + /// The complete global input system must leave `Game` untouched when an + /// open Operations workspace receives no input. This pins both mutable + /// call sites that used to look read-only while marking the resource + /// changed (`view_flip_input` and `ops_keyboard_input`). + #[test] + fn idle_global_input_does_not_dirty_an_open_operations_game() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = channel_scenario(); + game.ops = Some(debt_settled_workspace(&game.sim)); + + let mut app = App::new(); + app.insert_resource(game) + .insert_resource(ButtonInput::::default()) + .insert_resource(ButtonInput::::default()) + .insert_resource(SimClock { + timer: Timer::from_seconds(0.2, TimerMode::Repeating), + }) + .insert_resource(RenderMode::default()) + .init_resource::() + .init_resource::() + .add_message::() + .add_message::() + .add_systems(Update, handle_input); + app.world_mut().clear_trackers(); + + app.update(); + + assert!( + !app.world().resource_ref::().is_changed(), + "an idle frame must preserve the button entities needed by the next pointer press" + ); + } + + /// Pointer rows use the same shared commitment state machine as Enter. + /// This pins the player-visible failure: a plot action row must accept a + /// real Pressed event instead of remaining a decorative receipt. + #[test] + fn pressed_plot_action_row_opens_its_exact_confirmation() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = channel_scenario(); + game.ops = Some(debt_settled_workspace(&game.sim)); + let selected = game + .ops + .as_ref() + .unwrap() + .selected_action_index(&game.sim) + .unwrap(); + + let mut app = App::new(); + app.insert_resource(game) + .init_resource::() + .add_systems(Update, ops_pointer); + app.world_mut() + .spawn((Interaction::Pressed, OpsActionButton { index: selected })); + + app.update(); + + let game = app.world().resource::(); + assert_eq!( + game.ops.as_ref().unwrap().confirm, + Some(ConfirmChoice::Confirm), + "clicking the plot row must reach the same CONFIRM step as Enter" + ); + } + + /// The receipt's inline persona buttons are not explanatory decoration: + /// clicking a different live identity must rebind the exact plot command. + #[test] + fn pressed_persona_choice_rebinds_the_plot_command() { + let mut sim = channel_scenario(); + sim.set_persona("Alex", "auditor"); + let second = sim.newest_persona_id().unwrap(); + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = sim; + game.ops = Some(debt_settled_workspace(&game.sim)); + + let mut app = App::new(); + app.insert_resource(game) + .add_systems(Update, ops_receipt_pointer); + app.world_mut().spawn(( + Interaction::Pressed, + OpsPersonaChoiceButton { + person: 0, + plot_id: "marcus-debt-settled".into(), + persona: second, + }, + )); + + app.update(); + + let game = app.world().resource::(); + let row = game + .ops + .as_ref() + .unwrap() + .selected_action(&game.sim) + .unwrap(); + assert_eq!( + game.ops_plot_command(&row.command), + ActionCommand::StartPlot { + person: 0, + plot_id: "marcus-debt-settled".into(), + persona: Some(second), + } + ); + } + + /// ADD NEW rows on the same inline control dispatch the exact PERSONAS + /// command and leave the plot decision selected with its new default + /// identity, matching the keyboard path. + #[test] + fn pressed_persona_creation_row_establishes_and_binds_an_identity() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = personaless_scenario(); + game.ops = Some(debt_settled_workspace(&game.sim)); + let command = game + .sim + .operations_projection() + .personas + .iter() + .find(|object| object.target == OperationsTarget::PersonaDraft) + .and_then(|object| object.actions.first()) + .map(|desc| desc.command.clone()) + .expect("PERSONAS publishes a creation command"); + + let mut app = App::new(); + app.insert_resource(game) + .add_systems(Update, ops_receipt_pointer); + app.world_mut() + .spawn((Interaction::Pressed, OpsPersonaCreateButton { command })); + + app.update(); + + let game = app.world().resource::(); + let persona = game + .sim + .newest_persona_id() + .expect("clicking ADD NEW establishes an identity"); + let row = game + .ops + .as_ref() + .unwrap() + .selected_action(&game.sim) + .unwrap(); + assert!(matches!( + game.ops_plot_command(&row.command), + ActionCommand::StartPlot { + persona: Some(bound), + .. + } if bound == persona + )); + } } // ─── Operations workspace systems (operations-workspace.md) ───────────────── @@ -1238,14 +1448,22 @@ pub(super) fn ops_pointer( 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() - // The object rail is context while the identity-creation - // screen is open; hovering it must not retarget the draft. - && ops.draft.is_none() - { + // The chamber rebuilds whenever `Game` changes. Newly spawned + // buttons under a stationary pointer report Hovered again, so + // writing the already-selected index here would rebuild the + // row forever and despawn it before Pressed could arrive. + // Only a semantic cursor change earns a resource mutation. + let should_select = game.ops.as_ref().is_some_and(|ops| { + ops.pane == OpsPane::Objects + && ops.confirm.is_none() + // The object rail is context while the identity-creation + // screen is open; hovering it must not retarget the draft. + && ops.draft.is_none() + && ops.selected_index(&game.sim) != Some(row.index) + }); + if should_select { + let game = &mut *game; + let ops = game.ops.as_mut().expect("checked open workspace"); ops.select_object_index(&game.sim, row.index); } } @@ -1270,10 +1488,14 @@ pub(super) fn ops_pointer( for (interaction, row) in &rows.p1() { match interaction { Interaction::Hovered => { - let game = &mut *game; - if let Some(ops) = &mut game.ops - && ops.confirm.is_none() - { + let should_select = game.ops.as_ref().is_some_and(|ops| { + ops.confirm.is_none() + && (ops.pane != OpsPane::Actions + || ops.selected_action_index(&game.sim) != Some(row.index)) + }); + if should_select { + let game = &mut *game; + let ops = game.ops.as_mut().expect("checked open workspace"); ops.pane = OpsPane::Actions; ops.select_action_index(&game.sim, row.index); } @@ -1295,11 +1517,15 @@ pub(super) fn ops_pointer( for (interaction, row) in &rows.p0() { match interaction { Interaction::Hovered => { - let game = &mut *game; - if let Some(ops) = &mut game.ops - && ops.confirm.is_none() - && ops.draft.is_none() - { + let should_select = game.ops.as_ref().is_some_and(|ops| { + ops.confirm.is_none() + && ops.draft.is_none() + && (ops.pane != OpsPane::Related + || ops.selected_related_index(&game.sim) != Some(row.index)) + }); + if should_select { + let game = &mut *game; + let ops = game.ops.as_mut().expect("checked open workspace"); ops.pane = OpsPane::Related; ops.select_related_index(&game.sim, row.index); } diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index d2f4ee26..b3fc170f 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -1267,6 +1267,11 @@ impl DialId { pub enum HumanMenuPage { Root, Dial(DialId), + /// Resident routes whose exact current candidate count is zero. The host + /// remains explicit so opening this page cannot retarget another machine. + ProcedureDormant { + host_machine: u32, + }, LinkDestinations { source: u32, }, @@ -1284,6 +1289,7 @@ impl HumanMenuPage { match self { Self::Root => "ACTIONS", Self::Dial(dial) => dial.picker_title(), + Self::ProcedureDormant { .. } => "NO CURRENT WORK", Self::LinkDestinations { .. } => "WHERE SHOULD THIS CONNECT?", Self::BuildRoutes { family: None, @@ -1305,7 +1311,9 @@ impl HumanMenuPage { pub fn parent(&self) -> Option { match self { Self::Root => None, - Self::Dial(_) | Self::LinkDestinations { .. } => Some(Self::Root), + Self::Dial(_) | Self::ProcedureDormant { .. } | Self::LinkDestinations { .. } => { + Some(Self::Root) + } Self::BuildRoutes { intent, family: Some(family), @@ -1344,6 +1352,8 @@ impl HumanMenuPage { pub enum HumanMenuRow { /// Enter opens this dial's picker; no command. Dial { id: DialId, label: String }, + /// Enter expands resident routes with no exact eligible person now. + ProcedureDormant { host_machine: u32, label: String }, /// Enter opens the exact destination chooser for one source device. LinkDestinations { source: u32, label: String }, /// Enter opens the family-first route sheet for one exact intent. @@ -1367,6 +1377,7 @@ impl HumanMenuRow { pub fn enabled(&self) -> bool { match self { HumanMenuRow::Dial { .. } + | HumanMenuRow::ProcedureDormant { .. } | HumanMenuRow::LinkDestinations { .. } | HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } @@ -1379,6 +1390,7 @@ impl HumanMenuRow { pub fn indent(&self) -> bool { match self { HumanMenuRow::Dial { .. } + | HumanMenuRow::ProcedureDormant { .. } | HumanMenuRow::LinkDestinations { .. } | HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } @@ -1391,6 +1403,7 @@ impl HumanMenuRow { pub fn role(&self) -> ActionRole { match self { HumanMenuRow::Dial { .. } + | HumanMenuRow::ProcedureDormant { .. } | HumanMenuRow::LinkDestinations { .. } | HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } @@ -1413,6 +1426,7 @@ impl HumanMenuRow { pub fn display_text(&self) -> String { let (label, indent, active) = match self { HumanMenuRow::Dial { label, .. } + | HumanMenuRow::ProcedureDormant { label, .. } | HumanMenuRow::LinkDestinations { label, .. } | HumanMenuRow::BuildRoutes { label, .. } | HumanMenuRow::BuildRouteFamily { label, .. } => (label.as_str(), false, false), @@ -1446,6 +1460,7 @@ impl HumanMenuRow { HumanMenuRow::BuildRouteCandidate(candidate) => Some(&candidate.row), HumanMenuRow::Action(row) => Some(row), HumanMenuRow::Dial { .. } + | HumanMenuRow::ProcedureDormant { .. } | HumanMenuRow::LinkDestinations { .. } | HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } @@ -1481,7 +1496,8 @@ impl HumanMenuRow { pub fn as_dial(&self) -> Option { match self { HumanMenuRow::Dial { id, .. } => Some(*id), - HumanMenuRow::LinkDestinations { .. } + HumanMenuRow::ProcedureDormant { .. } + | HumanMenuRow::LinkDestinations { .. } | HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } | HumanMenuRow::BuildRouteCandidate(_) @@ -1493,6 +1509,11 @@ impl HumanMenuRow { pub fn child_page(&self) -> Option { match self { HumanMenuRow::Dial { id, .. } => Some(HumanMenuPage::Dial(*id)), + HumanMenuRow::ProcedureDormant { host_machine, .. } => { + Some(HumanMenuPage::ProcedureDormant { + host_machine: *host_machine, + }) + } HumanMenuRow::LinkDestinations { source, .. } => { Some(HumanMenuPage::LinkDestinations { source: *source }) } @@ -1783,6 +1804,20 @@ impl Sim { match page { HumanMenuPage::Root => self.human_menu_at_rate(anchor, None, tick_ms), HumanMenuPage::Dial(dial) => self.human_menu_at_rate(anchor, Some(dial), tick_ms), + HumanMenuPage::ProcedureDormant { host_machine } => self + .procedure_actions(host_machine) + .into_iter() + .filter(ActionDesc::enabled) + .filter_map(|action| { + let ActionCommand::ConfigureProcedure { job } = &action.command else { + return None; + }; + matches!(job.change, crate::procedure::ProcedureChange::Install(_)) + .then_some(job) + .filter(|job| self.procedure_job_ready_target_count(job) == 0) + .map(|_| self.human_procedure_row(&action, 0, true)) + }) + .collect(), HumanMenuPage::LinkDestinations { source } => self .available_actions(anchor) .into_iter() @@ -1823,6 +1858,7 @@ impl Sim { let mut rows = Vec::new(); let mut routed_intents = std::collections::BTreeSet::new(); let mut link_sources = std::collections::BTreeSet::new(); + let mut dormant_procedures = std::collections::BTreeMap::::new(); for dial in DialId::ALL { if !actions.iter().any(|a| dial_of(&a.command) == Some(dial)) { continue; @@ -1863,6 +1899,20 @@ impl Sim { } continue; } + if let ActionCommand::ConfigureProcedure { job } = &a.command + && matches!(job.change, crate::procedure::ProcedureChange::Install(_)) + { + if !a.enabled() { + continue; + } + let ready = self.procedure_job_ready_target_count(job); + if ready == 0 { + *dormant_procedures.entry(job.host_machine).or_default() += 1; + } else { + rows.push(self.human_procedure_row(a, ready, false)); + } + continue; + } if a.enabled() { rows.push(HumanMenuRow::Action(MenuRow { label: self.human_action_label(anchor, a), @@ -1883,6 +1933,12 @@ impl Sim { push_automate_rows(&mut auto_rows, a); rows.extend(auto_rows.into_iter().map(HumanMenuRow::Action)); } + for (host_machine, count) in dormant_procedures { + rows.push(HumanMenuRow::ProcedureDormant { + host_machine, + label: format!("NO CURRENT WORK · {count}"), + }); + } // Once the route commits, it no longer has an executable candidate // action. Keep its persisted receipt on the same spatial ghost rather // than making it disappear from the world menu. @@ -1911,6 +1967,62 @@ impl Sim { rows } + /// Compact resident-route choice for human menus. The root carries only + /// exact ready-now installs; zero-candidate routes retain their current + /// reason on the expandable NO CURRENT WORK page. The full authored + /// configuration remains bound in the ordinary `MenuRow` command. + fn human_procedure_row( + &self, + action: &ActionDesc, + ready: usize, + dormant: bool, + ) -> HumanMenuRow { + let ActionCommand::ConfigureProcedure { job } = &action.command else { + unreachable!("procedure menu helper requires a bound procedure command"); + }; + let crate::procedure::ProcedureChange::Install(blueprint) = &job.change else { + unreachable!("retirement remains an ordinary direct action"); + }; + let route = blueprint + .methods + .iter() + .find_map(|grant| match &grant.method { + crate::procedure::ProcedureMethod::AuthoredPlot { plot_id } => self + .plot_catalog() + .get(plot_id) + .map(|plot| plot.title.as_str()), + }) + .unwrap_or(&blueprint.mandate.category) + .to_ascii_uppercase(); + let ready_word = if ready == 1 { "TARGET" } else { "TARGETS" }; + let label = if dormant { + format!("{} · {route}", job.verb().to_ascii_uppercase()) + } else { + format!( + "{} · {route} · {ready} READY {ready_word}", + job.verb().to_ascii_uppercase() + ) + }; + let mut detail = vec![self.procedure_job_verb(job).to_ascii_uppercase()]; + if dormant { + detail.insert(0, "NO ELIGIBLE PERSON CAN USE THIS ROUTE RIGHT NOW".into()); + } + HumanMenuRow::Action(MenuRow { + label, + cost: action.cost.label(), + signature: action.signature_label(), + detail, + // Zero current candidates is information, not illegality: the + // player may deliberately install standing automation before its + // future work arrives. Preserve only real host/route blockers. + disabled: action.disabled_reason.clone(), + command: action.command.clone(), + role: action.command.definition().role, + indent: false, + active: false, + }) + } + fn human_dial_rows(&self, actions: &[ActionDesc], dial: DialId) -> Vec { let mut rows = Vec::new(); for a in actions { @@ -4535,6 +4647,61 @@ mod tests { }) .expect("the machine menu exposes its procedure job"); assert_eq!(control_row.signature, None); + assert!( + control_row.label.contains("READY TARGET"), + "the root names current executable reach instead of printing the whole catalog" + ); + + let root = s.human_menu( + Anchor::Tile { + x: host_x, + y: host_y, + }, + None, + ); + let direct_jobs = root + .iter() + .filter_map(HumanMenuRow::as_action) + .filter_map(|row| match &row.command { + ActionCommand::ConfigureProcedure { job } + if matches!(job.change, crate::procedure::ProcedureChange::Install(_)) => + { + Some(job) + } + _ => None, + }) + .collect::>(); + assert!(!direct_jobs.is_empty()); + assert!( + direct_jobs + .iter() + .all(|job| s.procedure_job_ready_target_count(job) > 0) + ); + let dormant_page = root + .iter() + .filter_map(HumanMenuRow::child_page) + .find(|page| matches!(page, HumanMenuPage::ProcedureDormant { .. })) + .expect("zero-candidate routes fold into one expandable row"); + let dormant = s.human_menu_page_at_rate( + Anchor::Tile { + x: host_x, + y: host_y, + }, + dormant_page, + Sim::DEFAULT_TICK_MS, + ); + assert!(!dormant.is_empty()); + assert!(dormant.iter().all(|row| { + row.as_action().is_some_and(|row| { + row.disabled.is_none() + && row + .detail + .iter() + .any(|line| line == "NO ELIGIBLE PERSON CAN USE THIS ROUTE RIGHT NOW") + && matches!(&row.command, ActionCommand::ConfigureProcedure { job } + if s.procedure_job_ready_target_count(job) == 0) + }) + })); // Configuration is hosted work: dispatch opens a reservoir on the // chosen machine and the registry stays empty until it fires. diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index d2860dd7..853083d8 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -651,12 +651,12 @@ impl Sim { // Strategically distinct exact holdings stay exact and rank above // generated reports. Routine sightings/schedules never enter this // list; they live once in stream custody. - let mut available = self + let mut findings = self .intel .iter() - .filter(|intel| intel.routine_class().is_none() && self.intel_holding_available(intel)) + .filter(|intel| intel.routine_class().is_none()) .collect::>(); - available.sort_by(|left, right| { + findings.sort_by(|left, right| { Self::intel_importance(left) .cmp(&Self::intel_importance(right)) .then_with(|| right.magnitude.cmp(&left.magnitude)) @@ -664,7 +664,7 @@ impl Sim { .then_with(|| right.raw_id.cmp(&left.raw_id)) }); let mut represented = Vec::::new(); - for intel in available { + for intel in findings { let Some(key) = intel.finding_key() else { continue; }; @@ -673,7 +673,16 @@ impl Sim { } represented.push(key); if let Some(object) = self.actionable_intel_object(intel) - && object.state == ObjectState::Available + // A sold record leaves live custody, not the player's mind. + // Keep a completed finding on the decision rail while the + // knowledge still opens a real approach; once that consequence + // is expended, quiet history falls out of the live root again. + && (object.state == ObjectState::Available + || object + .consequence + .as_ref() + .and_then(|consequence| consequence.action.as_ref()) + .is_some()) { out.push(object); } @@ -3543,6 +3552,116 @@ mod tests { assert_eq!(s.accounts.slush_balance(), before); } + #[test] + fn sold_leverage_keeps_the_learned_approach_on_the_live_intel_rail() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Leverage; + s.people.people[0].leverage = Leverage::Debt; + s.people.has_channel = true; + s.set_persona("Sam", "contractor"); + s.intel.push(ProcessedIntel { + raw_id: 919, + tick: 1, + processed_tick: 2, + feed: "creditor call".into(), + room: None, + x: 0, + y: 0, + person: Some(0), + magnitude: crate::intel::IntelMagnitude::MIN, + kind: IntelKind::Leverage(Leverage::Debt), + }); + + assert!(s.sell_intel(919)); + + assert_eq!(s.people.people[0].knowledge, Knowledge::Leverage); + let finding = s + .operations_projection() + .intel + .into_iter() + .find(|object| object.target == OperationsTarget::Intel { raw_id: 919 }) + .expect("sold custody must not erase an actionable learned finding"); + assert_eq!(finding.state, ObjectState::Completed); + assert!( + finding.actions.is_empty(), + "the sold record is no longer owned" + ); + assert_eq!( + finding + .consequence + .as_ref() + .and_then(|consequence| consequence.action.as_ref()) + .map(|action| action.label.as_str()), + Some("APPROACH MARCUS") + ); + assert!( + s.operations_object(&OperationsTarget::IntelOpportunity { raw_id: 919 }) + .is_some(), + "the approach remains reachable after the evidence record is sold" + ); + } + + #[test] + fn a_semantic_finding_keeps_the_sale_for_its_exact_record_still_in_custody() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Leverage; + s.people.people[0].leverage = Leverage::Debt; + s.intel.extend([ + ProcessedIntel { + raw_id: 919, + tick: 1, + processed_tick: 3, + feed: "fresh creditor call".into(), + room: None, + x: 0, + y: 0, + person: Some(0), + magnitude: crate::intel::IntelMagnitude::MAX, + kind: IntelKind::Leverage(Leverage::Debt), + }, + ProcessedIntel { + raw_id: 920, + tick: 1, + processed_tick: 2, + feed: "older creditor call".into(), + room: None, + x: 0, + y: 0, + person: Some(0), + magnitude: crate::intel::IntelMagnitude::MIN, + kind: IntelKind::Leverage(Leverage::Debt), + }, + ]); + + assert!(s.sell_intel(919)); + assert!( + !s.intel_holding_available(s.intel.iter().find(|intel| intel.raw_id == 919).unwrap()) + ); + assert!( + s.intel_holding_available(s.intel.iter().find(|intel| intel.raw_id == 920).unwrap()) + ); + + let finding = s + .operations_projection() + .intel + .into_iter() + .find(|object| matches!(object.target, OperationsTarget::Intel { .. })) + .expect("the duplicate finding remains represented"); + assert_eq!( + finding.target, + OperationsTarget::Intel { raw_id: 919 }, + "the semantic finding keeps its stable minimum-id address" + ); + assert_eq!(finding.state, ObjectState::Available); + assert!( + finding + .actions + .iter() + .any(|row| matches!(row.command, ActionCommand::SellIntel { raw_id: 920 })), + "the sale command binds only the corroborating record still in custody" + ); + } + #[test] fn serviced_leverage_leaves_the_live_intel_rail() { let mut s = sim(); diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index a406ae93..5660004a 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -789,7 +789,7 @@ impl OperationsWorkspace { } _ => entries.push(OpsActionEntry::Action { label: row.label.clone(), - description: None, + description: action_consequence_description(object, &row), row, }), } @@ -1566,6 +1566,29 @@ impl OperationsWorkspace { } } +/// State what an information sale transfers before the player commits it. +/// The command sells exact record custody; a processed finding and any live +/// social route it unlocked remain learned. This is presentation over the +/// same bound command and consequence link, never another ownership model. +fn action_consequence_description(object: &OperationsObject, row: &MenuRow) -> Option { + let records = match &row.command { + ActionCommand::SellIntel { .. } => 1, + ActionCommand::SellIntelBatch { raw_ids } => raw_ids.len(), + _ => return None, + }; + object.learned_result.as_ref()?; + let still_opens = object + .consequence + .as_ref() + .and_then(|consequence| consequence.action.as_ref()) + .map(|action| format!("\nSTILL OPENS: {}", action.label)) + .unwrap_or_default(); + let record_word = if records == 1 { "RECORD" } else { "RECORDS" }; + Some(format!( + "KEEPS: WHAT YOU LEARNED{still_opens}\nGIVES UP: {records} EXACT {record_word}" + )) +} + fn action_submenu_for_row(row: &MenuRow) -> Option { match row.command { ActionCommand::StartPlot { .. } @@ -1750,6 +1773,48 @@ mod tests { )); } + #[test] + fn leverage_sale_says_what_it_keeps_and_what_it_gives_up() { + let mut sim = Sim::new(); + sim.people.people[0].knowledge = Knowledge::Leverage; + sim.people.people[0].leverage = Leverage::Debt; + sim.people.has_channel = true; + sim.set_persona("Sam", "contractor"); + sim.intel.push(ProcessedIntel { + raw_id: 11, + tick: 10, + processed_tick: 11, + feed: "creditor call".into(), + room: Some("Server Room".into()), + x: 0, + y: 0, + person: Some(0), + magnitude: IntelMagnitude::new(2).unwrap(), + kind: IntelKind::Leverage(Leverage::Debt), + }); + let mut ops = OperationsWorkspace::open(); + let index = ops + .objects(&sim) + .iter() + .position(|object| object.target == OperationsTarget::Intel { raw_id: 11 }) + .unwrap(); + ops.select_object_index(&sim, index); + + let sale = ops + .action_entries(&sim) + .into_iter() + .find(|entry| { + entry + .row() + .is_some_and(|row| matches!(row.command, ActionCommand::SellIntel { .. })) + }) + .expect("the finding carries its exact sale row"); + assert_eq!( + sale.description(), + Some("KEEPS: WHAT YOU LEARNED\nSTILL OPENS: APPROACH MARCUS\nGIVES UP: 1 EXACT RECORD") + ); + } + #[test] fn exact_action_selection_survives_live_policy_reordering() { let mut sim = Sim::new(); diff --git a/crates/misaligned-core/src/sim/procedure.rs b/crates/misaligned-core/src/sim/procedure.rs index 4cbe9233..9e8c9f4a 100644 --- a/crates/misaligned-core/src/sim/procedure.rs +++ b/crates/misaligned-core/src/sim/procedure.rs @@ -663,6 +663,37 @@ impl Sim { jobs } + /// How many exact people this install could act on now through the same + /// ordinary candidate authority the resident scheduler will use later. + /// This is a presentation read, not a promise that mutable world state + /// will still be ready when the Thought-backed configuration lands. + pub(crate) fn procedure_job_ready_target_count(&self, job: &ProcedureJob) -> usize { + let ProcedureChange::Install(blueprint) = &job.change else { + return 0; + }; + let candidate = ResidentProcedure { + id: ProcedureId::MAX, + host_machine: job.host_machine, + persona_id: blueprint.persona_id, + mandate: blueprint.mandate.clone(), + methods: blueprint.methods.clone(), + input: blueprint.input.clone(), + envelope: blueprint.envelope.clone(), + installed_tick: self.tick, + }; + candidate + .input + .scope + .iter() + .filter(|target| { + candidate.methods.iter().any(|grant| { + self.procedure_candidate_reason(&candidate, **target, grant) + .is_none() + }) + }) + .count() + } + /// Test scaffolding may choose any machine; production surfaces always /// call `procedure_job_for_plot_on` from an explicitly selected host. #[cfg(test)] diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index be428ed5..52d9a821 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -494,6 +494,7 @@ impl App { } Some( HumanMenuRow::Dial { .. } + | HumanMenuRow::ProcedureDormant { .. } | HumanMenuRow::LinkDestinations { .. } | HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 1ac69f33..c1b02829 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -23,6 +23,12 @@ Status note: IMPLEMENTED. Current state: live destination chooser. `PROPOSE A LINK` opens `WHERE SHOULD THIS CONNECT?`; terminal and Bevy re-query that shared exact-command projection while agent mode retains its flat exact-command list. + - **Resident work is ready-first.** A host rack's human root lists only + install routes with at least one exact eligible person now, naming the + authored route and ready-person count. Every legal route with zero current + candidates folds behind one `NO CURRENT WORK` row; that live page explains + the absence without making future standing automation illegal. Agent mode + retains the complete flat exact-command inventory. - **Focus grammar.** An explicitly selected owned machine shows a bare `WORK / THINK / LIE` line (current mode bone-bright). A known device under pointer or reticule focus shows identity and relationship state only; its @@ -295,6 +301,14 @@ noise). Cameron adopted **status dials** (2026-07-09): the root answers the row count shrinks, and changes no simulation state until one exact command executes. Social and flow actions are not spatial one-shots; Operations renders them on their person/flow targets. +- **Resident procedure routes are ready-first.** The host root shows one + player-named install row per legal authored route that has at least one exact + eligible person now, including the ready-person count. Legal routes with no + candidate fold into one `NO CURRENT WORK · N` row and a live nested page. + The nested rows state that nobody can use the route right now but remain + installable standing automation; zero current candidates is information, + not a fabricated blocker. Exact host, persona, method, scope, and envelope + remain bound to the unchanged command. Retirement remains a direct root act. - **Automate lives in the dial.** Standing-policy / automate affordances for a dial live inside that dial's picker, not indented under every root alternative. @@ -425,6 +439,12 @@ made a three-word decision look dirty, slow, and administrative. controls. 10. `5`-`9` count committed action rows only. A newly added control cannot silently shift quick-action numbering. +11. A host with legal resident install jobs projects only positive + ready-person counts on the human root. Every zero-candidate job appears + exactly once behind one `NO CURRENT WORK` page, names its authored route, + explains the current absence, and retains the same executable bound + command. Terminal and Bevy consume that hierarchy; agent mode keeps the + flat exact jobs. ### Held-command-beat criteria (DECIDED 2026-08-02) diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index b899e73b..b45352b3 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -82,6 +82,14 @@ Status note: Reopened 2026-08-02 (adopted): selected OPENS/RELATED pane that pointer hover could reach while j/k navigation could not. Enter follows its exact semantic target; sale and control rows retain their existing commands and confirmation law. + Amended 2026-08-06: selecting a strategic finding sale states the ownership + boundary before confirmation: the learned result stays, any exact action it + still opens is named, and only the counted corroborating-record custody is + given up. A sold actionable finding remains on the compact INTEL rail while + that learned route is still live, then leaves when the consequence is spent. + Bevy Operations pointer rows now also retain entity continuity across idle + frames: global input does not dirty `Game` without real input, and pointer + action/persona creation dispatch drains its event trace at the act itself. Amended 2026-08-04: Human Operations surfaces no longer expose the internal term `actuator` while following a strategic object into the physical world. Terminal names `where this happens: ` and offers `f focus`; the @@ -570,6 +578,13 @@ envelope; it never makes the current one-shot preview absorb later arrivals. Both previews name the B1 external buyer route, payout account and amount, expected Financial observer band, channel, and sold state after commitment. +Before confirmation, a selected strategic-finding sale also separates what +persists from what transfers: **KEEPS** names the learned result, +**STILL OPENS** names any current consequence action, and **GIVES UP** counts +the exact corroborating records whose custody will leave. Selling a +consequential finding therefore cannot make an earned social route disappear +from the decision rail merely because its source record moved; that completed +finding stays live while its consequence action remains live. Selling clears only that exact item or lot snapshot, leaves learned knowledge intact, and cannot sell the same output twice. The completion event opens the exact payout account where the durable transaction lives. Cumulative sale @@ -981,7 +996,11 @@ state is not saved and never mutates or advances the sim. support distinct choices. Exact and lot sales preview and bind the selected target or report-lot generation/revision, preserve learned knowledge, cannot sell the same output twice, reject stale lot revisions without mutation, and - target the exact payout account on completion. + target the exact payout account on completion. A strategic finding's + pre-confirmation read states the exact corroborating-record count + transferred, the learned result retained, and any consequence action still + open; a sold actionable finding remains in the compact root until that + action is spent. 5. The pooled information inbox remains one host-bound source with PROCESS and PROCESS AUTOMATICALLY intentions; legacy direct `r`/`R` paths may remain. The default object index groups pending information by earned source/coarse kind; @@ -1052,7 +1071,10 @@ state is not saved and never mutates or advances the sim. and commands rather than relying on live display indexes. Reordering may move a cursor's displayed position but cannot change what Enter follows or executes; disappearance returns attention to the object before a fallback - row becomes actionable. + row becomes actionable. In Bevy, an idle global-input frame leaves `Game` + unchanged so a rendered action or persona row survives through its + Hovered-to-Pressed transition; pointer and keyboard reach the same bound + confirmation, persona rebind, and identity-creation commands. 13. Every Operations object may expose only earned related-object links with a named causal relation and stable semantic target. Following a link opens that exact canonical object and owning view in terminal, Bevy, and agent diff --git a/wiki/log/2026-08-06-action-choice-clarity.md b/wiki/log/2026-08-06-action-choice-clarity.md new file mode 100644 index 00000000..8dfcef02 --- /dev/null +++ b/wiki/log/2026-08-06-action-choice-clarity.md @@ -0,0 +1,74 @@ +# 2026-08-06 — Sales and resident routes say what the choice changes + +``` +Type: log +``` + +## Finding + +A focused action-clarity playtest found two places where the player had to +reverse-engineer the choice from implementation state. + +Selecting SELL PROCESSED INTEL named the payout and detection exposure but did +not say what “sell” transferred. It was impossible to tell whether Marcus's +debt knowledge, the exact record, and APPROACH MARCUS would all disappear +together. The simulation already separated those things — sale moved exact +record custody while learned leverage persisted — but the decision surface did +not. + +The host rack's resident-procedure surface presented every install job as a +flat, nearly identical row. It did not say which routes had anyone eligible +now, so the player had to inspect the catalog one route at a time. During Bevy +verification, a deeper pointer failure also surfaced: idle global input took +mutable `Game` references, marked the resource changed, and caused Operations +to destroy and rebuild its button entities every frame. A row could reach +Hovered but the entity was gone before Pressed arrived. + +## Changed + +- A strategic finding sale now says `KEEPS: WHAT YOU LEARNED`, names a live + `STILL OPENS` action when one exists, and says `GIVES UP: N EXACT RECORD(S)` + before confirmation. A sold actionable finding remains on the compact INTEL + rail while its learned consequence route remains usable; spent leverage + still leaves the live rail. +- The host's human root now shows only legal resident installs with at least + one exact scheduler-eligible person, using the authored plot title and an + `N READY TARGET(S)` count. Legal zero-candidate routes fold into one + `NO CURRENT WORK · N` page. Its rows explain the current absence but remain + installable as standing automation for future arrivals. Agent mode retains + the flat exact job inventory. +- Bevy's global F3 and Operations keyboard paths take mutable `Game` only when + a real key arrives. Pointer action and persona-creation dispatches drain the + event trace at the act itself. Idle frames therefore preserve button entity + identity, and click reaches the same confirmation, persona rebind, or + creation command as Enter. + +The simulation and save schemas are unchanged. + +## Evidence + +- Core projection tests pin the sale's retained learned route after custody + transfers, the exact three-line pre-confirmation explanation, positive-only + ready routes on root, and the complete zero-candidate page. +- Bevy regressions pin an unchanged `Game` resource on idle global input, a + no-op repeated hover, pointer arrival at the exact plot confirmation, exact + persona rebinding, and in-place persona creation plus rebinding. +- Shared `HumanMenuPage` projection keeps terminal and Bevy hierarchy identical; + the bound `ConfigureProcedure` command preserves host, persona, method, + scope, and envelope. +- Final verification is the exact landing gate after fresh-origin + reconciliation. + +## Defense + +operations-workspace.md requires consequence before context and exact sales to +preserve learned knowledge. The new read names the custody boundary before the +irreversible external commitment, and the live-rail regression prevents UI +organization from erasing an earned option. context-menu.md requires a short +human choice list over the same exact commands as agent mode: deriving ready +counts through the resident scheduler's own candidate authority removes +catalog noise without inventing frontend legality, while the nested page keeps +future standing automation reachable. The Bevy change is defended at the +actual failure boundary — resource change detection and entity continuity — so +pointer parity cannot pass only as a direct state-machine unit test while the +rendered row remains unclickable. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 67c6883f..7c66812d 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -41,6 +41,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-06-cells-and-perception-capture.md](2026-08-06-cells-and-perception-capture.md) +## 2026-08-06 - Sales and resident routes say what the choice changes + +- Intent: (see session log) +- Log: [wiki/log/2026-08-06-action-choice-clarity.md](2026-08-06-action-choice-clarity.md) + ## 2026-08-05 - Design session: earned forecast precision, self-similar at every scale - Intent: (see session log) diff --git a/wiki/log/decisions/2026-08-06.md b/wiki/log/decisions/2026-08-06.md index f2e1d972..e3232279 100644 --- a/wiki/log/decisions/2026-08-06.md +++ b/wiki/log/decisions/2026-08-06.md @@ -175,3 +175,33 @@ Owner: [scale.md](../../vision/scale.md#self-similar-scale), Owner: [digital-read.md](../../interface/digital-read.md) (the gain slot, binding 2026-08-06). + +## Action choices state custody and current reach + +Session: Cameron with Trace, from a focused playtest of leverage sale and host +resident-procedure choices. + +### DECIDED + +- **A strategic finding sale separates memory from custody before + confirmation.** Human Operations says what learned result stays, names any + exact action that still opens, and counts the exact corroborating records + given up. Selling a consequential finding does not remove its earned route + from the live rail while that route remains usable. +- **Resident install choices are ready-first, not a flat catalog.** A host's + human root lists legal authored routes with at least one exact eligible + person now and names the ready count. Legal zero-candidate routes fold behind + one NO CURRENT WORK row; they remain installable in advance because absence + of current work is not illegality. Agent mode retains the exact flat jobs. + +### REJECTED + +- Treating “sell” as self-explanatory when the simulation separately owns + exact record custody, learned knowledge, and unlocked action reach. +- Repeating every compiled route as an undifferentiated INSTALL sibling, or + deleting zero-candidate routes so standing automation cannot be prepared in + advance. + +Owner: [operations-workspace.md](../../interface/operations-workspace.md), +[context-menu.md](../../interface/context-menu.md), and +[research.md](../../mechanics/research.md#resident-procedure-contract). diff --git a/wiki/mechanics/research.md b/wiki/mechanics/research.md index 9b8eb4c8..3814b4a7 100644 --- a/wiki/mechanics/research.md +++ b/wiki/mechanics/research.md @@ -24,6 +24,13 @@ and the session agent; forecast ladder, and a Person model may be studied against one authored plot to learn its payoff and what would break its beats, completing into a held choice on that exact route. + Amended 2026-08-06 (implemented resident slice): a host's human action root + exposes only legal install routes with exact eligible people now, using + authored route titles and ready-person counts. Legal zero-candidate routes + fold into a live NO CURRENT WORK page and remain installable in advance; + terminal and Bevy share the hierarchy while agent mode retains flat exact + jobs. This presentation read uses the resident scheduler's candidate + authority and does not implement the still-pending archive/model graph. Stage: B1 Work order: research-graph Work priority: 40 @@ -586,6 +593,10 @@ remains the format authority. authority and interrupts on a novel regime; resident procedures persist through rollback on their machines while re-derivation requires the possibly lost model (tests). + The implemented host-menu slice computes current candidate counts through + that same exact scheduler authority: positive-count install routes live on + the human root, while legal zero-count routes fold under NO CURRENT WORK + without losing their exact install command or becoming illegal. 11. STUDY concurrency is bound by hosting machinery, not a sim-global rule (test proves hardware-bound); switching studies parks progress without loss (test).