diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index 1b57afb5..bd27adbc 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -302,6 +302,20 @@ mod operations_workspace_tests { .unwrap(); ops.select_object_index(&sim, index); ops.select(&sim); + let entries = ops.action_entries(&sim); + assert!(matches!( + entries.first(), + Some(OpsActionEntry::Open { label, .. }) if label == "APPROACH MARCUS" + )); + let sale = entries + .iter() + .position(|entry| { + entry.row().is_some_and(|row| { + matches!(row.command, ActionCommand::SellIntel { raw_id: 777 }) + }) + }) + .expect("sale follows the consequence route in the same action choir"); + ops.select_action_index(&sim, sale); let row = ops.selected_action(&sim).unwrap(); assert!(matches!( row.command, @@ -1112,7 +1126,6 @@ pub(super) fn manage_operations_ui( obj, &entries, selected_action, - selected_related, &ops, ); }); @@ -1200,8 +1213,8 @@ fn spawn_ops_object_body( let supporting_links = obj .related .iter() + .filter(|link| consequence_link != Some(*link)) .enumerate() - .filter(|(_, link)| consequence_link != Some(*link)) .collect::>(); if !supporting_links.is_empty() { spawn_ops_section_label(detail, "RELATED"); @@ -1234,24 +1247,8 @@ fn spawn_ops_contextual_actions( obj: &misaligned::operations_projection::OperationsObject, entries: &[OpsActionEntry], selected_action: usize, - selected_related: usize, ops: &OperationsWorkspace, ) { - if let Some(action) = obj - .consequence - .as_ref() - .and_then(|consequence| consequence.action.as_ref()) - { - spawn_ops_section_label(actions, "WHAT THIS OPENS"); - let index = obj - .related - .iter() - .position(|link| link == action) - .unwrap_or(0); - let selected = index == selected_related && ops.pane == OpsPane::Related; - spawn_ops_relation_button(actions, action, index, selected); - } - if let Some(label) = sim.operations_actuator_label(&obj.target) { spawn_ops_section_label(actions, "WHERE THIS HAPPENS"); actions @@ -1538,6 +1535,7 @@ fn ops_context_text(obj: &misaligned::operations_projection::OperationsObject) - fn ops_action_entry_line(entry: &OpsActionEntry) -> String { match entry { + OpsActionEntry::Open { label, .. } => format!("{label} >"), OpsActionEntry::Action { label, row, .. } => { let indent = if row.indent { "- " } else { "" }; format!("{indent}{label}") diff --git a/crates/misaligned-bevy/src/shot_harness.rs b/crates/misaligned-bevy/src/shot_harness.rs index 19541cd2..5f7017ef 100644 --- a/crates/misaligned-bevy/src/shot_harness.rs +++ b/crates/misaligned-bevy/src/shot_harness.rs @@ -774,6 +774,8 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s } // Live INTEL index hierarchy: strategic holdings outrank sightings, a // newer sold item is absent, and the pooled inbox follows live holdings. + // Marcus's debt also stages the complete consequence-action seam: the + // exact APPROACH route sits above its sale in one keyboard action list. if kind == "operations-intel" { use misaligned::intel::{IntelKind, ProcessedIntel}; use misaligned::person::Leverage; @@ -822,10 +824,17 @@ pub(super) fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &s item(90_005, 50, Some(2), IntelKind::Sighting), item(90_006, 60, Some(3), IntelKind::Sighting), ]); + game.sim.people.people[0].knowledge = Knowledge::Leverage; + game.sim.set_persona("Sam Reyes", "IT contractor"); + game.sim.accounts.set_slush_balance(1_000); + game.sim.player.money = 1_000; game.sim.accounts.mark_intel_sold(90_006); dev_clear_teaching_lock(game); game.ops = Some(OperationsWorkspace::open_view(OperationsView::Intel)); game.drain(); + let ops = game.ops.as_mut().expect("INTEL shot opened"); + ops.pane = OpsPane::Actions; + ops.select_action_index(&game.sim, 1); mode.material = true; mode.zoom = 2.0; return; diff --git a/crates/misaligned-core/src/operations_ui.rs b/crates/misaligned-core/src/operations_ui.rs index 0666e324..a9ba9c56 100644 --- a/crates/misaligned-core/src/operations_ui.rs +++ b/crates/misaligned-core/src/operations_ui.rs @@ -56,6 +56,14 @@ pub enum OpsActionSubmenu { /// plain-language intent row and open into the same exact rows one level down. #[derive(Debug, Clone, PartialEq)] pub enum OpsActionEntry { + /// A consequence-specific route into the exact choices this information + /// unlocked. It is an action in the human decision list even though its + /// immediate effect is renderer-neutral Operations navigation. + Open { + label: String, + description: String, + target: OperationsTarget, + }, Action { label: String, description: Option, @@ -71,12 +79,15 @@ pub enum OpsActionEntry { impl OpsActionEntry { pub fn label(&self) -> &str { match self { - Self::Action { label, .. } | Self::Submenu { label, .. } => label, + Self::Open { label, .. } | Self::Action { label, .. } | Self::Submenu { label, .. } => { + label + } } } pub fn description(&self) -> Option<&str> { match self { + Self::Open { description, .. } => Some(description), Self::Action { description, .. } => description.as_deref(), Self::Submenu { description, .. } => Some(description), } @@ -85,13 +96,14 @@ impl OpsActionEntry { pub fn row(&self) -> Option<&MenuRow> { match self { Self::Action { row, .. } => Some(row), - Self::Submenu { .. } => None, + Self::Open { .. } | Self::Submenu { .. } => None, } } pub fn submenu(&self) -> Option { match self { Self::Action { .. } => None, + Self::Open { .. } => None, Self::Submenu { submenu, .. } => Some(*submenu), } } @@ -102,6 +114,7 @@ impl OpsActionEntry { /// closed or while an executed action changes the live projection. #[derive(Debug, Clone, PartialEq)] enum OpsActionKey { + Open(OperationsTarget), Command(ActionCommand), Submenu(OpsActionSubmenu), } @@ -109,6 +122,7 @@ enum OpsActionKey { impl OpsActionKey { fn of(entry: &OpsActionEntry) -> Self { match entry { + OpsActionEntry::Open { target, .. } => Self::Open(target.clone()), OpsActionEntry::Action { row, .. } => Self::Command(row.command.clone()), OpsActionEntry::Submenu { submenu, .. } => Self::Submenu(*submenu), } @@ -350,7 +364,17 @@ impl OperationsWorkspace { > 1; let mut leverage_added = false; let mut recruitment_added = false; - let mut entries = Vec::new(); + let mut entries = object + .consequence + .as_ref() + .and_then(|consequence| consequence.action.as_ref()) + .map(|action| OpsActionEntry::Open { + label: action.label.clone(), + description: "OPEN THE EXACT CHOICES THIS INFORMATION ENABLES".into(), + target: action.target.clone(), + }) + .into_iter() + .collect::>(); for row in rows { match action_submenu_for_row(&row) { Some(OpsActionSubmenu::Leverage) if has_leverage_choices => { @@ -377,7 +401,16 @@ impl OperationsWorkspace { pub fn related_links(&self, sim: &Sim) -> Vec { self.selected_object(sim) - .map(|object| object.related) + .map(|mut object| { + let consequence_action = object + .consequence + .as_ref() + .and_then(|consequence| consequence.action.as_ref()); + object + .related + .retain(|link| consequence_action != Some(link)); + object.related + }) .unwrap_or_default() } @@ -826,6 +859,7 @@ impl OperationsWorkspace { .map(|link| OpsSelect::Open(link.target)) .unwrap_or(OpsSelect::None), OpsPane::Actions => match self.selected_action_entry(sim) { + Some(OpsActionEntry::Open { target, .. }) => OpsSelect::Open(target), Some(entry) if entry.submenu().is_some() => { self.action_submenu = entry.submenu(); self.action = 0; @@ -1010,7 +1044,7 @@ mod tests { use crate::actions::{ActionCost, ActionDesc, ActionRole}; use crate::intel::{IntelKind, IntelMagnitude, IntelPolicyMatch, ProcessedIntel}; use crate::operations_projection::ObjectState; - use crate::person::Leverage; + use crate::person::{Knowledge, Leverage}; fn intel(raw_id: u64, processed_tick: u64, kind: IntelKind) -> ProcessedIntel { ProcessedIntel { @@ -1098,6 +1132,58 @@ mod tests { assert_eq!(row.command, remove_first); } + #[test] + fn consequence_route_is_the_first_keyboard_action_not_a_separate_pane() { + let mut sim = Sim::new(); + sim.people.people[0].knowledge = Knowledge::Leverage; + sim.set_persona("Sam", "contractor"); + sim.accounts.set_slush_balance(1_000); + sim.player.money = 1_000; + sim.intel.push(ProcessedIntel { + raw_id: 400, + tick: 5, + processed_tick: 6, + feed: "creditor call".into(), + room: None, + x: 0, + y: 0, + person: Some(0), + magnitude: IntelMagnitude::new(5).unwrap(), + kind: IntelKind::Leverage(Leverage::Debt), + }); + + let target = OperationsTarget::Intel { raw_id: 400 }; + let mut ops = OperationsWorkspace::open_target(&sim, &target); + assert_eq!(ops.select(&sim), OpsSelect::None); + assert_eq!(ops.pane, OpsPane::Actions); + let entries = ops.action_entries(&sim); + assert!(matches!( + entries.first(), + Some(OpsActionEntry::Open { + label, + target: OperationsTarget::IntelOpportunity { raw_id: 400 }, + .. + }) if label == "APPROACH MARCUS" + )); + let sale = entries + .iter() + .position(|entry| { + entry.row().is_some_and(|row| { + matches!(row.command, ActionCommand::SellIntel { raw_id: 400 }) + }) + }) + .expect("the exact sale remains in the same decision list"); + assert_eq!(sale, 1, "APPROACH is immediately above the sale row"); + + ops.select_action_index(&sim, sale); + ops.move_up(&sim); + assert_eq!(ops.selected_action_index(&sim), Some(0)); + assert_eq!( + ops.select(&sim), + OpsSelect::Open(OperationsTarget::IntelOpportunity { raw_id: 400 }) + ); + } + #[test] fn vanished_action_returns_to_the_object_before_enter_can_retarget() { let mut sim = Sim::new(); diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 62eff41a..42bf613f 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -2402,6 +2402,9 @@ impl UI { for (i, entry) in entries.iter().enumerate() { let marker = if i == action_index { "▸" } else { " " }; let text = match entry { + OpsActionEntry::Open { label, .. } => { + format!("{marker} {label} >") + } OpsActionEntry::Submenu { label, .. } => { format!("{marker} {label} >") } @@ -2427,7 +2430,12 @@ impl UI { } } let Some(row) = entry.row() else { - dline(stdout, y, "Enter to compare choices", pal::DIM, None)?; + let hint = if entry.submenu().is_some() { + "Enter to compare choices" + } else { + "Enter to open exact choices" + }; + dline(stdout, y, hint, pal::DIM, None)?; return Ok(()); }; let sig = row @@ -2485,26 +2493,6 @@ impl UI { for line in wrap(&consequence.statement, dw) { dline(stdout, &mut y, &line, pal::TEXT, None)?; } - if let Some(action) = &consequence.action { - let index = obj - .related - .iter() - .position(|link| link == action) - .unwrap_or(0); - let marker = if index == related_index { "▸" } else { " " }; - let line = format!("{marker} {}", action.label); - if index == related_index && ops.pane == OpsPane::Related { - dline( - stdout, - &mut y, - &line, - pal::AMBER_DIM, - Some(Attribute::Reverse), - )?; - } else { - dline(stdout, &mut y, &line, pal::AMBER_DIM, None)?; - } - } } render_actions(stdout, &mut y)?; dline(stdout, &mut y, "", pal::FAINT, None)?; @@ -2520,8 +2508,8 @@ impl UI { let supporting_links = obj .related .iter() + .filter(|link| consequence_link != Some(*link)) .enumerate() - .filter(|(_, link)| consequence_link != Some(*link)) .collect::>(); if !supporting_links.is_empty() { dline(stdout, &mut y, "RELATED", pal::DIM, None)?; diff --git a/wiki/interface/liturgical-ui-constitution.md b/wiki/interface/liturgical-ui-constitution.md index bb764486..f674bb1e 100644 --- a/wiki/interface/liturgical-ui-constitution.md +++ b/wiki/interface/liturgical-ui-constitution.md @@ -183,6 +183,13 @@ are implementation vocabulary; player-facing labels stay ordinary (`CHOOSE`, `CURRENT`, `WHAT DOES THIS CHANGE?`, `WHERE THIS HAPPENS`, `ACTIONS`, `WHAT HAPPENS NEXT`, `CONTEXT`). +The contextual verbs form one vertical decision choir. A consequence-specific +route such as APPROACH MARCUS is its first ordinary selectable row, followed by +the object's command and control rows; all share one keyboard/pointer cursor. +`WHAT THIS OPENS` is not a sixth section and a consequence action is not a +supporting RELATED link. The center states what changed; the right side answers +what the player can do about it. + Operations is frontend-only navigation over the shared projection. Opening, navigating, and closing mutate no sim/save state, never rewrite the player's explicit pause state, and never advance a tick themselves. While the chamber diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 6859f8d6..aa4638b5 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -53,6 +53,12 @@ Status note: Reopened 2026-08-02 (adopted, not yet implemented): selected canonical agent language across the pooled host, exact opaque information, custody policies, sink receipts, and accounting records; legacy `review` spellings remain input-only aliases. + Amended 2026-08-02: a consequence-specific route such as APPROACH MARCUS + is the first entry in the same renderer-neutral action list as SELL and + standing policy controls. It is no longer rendered as a separate WHAT THIS + 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-07-21: ACCOUNTS teaches ordinary TAP on the known accounting device, then waits honestly for later-authored record mail, then offers PROCESS on captured books. The retired second-step `TAP LEDGER` label is not @@ -417,9 +423,12 @@ a toast, modal pop-up, or scale-triggered notification. For the Marcus call the first useful read is “Marcus owes $400,” followed by “You can approach Marcus through his debt” and **APPROACH MARCUS**. That control opens the exact debt-enabled choices on Marcus; it does not merely navigate to an unfiltered -dossier. Provenance and source history follow. When processing changes only a -knowledge model or relationship and creates no action, the result says that -plainly and exposes no invented button. +dossier. It is the first row of the same action cursor as SELL and any standing +policy control, so pointer hover and j/k-or-arrow navigation cannot disagree +about whether it is actionable. There is no separate WHAT THIS OPENS section +or RELATED-pane detour for a consequence action. Provenance and source history +follow. When processing changes only a knowledge model or relationship and +creates no action, the result says that plainly and exposes no invented button. ### The same object at every scale @@ -739,6 +748,11 @@ explanatory surface its strategic systems lacked. gain, signature/observer band (or `no signature`), channel/actuator, and disabled reason. A disabled row is not merely gray. Attempting it also writes the same reason to the trace. +- A consequence route that opens exact choices is an action-list entry, not a + supporting relationship. It leads the same vertical cursor as commands and + controls, carries its exact semantic target, and opens directly on Enter or + click. Supporting RELATED links retain their separate pane because they are + context, not answers to “what do you want to do?” - Confirmation follows consequence, not the fact that a row was selected. Routine internal unsigned work such as PROCESS starts immediately after its visible explanation; forcing a second Enter adds no decision. @@ -969,10 +983,12 @@ state is not saved and never mutates or advances the sim. frontends do not create a toast/modal for completion or for a scale change. The Marcus fixture renders MARCUS OWES $400, WHAT DOES THIS CHANGE?, YOU CAN APPROACH MARCUS THROUGH HIS DEBT, and an APPROACH MARCUS control before - provenance; that control opens the debt-enabled choices. A result with no - consequence-specific route names the changed knowledge or relationship - without inventing a button, while any independently canonical object action - remains available directly beneath the consequence. + provenance; that control is the first member of the same action cursor as + sale and policy controls, and opens the debt-enabled choices. Up from the + sale row reaches APPROACH; pointer hover selects that same entry. A result + with no consequence-specific route names the changed knowledge or + relationship without inventing a button, while any independently canonical + object action remains available directly beneath the consequence. 22. Human Operations labels, learned results, consequences, provenance, facts, progress, related links, and action verbs expose no internal object/rule/ persona/expectation/grant/correlation/position ids, record keys, report-lot @@ -996,6 +1012,13 @@ state is not saved and never mutates or advances the sim. live content; scrolling it cannot zoom either world camera or move the hidden resting sidebar. +24. A projected consequence action is represented once as the first shared + `OpsActionEntry`, ahead of the selected object's command/control rows. Its + exact `OperationsTarget` is the cursor identity and Enter/click returns the + ordinary `Open` transition. It is excluded from supporting RELATED links, + never receives its own WHAT THIS OPENS section, and participates in the + same j/k-or-arrow sequence as every row below it in terminal and Bevy. + Defense: `operations_ui::tests::repeated_plot_and_recruitment_variants_fold_into_intent_submenus`, `operations_ui::tests::one_recruitment_choice_stays_a_direct_exact_action`, and `operations_ui::tests::back_walks_confirmation_submenu_detail_objects_then_closes` @@ -1006,6 +1029,12 @@ pin the workspace's visible, non-persisted time and camera boundary; `operations_ui::operations_workspace_tests::action_scroll_uses_wheel_direction_and_never_leaves_its_content` pins the action column's wheel direction and bounds. +Defense: `operations_ui::tests::consequence_route_is_the_first_keyboard_action_not_a_separate_pane` +pins APPROACH MARCUS immediately above the exact sale command and proves Up +then Enter opens its evidence-scoped target. Bevy's shared-row regression pins +the same first entry before dispatching the unchanged sale command; terminal +and Bevy render that `OpsActionEntry` through their ordinary action-row path. + Defense: `operations_projection::tests::processed_marcus_debt_projects_fact_consequence_and_exact_approach` pins the shared result, consequence, and evidence-scoped APPROACH target; `operations_projection::tests::schemes_keep_outside_access_on_switch_and_explain_the_blocker` diff --git a/wiki/log/2026-08-02-consequence-actions-one-list.md b/wiki/log/2026-08-02-consequence-actions-one-list.md new file mode 100644 index 00000000..873873ce --- /dev/null +++ b/wiki/log/2026-08-02-consequence-actions-one-list.md @@ -0,0 +1,47 @@ +# 2026-08-02 — Put consequence routes in the action list + +``` +Type: log +``` + +## Finding + +Cameron's screenshot exposed a semantic split hidden by the visual layout. +APPROACH MARCUS was rendered in the right-hand action column under WHAT THIS +OPENS, but the shared workspace still classified it as a RELATED link. Mouse +hover could select that link; Up/Down stayed in the separate action pane and +could move only between SELL and the auto-sale control. The first RELATED +cursor entry was therefore also invisible whenever the consequence link was +filtered out of the center pane. + +## Change + +- The renderer-neutral action hierarchy now has an `Open` entry carrying the + exact consequence target. +- A consequence route leads the same action list as the selected object's + command and control rows. APPROACH MARCUS sits immediately above SELL, so Up + and Enter reach it through the ordinary action cursor. +- Consequence routes no longer remain in the workspace's supporting RELATED + list. Terminal and Bevy both render the shared `Open` entry as an ordinary + selectable row with a `>` continuation mark. +- Bevy removes the duplicate WHAT THIS OPENS section. The right column now + answers one question with one selection grammar. + +## Verification + +The focused core regression constructs Marcus's processed debt, proves +APPROACH is row zero and the sale is row one, then moves Up from sale and opens +the exact `IntelOpportunity` target. The Bevy Operations tests pin the same +shared ordering before dispatching the unchanged exact sale command; all +focused core, Bevy, and terminal Operations tests pass. The deterministic +`MISALIGNED_SHOT=operations-intel` frame was inspected with SELL selected: it +shows APPROACH MARCUS directly above SELL and auto-sale under the single WHAT +DO YOU WANT TO DO? heading, with no WHAT THIS OPENS split. + +## Defense + +`wiki/interface/operations-workspace.md` requires the processed result to move +from learned fact to changed consequence to exact action without forcing the +player to rediscover a different interface grammar. The Liturgical UI +Constitution now makes that result one decision choir: the center explains the +change, and the right column contains every available response in one cursor. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 89fcb924..35e892b6 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -26,6 +26,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-02-knowledge-use-receipts.md](2026-08-02-knowledge-use-receipts.md) +## 2026-08-02 - Put consequence routes in the action list + +- Intent: (see session log) +- Log: [wiki/log/2026-08-02-consequence-actions-one-list.md](2026-08-02-consequence-actions-one-list.md) + ## 2026-08-02 - Keep Bevy text linearly sampled - Intent: (see session log) diff --git a/wiki/log/decisions/2026-08-02.md b/wiki/log/decisions/2026-08-02.md index d6c901ae..b67691a9 100644 --- a/wiki/log/decisions/2026-08-02.md +++ b/wiki/log/decisions/2026-08-02.md @@ -113,3 +113,39 @@ Owners: [plots.md](../../mechanics/plots.md#player-surface), [personas.md](../../mechanics/personas.md), [action-vocabulary.md](../../interface/action-vocabulary.md), and [liturgical-ui-constitution.md](../../interface/liturgical-ui-constitution.md#control-canon). + +## Rising read beats have a human reading opportunity + +### DECIDED + +- Newly processed intel and a routed record's terminal read/stop outcome get + one speed-aware three-second reading opportunity in wall-clock human + frontends. Command-clocked reads remain exact-current. +- Routed custody glyphs keep moving on exact persisted hops, while their cause + sentence stays grouped at the emitter instead of jumping every tick. +- This supersedes the temporal-hierarchy OPEN above. Read dwell is short world + attention; NOTICES remains durable event history, and neither duplicates + saved intel/evidence custody. + +Owner: [digital-read.md](../../interface/digital-read.md). + +## Consequence actions share one cursor + +### DECIDED + +- A consequence-specific route such as APPROACH MARCUS is an action-list + entry, not supporting RELATED context. It leads the same list as SELL and + standing policy controls. +- Pointer and keyboard operate the same row identity. Up from SELL reaches + APPROACH; Enter/click opens the exact evidence-scoped choices. +- The separate WHAT THIS OPENS section is removed. WHAT DOES THIS CHANGE? + remains the explanation; the one action choir contains every response. + +### REJECTED + +- **Keep the visual split and add a special Up handoff.** That would preserve + two semantic cursors while teaching one apparent list, leaving keyboard and + pointer behavior fragile whenever another consequence route appears. + +Owner: [operations-workspace.md](../../interface/operations-workspace.md), +constrained by [liturgical-ui-constitution.md](../../interface/liturgical-ui-constitution.md).