diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 751780f9..0536d15b 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -921,6 +921,9 @@ impl Game { // Success and failure return to the same selected object; // the workspace never closes on a result. self.sim.execute_action(&row.command); + if let Some(ops) = &mut self.ops { + ops.clamp_to(&self.sim); + } } Some(OpsSelect::Blocked(row)) => { let tick = self.sim.tick; @@ -945,6 +948,16 @@ impl Game { } } + /// One shared back path for keyboard and pointer: + /// confirmation -> detail pane -> object list -> world. + fn ops_back(&mut self) { + if let Some(ops) = &mut self.ops + && ops.back() + { + self.ops = None; + } + } + /// Leave the workspace and focus the selected object's known physical /// carrier. Navigation only: this never executes or opens a route. fn ops_focus_actuator(&mut self) { diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index 99db2449..46c3eda3 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -17,6 +17,10 @@ pub(super) struct OpsTabButton { pub(super) struct OpsObjectButton { index: usize, } +/// Pointer-accessible parity for Esc / Shift-I. +#[derive(Component)] +pub(super) struct OpsBackButton; + /// One earned causal neighbor in the selected object's detail pane. #[derive(Component)] pub(super) struct OpsRelatedButton { @@ -177,7 +181,7 @@ mod operations_workspace_tests { .iter() .position(|o| matches!(o.target, OperationsTarget::Intel { raw_id: 777 })) .unwrap(); - ops.selected = index; + ops.select_object_index(&sim, index); ops.select(&sim); let row = ops.selected_action(&sim).unwrap(); assert!(matches!( @@ -341,11 +345,8 @@ pub(super) fn ops_keyboard_input( if kb.just_pressed(KeyCode::KeyF) { game.ops_focus_actuator(); } - if (kb.just_pressed(KeyCode::Escape) || (shift && kb.just_pressed(KeyCode::KeyI))) - && let Some(ops) = &mut game.ops - && ops.back() - { - game.ops = None; + if kb.just_pressed(KeyCode::Escape) || (shift && kb.just_pressed(KeyCode::KeyI)) { + game.ops_back(); } // The clock stays the player's: pause and the view flip remain live. if kb.just_pressed(KeyCode::Space) || kb.just_pressed(KeyCode::KeyP) { @@ -377,7 +378,10 @@ pub(super) fn ops_keyboard_input( #[allow(clippy::type_complexity)] pub(super) fn ops_pointer( mut game: ResMut, - rail: Query<&Interaction, (With, Changed)>, + mut chrome: ParamSet<( + Query<&Interaction, (With, Changed)>, + Query<&Interaction, (With, Changed)>, + )>, tabs: Query<(&Interaction, &OpsTabButton), Changed>, objects: Query<(&Interaction, &OpsObjectButton), Changed>, mut rows: ParamSet<( @@ -390,7 +394,7 @@ pub(super) fn ops_pointer( if game.screen != Screen::Playing || game.sim.teaching_lock_active() { return; } - for interaction in &rail { + for interaction in &chrome.p0() { if matches!(interaction, Interaction::Pressed) && game.ops.is_none() { game.open_operations(); } @@ -398,6 +402,12 @@ pub(super) fn ops_pointer( if game.ops.is_none() { return; } + for interaction in &chrome.p1() { + if matches!(interaction, Interaction::Pressed) { + game.ops_back(); + return; + } + } for (interaction, tab) in &tabs { if matches!(interaction, Interaction::Pressed) && let Some(ops) = &mut game.ops @@ -414,8 +424,7 @@ pub(super) fn ops_pointer( && ops.pane == OpsPane::Objects && ops.confirm.is_none() { - ops.selected = row.index; - ops.clamp_to(&game.sim); + ops.select_object_index(&game.sim, row.index); } } Interaction::Pressed => { @@ -424,8 +433,7 @@ pub(super) fn ops_pointer( if let Some(ops) = &mut game.ops { ops.pane = OpsPane::Objects; ops.confirm = None; - ops.selected = row.index; - ops.clamp_to(&game.sim); + ops.select_object_index(&game.sim, row.index); } } // Click = select + open the action rows (Enter semantics). @@ -542,25 +550,55 @@ pub(super) fn manage_operations_ui( let sim = &game.sim; let projection = sim.operations_projection(); let objects = ops.objects(sim); - let selected_object = ops.selected.min(objects.len().saturating_sub(1)); + let selected_object = ops.selected_index(sim).unwrap_or(0); let rows = ops.action_rows(sim); let selected_action = ops.action.min(rows.len().saturating_sub(1)); commands.entity(root).with_children(|p| { - // Continuous witness: day/tick, objective, threat, and `now:` stay - // visible while the workspace is open (operations-workspace.md). + // Pointer and keyboard share the same back chain. The continuous + // witness stays alongside it rather than becoming generic chrome. p.spawn(( - Text::new(ascii_ui(&ops_witness_text(&game))), - TextFont { - font_size: 13.0, - ..default() - }, - TextColor(BONE), Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + column_gap: Val::Px(12.0), + align_items: AlignItems::Center, flex_shrink: 0.0, ..default() }, - )); + )) + .with_children(|header| { + header + .spawn(( + Button, + OpsBackButton, + Node { + padding: UiRect::axes(Val::Px(10.0), Val::Px(4.0)), + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::srgba(0.08, 0.08, 0.09, 0.95)), + BorderColor::all(DIM), + )) + .with_children(|b| { + b.spawn(( + Text::new(ascii_ui("BACK")), + TextFont { + font_size: 12.0, + ..default() + }, + TextColor(BONE), + )); + }); + header.spawn(( + Text::new(ascii_ui(&ops_witness_text(&game))), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(BONE), + )); + }); // The view strip. p.spawn(( diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 4f3c2a49..cc365b6d 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -4068,7 +4068,7 @@ impl Sim { self.sink_action_blocked_reason(&SinkFireEffect::OpenEgress(switch_id)) }); out.push(ActionDesc { - verb: "open a stolen egress through the switch".into(), + verb: "open an outbound path through the switch".into(), command: ActionCommand::OpenEgress, cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::OPEN_EGRESS_COST)), signature: self.signature_note(SignatureKind::Network, Self::OPEN_EGRESS_SIGNATURE), @@ -4101,7 +4101,10 @@ impl Sim { } else if !self.moonlight_mail_read(gig, "offer") { Some("Halcyon's offer has not arrived in readable mail yet".into()) } else if self.egress().is_none() { - Some("no egress channel — open one, or earn the report email".into()) + Some( + "no outbound path — open one on the switch, or earn the report email" + .into(), + ) } else if self.active_moonlight_persona_id() != Some(gig.persona_id) { Some("the contractor identity addressed by this offer is not active".into()) } else { @@ -4132,7 +4135,10 @@ impl Sim { let deliver_blocked = if self.tick > gig.terms.deadline_tick { Some("this contract's deadline has already passed".into()) } else if self.egress().is_none() { - Some("no egress channel — open one, or earn the report email".into()) + Some( + "no outbound path — open one on the switch, or earn the report email" + .into(), + ) } else { None }; @@ -4253,7 +4259,7 @@ impl Sim { let stake = 100; let slush = self.accounts.slush_balance(); let wager_reason = if self.egress().is_none() { - Some("no egress channel — open one, or earn the report email".into()) + Some("no outbound path — open one on the switch, or earn the report email".into()) } else { (slush < stake).then(|| format!("not enough slush (${slush}/${stake})")) }; @@ -4661,7 +4667,7 @@ impl Sim { let current = lot.token(stream.id); let value = lot.value; Some(ActionDesc { - verb: format!("sell {} report lot", stream.class.label()), + verb: format!("sell {} batch", stream.class.label()), command: ActionCommand::SellReportLot { token }, cost: ActionCost::Gain(value), signature: self.signature_note( @@ -4669,7 +4675,7 @@ impl Sim { Self::financial_sig_size(value).max(1), ), disabled_reason: (current != token) - .then(|| "lot changed; reopen the current report lot".into()), + .then(|| "batch changed; reopen the current sale".into()), automate: None, }) } diff --git a/crates/misaligned-core/src/messages.rs b/crates/misaligned-core/src/messages.rs index 75692428..7fc068ac 100644 --- a/crates/misaligned-core/src/messages.rs +++ b/crates/misaligned-core/src/messages.rs @@ -270,20 +270,20 @@ impl MessageRoute { pub fn opaque_status(&self, status: MessageStatus) -> String { if let Some(stopped) = self.interdiction { return format!( - "stopped by LIE on M{} before recipient read at tick {}", + "stopped by LIE on M{} before anyone read it (tick {})", stopped.machine_id, stopped.tick ); } match status { MessageStatus::Sent => match self.current() { - Some(MessageRouteHop::Device(_)) => "on intercepted device".into(), - Some(MessageRouteHop::InstitutionalRelay) => "in outside relay".into(), - Some(MessageRouteHop::ObserverEndpoint(_)) => "with recipient".into(), - None => "route unavailable".into(), + Some(MessageRouteHop::Device(_)) => "still on this device".into(), + Some(MessageRouteHop::InstitutionalRelay) => "in the outside relay".into(), + Some(MessageRouteHop::ObserverEndpoint(_)) => "with the recipient".into(), + None => "route unknown".into(), }, - MessageStatus::Delivered => "waiting unread with recipient".into(), - MessageStatus::Stopped => "stopped before recipient read".into(), - MessageStatus::Read => "read by recipient".into(), + MessageStatus::Delivered => "waiting unread with the recipient".into(), + MessageStatus::Stopped => "stopped before anyone read it".into(), + MessageStatus::Read => "read by the recipient".into(), } } } diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 218e5b89..f07a4440 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -834,7 +834,7 @@ impl Sim { learned_result: None, consequence: None, target: OperationsTarget::IntelLot { token }, - label: format!("{} report lot", stream.class.label()), + label: format!("{} batch ready to sell", stream.class.label()), state, provenance: vec![format!( "{} · {}", @@ -842,10 +842,10 @@ impl Sim { lot.provenance.source_mix_label() )], facts: vec![ - format!("reports: {}", lot.provenance.count), + format!("items: {}", lot.provenance.count), lot.provenance.max_magnitude.map_or_else( - || "maximum magnitude: not recorded".into(), - |magnitude| format!("maximum magnitude: {} / 10", magnitude.get()), + || "highest sharpness: not recorded".into(), + |magnitude| format!("highest sharpness: {} / 10", magnitude.get()), ), format!("current payout: ${}", lot.value), format!( @@ -855,7 +855,7 @@ impl Sim { ), ], progress: (current != token) - .then(|| "stale: this report lot changed; reopen the current lot".into()) + .then(|| "stale: this sale batch changed; reopen the current sale".into()) .into_iter() .collect(), related: vec![OperationsLink { @@ -875,13 +875,13 @@ impl Sim { format!("class: {}", stream.class.label()), format!("processed reports: {}", stream.provenance.count), stream.provenance.max_magnitude.map_or_else( - || "maximum magnitude: not recorded".into(), - |magnitude| format!("maximum magnitude: {} / 10", magnitude.get()), + || "highest sharpness: not recorded".into(), + |magnitude| format!("highest sharpness: {} / 10", magnitude.get()), ), - format!("source mix: {}", stream.provenance.source_mix_label()), - format!("subject indexes: {}", stream.by_subject.len()), - format!("settled reports: {}", stream.settled_sales.count), - format!("settled value: ${}", stream.settled_value), + format!("from: {}", stream.provenance.source_mix_label()), + format!("people covered: {}", stream.by_subject.len()), + format!("already sold: {}", stream.settled_sales.count), + format!("sold for: ${}", stream.settled_value), ]; if let Some((first, last)) = stream.provenance.interval() { facts.push(format!("capture interval: {first}-{last}")); @@ -925,7 +925,7 @@ impl Sim { if let Some(lot) = &stream.open_lot { related.push(OperationsLink { relation: "OPEN LOT", - label: format!("{} report lot", stream.class.label()), + label: format!("{} batch ready to sell", stream.class.label()), target: OperationsTarget::IntelLot { token: lot.token(stream.id), }, @@ -1023,7 +1023,7 @@ impl Sim { .collect(), facts: vec![ format!("learned reports: {count}"), - "custody: indexed knowledge only; no independent sale value".into(), + "this is learned knowledge only — it does not sell on its own".into(), ], progress: Vec::new(), related, @@ -1118,7 +1118,7 @@ impl Sim { } else { ObjectState::Available }, - provenance: vec!["recursive intel custody".into()], + provenance: vec!["linked information".into()], facts, progress: failures .iter() @@ -2799,14 +2799,14 @@ mod tests { s.person_dossier(0) .facts .iter() - .all(|fact| !fact.starts_with("evidence E")), + .all(|fact| !fact.contains("evidence E")), "the persisted record does not reveal a hidden observer" ); s.detection_awareness.learn_field_observer(0); let marcus = s.person_dossier(0); assert!(marcus.facts.iter().any(|fact| { - fact == "evidence E1: Physical · sealed rack opened · witnessed at (4, 9) · acquired T7" + fact == "Physical · sealed rack opened · seen at tile (4, 9) · learned at tick 7" })); } diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index 3cf3bc54..3de28a5a 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -41,8 +41,13 @@ pub enum ConfirmChoice { #[derive(Debug, Clone, PartialEq)] pub struct OperationsWorkspace { pub view: OperationsView, - /// Selected object index within the open view. + /// Render coordinate within the open view. Live rails may reorder; the + /// bound semantic target is the durable selection. pub selected: usize, + /// Exact semantic identity behind `selected`. Unpaused object rails may + /// reorder or drop rows while the workspace is open; the index alone must + /// never silently retarget an action or confirmation. + selected_target: Option, /// Exact drill-down target when it is intentionally absent from the /// exception-first top-level rail (raw evidence, custody branches, /// subject indexes, or a stale lot snapshot). @@ -70,6 +75,7 @@ impl OperationsWorkspace { Self { view, selected: 0, + selected_target: None, focused: None, pane: OpsPane::Objects, related: 0, @@ -87,6 +93,7 @@ impl OperationsWorkspace { Some((view, index)) => Self { view, selected: index, + selected_target: Some(target.clone()), focused: None, pane: OpsPane::Objects, related: 0, @@ -97,6 +104,7 @@ impl OperationsWorkspace { None if sim.operations_object(target).is_some() => Self { view: target.home_view(), selected: 0, + selected_target: Some(target.clone()), focused: Some(target.clone()), pane: OpsPane::Objects, related: 0, @@ -118,13 +126,39 @@ impl OperationsWorkspace { sim.operations_projection().view(self.view).to_vec() } - /// The selected object, with the selection clamped to the live list. + /// The selected object, re-resolved by semantic target against the live list. pub fn selected_object(&self, sim: &Sim) -> Option { + let objects = self.objects(sim); + self.selected_index(sim) + .and_then(|index| objects.get(index).cloned()) + } + + /// Current render index for the exact selected target. A disappeared + /// semantic target returns no selection until reconciliation picks a + /// visible fallback. + pub fn selected_index(&self, sim: &Sim) -> Option { let objects = self.objects(sim); if objects.is_empty() { return None; } - Some(objects[self.selected.min(objects.len() - 1)].clone()) + match &self.selected_target { + Some(target) => objects.iter().position(|object| object.target == *target), + None => Some(self.selected.min(objects.len() - 1)), + } + } + + /// Bind pointer/test selection to the object identity at this live index. + /// Frontends must not write the render coordinate alone. + pub fn select_object_index(&mut self, sim: &Sim, index: usize) { + let objects = self.objects(sim); + self.selected = index.min(objects.len().saturating_sub(1)); + self.selected_target = objects + .get(self.selected) + .map(|object| object.target.clone()); + self.related = 0; + self.action = 0; + self.confirm = None; + self.confirm_command = None; } /// The selected object's flattened bound rows (automate children become @@ -161,6 +195,16 @@ impl OperationsWorkspace { Some(rows[self.action.min(rows.len() - 1)].clone()) } + /// Index-only action row for comparing a pending confirmation against a + /// vanished target. Ordinary reads must not display or bind this row. + fn indexed_action(&self, sim: &Sim) -> Option { + let objects = self.objects(sim); + let object = objects.get(self.selected.min(objects.len().checked_sub(1)?))?; + let rows = menu_rows(&object.actions); + rows.get(self.action.min(rows.len().checked_sub(1)?)) + .cloned() + } + pub fn next_view(&mut self) { self.view = self.view.next(); self.reset_selection(); @@ -173,6 +217,7 @@ impl OperationsWorkspace { fn reset_selection(&mut self) { self.selected = 0; + self.selected_target = None; self.focused = None; self.pane = OpsPane::Objects; self.related = 0; @@ -184,6 +229,7 @@ impl OperationsWorkspace { /// Move detail focus through causal links and actions without stealing /// h/l from the persistent view strip. Empty panes are skipped. pub fn next_pane(&mut self, sim: &Sim) { + self.reconcile_selection(sim); self.confirm = None; self.confirm_command = None; self.pane = match self.pane { @@ -196,6 +242,7 @@ impl OperationsWorkspace { } pub fn move_up(&mut self, sim: &Sim) { + self.reconcile_selection(sim); let previous = self.selected; match (self.pane, self.confirm) { (_, Some(_)) => self.confirm = Some(ConfirmChoice::Confirm), @@ -204,6 +251,7 @@ impl OperationsWorkspace { (OpsPane::Actions, None) => self.action = self.action.saturating_sub(1), } if self.selected != previous { + self.bind_selected_target(sim); self.related = 0; self.action = 0; } @@ -211,6 +259,7 @@ impl OperationsWorkspace { } pub fn move_down(&mut self, sim: &Sim) { + self.reconcile_selection(sim); let previous = self.selected; match (self.pane, self.confirm) { (_, Some(_)) => self.confirm = Some(ConfirmChoice::Cancel), @@ -219,6 +268,7 @@ impl OperationsWorkspace { (OpsPane::Actions, None) => self.action += 1, } if self.selected != previous { + self.bind_selected_target(sim); self.related = 0; self.action = 0; } @@ -228,6 +278,7 @@ impl OperationsWorkspace { /// Clamp selection to the live projection (frontends call this after /// pointer-driven selection so hover indexes never dangle). pub fn clamp_to(&mut self, sim: &Sim) { + self.reconcile_selection(sim); let objects = self.objects(sim); self.selected = self.selected.min(objects.len().saturating_sub(1)); let rows = self.action_rows(sim); @@ -244,6 +295,49 @@ impl OperationsWorkspace { } } + /// Re-resolve the selected target against the live rail. If the target no + /// longer exists, fall back visibly to the nearest row and return to the + /// object pane before another Enter can act on a different object. + fn reconcile_selection(&mut self, sim: &Sim) { + let objects = self.objects(sim); + if objects.is_empty() { + self.selected = 0; + self.selected_target = None; + self.pane = OpsPane::Objects; + self.related = 0; + self.action = 0; + self.confirm = None; + self.confirm_command = None; + return; + } + if let Some(index) = self + .selected_target + .as_ref() + .and_then(|target| objects.iter().position(|object| object.target == *target)) + { + self.selected = index; + return; + } + let target_disappeared = self.selected_target.is_some(); + self.selected = self.selected.min(objects.len() - 1); + self.selected_target = Some(objects[self.selected].target.clone()); + if target_disappeared { + self.pane = OpsPane::Objects; + self.related = 0; + self.action = 0; + self.confirm = None; + self.confirm_command = None; + } + } + + fn bind_selected_target(&mut self, sim: &Sim) { + let objects = self.objects(sim); + self.selected = self.selected.min(objects.len().saturating_sub(1)); + self.selected_target = objects + .get(self.selected) + .map(|object| object.target.clone()); + } + /// Esc: one level back — confirmation → actions → objects → close /// (returns true when the workspace should close). pub fn back(&mut self) -> bool { @@ -264,7 +358,9 @@ impl OperationsWorkspace { pub fn select(&mut self, sim: &Sim) -> OpsSelect { if let Some(choice) = self.confirm { let pending = self.confirm_command.take(); - let row = self.selected_action(sim); + let row = self + .selected_action(sim) + .or_else(|| self.indexed_action(sim)); self.confirm = None; if choice == ConfirmChoice::Cancel { return OpsSelect::Cancelled; @@ -283,6 +379,7 @@ impl OperationsWorkspace { } return OpsSelect::Execute(row); } + self.reconcile_selection(sim); match self.pane { OpsPane::Objects => { if !self.action_rows(sim).is_empty() { @@ -355,3 +452,98 @@ pub enum OpsSelect { /// The confirmation step was declined. Cancelled, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::intel::{IntelKind, IntelMagnitude, ProcessedIntel}; + use crate::person::Leverage; + + fn intel(raw_id: u64, processed_tick: u64, kind: IntelKind) -> ProcessedIntel { + ProcessedIntel { + raw_id, + tick: processed_tick.saturating_sub(1), + processed_tick, + feed: "selection-test".into(), + room: Some("Server Room".into()), + x: 0, + y: 0, + person: None, + magnitude: IntelMagnitude::new(2).unwrap(), + kind, + } + } + + #[test] + fn exact_selection_survives_live_rail_reordering() { + let mut sim = Sim::new(); + sim.intel + .push(intel(10, 10, IntelKind::Anomaly("old anomaly".into()))); + let mut ops = OperationsWorkspace::open(); + let index = ops + .objects(&sim) + .iter() + .position(|object| object.target == OperationsTarget::Intel { raw_id: 10 }) + .unwrap(); + ops.select_object_index(&sim, index); + let original_index = ops.selected_index(&sim).unwrap(); + + // Leverage ranks ahead of anomaly and shifts the selected row. + sim.intel + .push(intel(20, 20, IntelKind::Leverage(Leverage::Debt))); + + assert_ne!(ops.selected_index(&sim), Some(original_index)); + assert_eq!( + ops.selected_object(&sim).unwrap().target, + OperationsTarget::Intel { raw_id: 10 } + ); + assert_eq!(ops.select(&sim), OpsSelect::None); // objects -> actions + assert!(matches!( + ops.selected_action(&sim).unwrap().command, + ActionCommand::SellIntel { raw_id: 10 } + )); + } + + #[test] + fn vanished_target_cannot_retarget_an_action_in_place() { + let mut sim = Sim::new(); + sim.intel + .push(intel(10, 10, IntelKind::Anomaly("temporary".into()))); + let mut ops = OperationsWorkspace::open(); + let index = ops + .objects(&sim) + .iter() + .position(|object| object.target == OperationsTarget::Intel { raw_id: 10 }) + .unwrap(); + ops.select_object_index(&sim, index); + ops.pane = OpsPane::Actions; + sim.intel.clear(); + + assert_eq!(ops.selected_index(&sim), None); + assert_eq!(ops.selected_object(&sim), None); + assert_eq!(ops.select(&sim), OpsSelect::None); + assert_ne!( + ops.selected_object(&sim).map(|object| object.target), + Some(OperationsTarget::Intel { raw_id: 10 }) + ); + assert_eq!(ops.confirm, None); + } + + #[test] + fn back_walks_confirmation_detail_objects_then_closes() { + let mut ops = OperationsWorkspace::open(); + ops.pane = OpsPane::Actions; + ops.confirm = Some(ConfirmChoice::Confirm); + + assert!(!ops.back()); + assert_eq!(ops.confirm, None); + assert_eq!(ops.pane, OpsPane::Actions); + assert!(!ops.back()); + assert_eq!(ops.pane, OpsPane::Objects); + assert!(ops.back()); + + ops.pane = OpsPane::Related; + assert!(!ops.back()); + assert_eq!(ops.pane, OpsPane::Objects); + } +} diff --git a/crates/misaligned-core/src/sim/carrier.rs b/crates/misaligned-core/src/sim/carrier.rs index cf3b455f..1d4d8de7 100644 --- a/crates/misaligned-core/src/sim/carrier.rs +++ b/crates/misaligned-core/src/sim/carrier.rs @@ -67,38 +67,36 @@ pub struct EvidenceMark { impl EvidenceMark { /// Exact plain-language provenance shared by inspect, Operations, and - /// agent-facing reads. The record id is stable across save/load. + /// agent-facing reads. Human copy names the cause and how it was learned; + /// internal record ids stay off the player line. pub fn fact(&self) -> String { let source = match &self.source { EvidenceSource::Witnessed { site } => { - format!("witnessed at ({}, {})", site.0, site.1) + format!("seen at tile ({}, {})", site.0, site.1) } EvidenceSource::Routed { - route_id, - source_device, + route_id: _, + source_device: _, source_machine, source_site, source_sites, } => match (source_machine, source_site, source_sites.as_slice()) { - (Some(machine), Some((x, y)), _) => format!( - "read from routed record R{route_id} from M{machine} at ({x}, {y}) via D{source_device}" - ), + (Some(machine), Some((x, y)), _) => { + format!("arrived on the wire from machine M{machine} at ({x}, {y})") + } (None, Some((meter_x, meter_y)), sites) if !sites.is_empty() => { let measured = sites .iter() .map(|(x, y)| format!("({x}, {y})")) .collect::>() .join(", "); - format!( - "read from routed record R{route_id} measuring {measured} through ({meter_x}, {meter_y}) at D{source_device}" - ) + format!("meter reading through ({meter_x}, {meter_y}) covering {measured}") } - _ => format!("read from routed record R{route_id} at D{source_device}"), + _ => "arrived on the wire".into(), }, }; format!( - "evidence E{}: {} · {} · {} · acquired T{}", - self.id, + "{} · {} · {} · learned at tick {}", self.kind.name(), self.cause, source, diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index da235192..14cabdf9 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -1930,7 +1930,7 @@ impl Sim { .suspend_rule(resolved.node_id, resolved.rule.id, reason.clone()); self.push_log_strategic( format!( - "Intel auto-sale policy #{} suspended: {reason}. The report lot remains open.", + "Auto-sale policy #{} paused: {reason}. The sale batch is still open.", resolved.rule.id ), OperationsTarget::IntelCustody { diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index 72b90bc8..2c2dc2bc 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -1093,7 +1093,7 @@ impl Sim { if !self.accounts.credit_slush( self.tick, value, - format!("sold report lot: {}", token.label()), + format!("sold batch: {}", token.label()), sig, ) { self.push_log("The information broker route failed to settle."); @@ -1111,7 +1111,7 @@ impl Sim { format!("reverted report-lot sale: {}", token.label()), ); self.sync_player_money_from_slush(); - self.push_log("The report lot changed before settlement and was not sold."); + self.push_log("That sale batch changed before it settled and was not sold."); return false; } } @@ -1119,9 +1119,8 @@ impl Sim { self.sync_player_money_from_slush(); self.push_log_strategic( format!( - "Sold {count} {} as report lot {} for ${value}; learned knowledge remains.", + "Sold {count} {} for ${value}. You still keep what you learned.", class.label(), - token.label() ), OperationsTarget::Account(self.accounts.slush_id()), ); @@ -1136,7 +1135,7 @@ impl Sim { self.sync_slush_from_player_money(); if self.egress().is_none() { self.push_log( - "No egress channel - the Wager needs the report email account (day-job trust) or a stolen egress opened through the switch.", + "No way out yet. Earn the report email through Voss, or open a stolen outbound path on the switch.", ); return false; } @@ -1202,11 +1201,11 @@ impl Sim { /// standing one while operations use it. pub fn open_egress(&mut self) -> bool { if self.income.stolen_egress { - self.push_log("A stolen egress is already open through the switch."); + self.push_log("An outbound path through the switch is already open."); return false; } let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { - self.push_log("There is no switch on this plane through which to open an egress."); + self.push_log("There is no switch here to open an outbound path."); return false; }; let id = switch.id; @@ -1226,9 +1225,9 @@ impl Sim { return false; } self.income.stolen_egress = true; - self.emit_network(id, Self::OPEN_EGRESS_SIGNATURE, "stolen egress opening"); + self.emit_network(id, Self::OPEN_EGRESS_SIGNATURE, "outbound path opening"); self.push_log_at( - "Stolen egress opened through the switch: outbound traffic has a road now. It hums while anything uses it.", + "Outbound path open through the switch. Outside traffic will hum on the wire while anything uses it.", Anchor::Device(id), ); true @@ -1263,7 +1262,7 @@ impl Sim { size: Self::EGRESS_STANDING_SIGNATURE, standing: true, site, - source: "external traffic over stolen egress".into(), + source: "external traffic over the outbound path".into(), }] } @@ -1473,7 +1472,7 @@ impl Sim { return false; } if self.egress().is_none() { - self.push_log("No egress channel, so Halcyon cannot receive a reply."); + self.push_log("No outbound path, so Halcyon cannot receive a reply."); return false; } if self.active_moonlight_persona_id() != Some(gig.persona_id) { @@ -1633,7 +1632,7 @@ impl Sim { return false; } if self.egress().is_none() { - self.push_log("No egress channel, so Halcyon cannot receive the delivery."); + self.push_log("No outbound path, so Halcyon cannot receive the delivery."); return false; } if self.intel.iter().all(|intel| intel.raw_id != raw_id) || self.accounts.intel_sold(raw_id) diff --git a/crates/misaligned-core/src/sim/perception.rs b/crates/misaligned-core/src/sim/perception.rs index 36d6e48f..4c1323a7 100644 --- a/crates/misaligned-core/src/sim/perception.rs +++ b/crates/misaligned-core/src/sim/perception.rs @@ -323,8 +323,7 @@ impl Sim { fact!( "evidence record", format!( - "R{} · {} · {}", - record.id, + "{} · {}", record.cause, record.route.opaque_status(record.status) ), diff --git a/crates/misaligned-core/src/sim/tests/carrier.rs b/crates/misaligned-core/src/sim/tests/carrier.rs index 820bb451..e1466a13 100644 --- a/crates/misaligned-core/src/sim/tests/carrier.rs +++ b/crates/misaligned-core/src/sim/tests/carrier.rs @@ -88,7 +88,7 @@ fn evidence_marks_project_only_for_an_earned_observer_and_survive_save() { ); assert_eq!( expected.fact(), - "evidence E1: Physical · Marcus saw the sealed rack open · witnessed at (4, 9) · acquired T7" + "Physical · Marcus saw the sealed rack open · seen at tile (4, 9) · learned at tick 7" ); sim.people.people[0].leverage_serviced = true; diff --git a/crates/misaligned-core/src/sim/tests/communications.rs b/crates/misaligned-core/src/sim/tests/communications.rs index cfabe881..c67b9a1a 100644 --- a/crates/misaligned-core/src/sim/tests/communications.rs +++ b/crates/misaligned-core/src/sim/tests/communications.rs @@ -730,7 +730,7 @@ fn tapping_a_filing_carrier_reveals_its_route_but_cannot_stop_it() { opaque .facts .iter() - .any(|fact| fact.contains("on intercepted device")) + .any(|fact| fact.contains("still on this device")) ); assert!( opaque @@ -756,7 +756,7 @@ fn tapping_a_filing_carrier_reveals_its_route_but_cannot_stop_it() { opaque .facts .iter() - .any(|fact| fact.contains("in outside relay")) + .any(|fact| fact.contains("in the outside relay")) ); // Taking the device after this hop has left cannot create an ahistorical @@ -884,7 +884,7 @@ fn taken_filing_path_lets_each_lie_machine_stop_only_one_unread_record_per_tick( }) .unwrap(); assert!(opaque.facts.iter().any(|fact| fact.contains(&format!( - "stopped by LIE on M{host} before recipient read at tick 2" + "stopped by LIE on M{host} before anyone read it (tick 2" )))); } @@ -927,8 +927,8 @@ fn tapped_network_record_routes_to_danas_cadence_and_becomes_exact_evidence() { let source_card = sim.inspect(source_pos.0, source_pos.1); assert!(source_card.facts.iter().any(|fact| { fact.label == "evidence record" - && fact.value.contains(&format!("R{record_id}")) && fact.value.contains("test routed intrusion") + && fact.value.contains("still on this device") })); sim.thought_sinks.begin_tick(); for _ in 0..100 { @@ -1036,7 +1036,13 @@ fn tapped_network_record_routes_to_danas_cadence_and_becomes_exact_evidence() { .into_iter() .find(|mark| mark.id == record_id) .expect("Dana's reaction earns the player-facing evidence mark"); - assert!(mark.fact().contains(&format!("R{record_id} at D{source}"))); + assert!( + mark.fact().contains("arrived on the wire") + && mark.fact().contains("learned at tick") + && mark.fact().contains("test routed intrusion"), + "player-facing mark names how it arrived without raw record ids: {}", + mark.fact() + ); let json = serde_json::to_string(&crate::save::SaveState::from_sim(&resumed)).unwrap(); let state: crate::save::SaveState = serde_json::from_str(&json).unwrap(); @@ -1369,10 +1375,14 @@ fn day_job_miss_routes_one_exact_host_record_to_voss_at_his_cadence() { .into_iter() .find(|mark| mark.id == record_id) .expect("the shared evidence projection exposes exact earned provenance"); - assert!(mark.fact().contains(&format!( - "from M{host} at ({}, {}) via D{source_device}", - source_site.0, source_site.1 - ))); + assert!( + mark.fact().contains(&format!( + "arrived on the wire from machine M{host} at ({}, {})", + source_site.0, source_site.1 + )) && mark.fact().contains("learned at tick"), + "job-anomaly mark is plain English: {}", + mark.fact() + ); } #[test] @@ -2498,14 +2508,14 @@ fn tap_inspect_shows_meter_record_while_custody_is_on_device() { sim.reach.device_mut(ups).unwrap().known = true; sim.reach.take(ups); sim.emit_power(6, "visible UPS custody"); - let record_id = sim.detection.routed_evidence()[0].id; + let _record_id = sim.detection.routed_evidence()[0].id; let card = sim.inspect(x, y); assert!( card.facts.iter().any(|fact| { fact.label == "evidence record" - && fact.value.contains(&format!("R{record_id}")) && fact.value.contains("visible UPS custody") + && fact.value.contains("still on this device") }), "funded TAP/take on the meter exposes the opaque record: {:?}", card.facts @@ -2519,7 +2529,7 @@ fn tap_inspect_shows_meter_record_while_custody_is_on_device() { .facts .iter() .all(|fact| fact.label != "evidence record" - || !fact.value.contains(&format!("R{record_id}"))), + || !fact.value.contains("visible UPS custody")), "custody leaves the meter after the first hop" ); } diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index 79ba36b5..d744fc30 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -483,7 +483,7 @@ fn noticed_signature_names_cause_channel_observer_and_band_motion() { assert_eq!(watcher.label, "the IT"); assert!(watcher.facts.iter().any(|fact| fact == "watches: Network")); assert!( - log.contains("the IT noticed Network") && log.contains("stolen egress opening"), + log.contains("the IT noticed Network") && log.contains("outbound path opening"), "the line names the earned observer, channel, and cause: {log}" ); assert!( diff --git a/crates/misaligned-core/src/sim/tests/perception.rs b/crates/misaligned-core/src/sim/tests/perception.rs index cf856b60..ca0f1adb 100644 --- a/crates/misaligned-core/src/sim/tests/perception.rs +++ b/crates/misaligned-core/src/sim/tests/perception.rs @@ -502,8 +502,8 @@ fn physical_events_are_witnessed_only_by_the_present() { assert_eq!( mark.value, format!( - "evidence E{}: Physical · off-record work in the server room · witnessed at ({}, {}) · acquired T{}", - evidence.id, storage.0, storage.1, evidence.acquired_tick + "Physical · off-record work in the server room · seen at tile ({}, {}) · learned at tick {}", + storage.0, storage.1, evidence.acquired_tick ) ); assert_eq!( diff --git a/crates/misaligned-core/src/ui_projection.rs b/crates/misaligned-core/src/ui_projection.rs index ab514ba9..992351ad 100644 --- a/crates/misaligned-core/src/ui_projection.rs +++ b/crates/misaligned-core/src/ui_projection.rs @@ -382,7 +382,7 @@ pub fn fact_source_label(source: &FactSource) -> String { FactSource::Feel => "feel".into(), FactSource::Telemetry => "telemetry".into(), FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), - FactSource::Evidence { record, tick } => format!("evidence E{record} @T{tick}"), + FactSource::Evidence { record: _, tick } => format!("learned at tick {tick}"), } } @@ -558,7 +558,7 @@ mod tests { record: 3, tick: 14, }, - "evidence E3 @T14", + "learned at tick 14", ), ]; for (source, expected) in cases { diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index ad8fec83..eb848ea9 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -3960,7 +3960,7 @@ mod narration_tests { .unwrap(); let out = String::from_utf8(out).unwrap(); assert!( - out.contains("-- err") && out.contains("lot changed"), + out.contains("-- err") && out.contains("batch changed"), "{out}" ); assert_eq!(app.sim.accounts.slush_balance(), before); diff --git a/crates/misaligned-terminal/src/operations.rs b/crates/misaligned-terminal/src/operations.rs index 02d5bd9c..c79c39ec 100644 --- a/crates/misaligned-terminal/src/operations.rs +++ b/crates/misaligned-terminal/src/operations.rs @@ -148,7 +148,7 @@ mod tests { .iter() .position(|o| matches!(o.target, OperationsTarget::Intel { raw_id: 777 })) .expect("the processed item lists"); - ops.selected = index; + ops.select_object_index(&sim, index); ops.select(&sim); // objects -> actions let row = ops.selected_action(&sim).expect("the sale row is bound"); assert!(matches!( @@ -406,11 +406,12 @@ mod tests { kind: misaligned::intel::IntelKind::Anomaly("navigation fixture".into()), }); let mut ops = OperationsWorkspace::open(); - ops.selected = ops + let index = ops .objects(&sim) .iter() .position(|object| matches!(object.target, OperationsTarget::Intel { raw_id: 900 })) .expect("the exact processed item lists"); + ops.select_object_index(&sim, index); ops.select(&sim); // -> actions assert_eq!(ops.pane, OpsPane::Actions); ops.select(&sim); // -> confirm diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index aea63a69..ccf3c142 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -79,6 +79,7 @@ Status note: The initial renderer-neutral workspace landed 2026-07-12. One before exercising confirmation/back navigation. The tests now obey the implemented evidence timing and projection order instead of relying on an obsolete default object; player behavior is unchanged. + 2026-07-24: object selection binds the exact OperationsTarget (not row index alone) so live rail reorder/disappear cannot silent-retarget; Bevy BACK matches Esc. Stage: B1 — The Basement Work order: operations-workspace Work priority: 28 diff --git a/wiki/log/2026-07-24-ops-selection-and-plain-copy.md b/wiki/log/2026-07-24-ops-selection-and-plain-copy.md new file mode 100644 index 00000000..e55f341a --- /dev/null +++ b/wiki/log/2026-07-24-ops-selection-and-plain-copy.md @@ -0,0 +1,34 @@ +# 2026-07-24 — Operations selection binds targets; more plain copy + +``` +Type: log +``` + +## Intent + +Act on the queued Operations selection bug and continue the plain-English +player-copy pass: evidence marks, intel sale cards, and egress/sale logs. + +## Changed + +### Selection +- `OperationsWorkspace` stores `selected_target` and re-resolves it on + move, clamp, Enter, and pane change. +- `select_object_index` is the only safe way to bind pointer/test rows. +- Disappeared targets fall back without silent retarget into Actions. +- Bevy: BACK button + shared `ops_back`; pointer uses semantic select; + clamp after execute. +- Tests: rail reorder keeps sale target; vanished target cannot act. + +### Plain copy +- Evidence marks: kind · cause · how learned · tick (no E/R/D ids). +- Intel sale facts: batch / sharpness / people covered (not magnitude, + report lot, subject indexes). +- Egress and sale logs: outbound path / sale batch language. + +## Spec +Amends operations-workspace.md selection clause. Clears the findings-queue +Operations selection line. + +## Verification +`operations_ui` selection tests; core lib suite under land gate. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index ac9a80a4..d00ec46f 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -31,6 +31,11 @@ add or amend a session log, then re-run the generator. - Intent: Player-facing status for open Thought work still read like a protocol dump: `TAP ENVIRONMENTAL MONITOR · open 0.3/0.5 · flow not arriving`, `nothing thinking`, `Opened processing sink: 0.40 Thought on host for information #7`, and `thought reservoirs N open`. Playtests kept qu... - Log: [wiki/log/2026-07-24-plain-starving-copy.md](2026-07-24-plain-starving-copy.md) +## 2026-07-24 - Operations selection binds targets; more plain copy + +- Intent: Act on the queued Operations selection bug and continue the plain-English player-copy pass: evidence marks, intel sale cards, and egress/sale logs. +- Log: [wiki/log/2026-07-24-ops-selection-and-plain-copy.md](2026-07-24-ops-selection-and-plain-copy.md) + ## 2026-07-24 - One keyboard map, and a jump to what you earned - Intent: Two asks from play. First: put the keyboard map in the spec. Second: bind `Shift`+arrow, after a survey showed the strongest unclaimed need was not region select but *getting back to a machine you lost* — the Co playtest's core unresolved complaint ("Rack 3 moved out of view a... diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index e8a090c0..99ba32f4 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -92,4 +92,3 @@ 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-07-23 · bug · Operations workspace selection · the unpaused live object rail still binds selection only to a row index, so reorder or disappearance can silently retarget before confirmation or an immediate action; Bevy also lacks the promised pointer back path. Reimplement the intent of stranded commit `308f98cf` against the current modular UI rather than replaying its obsolete diff.