diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index dea018fa..6ef79588 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -1001,6 +1001,20 @@ pub struct PredictedDelta { pub max: i32, } +impl PredictedDelta { + /// One disclosed delta as the shared receipt clause ("DISPOSITION +10 TO + /// +20"; min == max collapses to the single number, "OBLIGATION +35"). + /// Formatted once so terminal and Bevy print identical numbers. + pub fn clause(&self) -> String { + let axis = self.axis.name().to_ascii_uppercase(); + if self.min == self.max { + format!("{axis} {:+}", self.min) + } else { + format!("{axis} {:+} TO {:+}", self.min, self.max) + } + } +} + /// One planned account movement a plot's transfer acts will perform, named by /// its exact accounts. Naming is not reservation: the forecast describes the /// planned world acts; money moves only when the authored beat executes and @@ -4955,6 +4969,32 @@ mod tests { assert!(auto.cost.contains("ops each second")); } + /// The one shared delta clause (2026-08-02 receipt disclosure): a range + /// when the success endings disagree, the single number when they agree, + /// signed the same way everywhere so terminal and Bevy print identical + /// numbers. + #[test] + fn predicted_delta_clause_formats_ranges_and_single_numbers_once() { + let range = PredictedDelta { + axis: RelationshipAxis::Disposition, + min: 10, + max: 20, + }; + assert_eq!(range.clause(), "DISPOSITION +10 TO +20"); + let single = PredictedDelta { + axis: RelationshipAxis::Obligation, + min: 35, + max: 35, + }; + assert_eq!(single.clause(), "OBLIGATION +35"); + let negative = PredictedDelta { + axis: RelationshipAxis::Disposition, + min: -5, + max: -5, + }; + assert_eq!(negative.clause(), "DISPOSITION -5"); + } + #[test] fn human_auto_review_price_tracks_the_live_command_clock() { let s = sim(); diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index a4ca6487..4baeda09 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -7,7 +7,9 @@ use std::collections::BTreeSet; use std::io::{self, BufRead, Write}; -use misaligned::actions::{ActionCommand, ActionKind, ActionRole, Anchor, MenuRow, menu_rows}; +use misaligned::actions::{ + ActionCommand, ActionDesc, ActionKind, ActionRole, Anchor, MenuRow, menu_rows, +}; use misaligned::detection::Band; use misaligned::hall::RackSite; use misaligned::intel::{IntelRoutineClass, ReportLotToken}; @@ -767,8 +769,8 @@ impl AgentApp { // `flow `, `scheme `, `run `) use the // Operations projection. match self.resolve_query(&tokens[1..]) { - Ok(query) => match self.query_rows(&query) { - Ok(rows) => output.extend(action_row_lines(&rows)), + Ok(query) => match self.query_descs(&query) { + Ok(descs) => output.extend(action_desc_lines(&descs)), Err(e) => status = Status::Err(e), }, Err(e) => status = Status::Err(e), @@ -1034,9 +1036,9 @@ impl AgentApp { .map(|e| (e.anchor, e.target.clone())) .ok_or_else(|| "no anchored events yet".to_string())?; if let Some(target) = target { - let rows = self.query_rows(&QueryTarget::Strategic(target.clone()))?; + let descs = self.query_descs(&QueryTarget::Strategic(target.clone()))?; self.frame = FrameKind::OperationsTarget(target); - return Ok(action_row_lines(&rows)); + return Ok(action_desc_lines(&descs)); } let anchor = anchor.expect("linked event carries an anchor or target"); if let Anchor::Flow(_) = anchor { @@ -1290,25 +1292,31 @@ impl AgentApp { Ok(target) } - /// One target family's bound rows: spatial anchors use the shared - /// ui_projection; strategic targets use the Operations projection. Both - /// answer with the exact rows execution dispatches — no agent-only - /// legality (agent-play.md A2). - fn query_rows(&self, query: &QueryTarget) -> Result, String> { + /// One target family's bound descriptors: spatial anchors use the shared + /// ui_projection; strategic targets use the Operations projection. + fn query_descs(&self, query: &QueryTarget) -> Result, String> { match query { - QueryTarget::Spatial(anchor) => { - Ok(menu_rows(&self.sim.ui_projection(*anchor, None, 0).actions)) - } + QueryTarget::Spatial(anchor) => Ok(self.sim.ui_projection(*anchor, None, 0).actions), QueryTarget::Strategic(target) => { let object = self .sim .operations_object(target) .ok_or_else(|| format!("no known object {}", target_query_id(target)))?; - Ok(menu_rows(&object.actions)) + Ok(object.actions) } } } + /// One target family's bound rows, after the terminal plot fan-out so + /// `actions` display and `act ` dispatch share one numbering. Both + /// answer with the exact rows execution dispatches — no agent-only + /// legality (agent-play.md A2). + fn query_rows(&self, query: &QueryTarget) -> Result, String> { + Ok(menu_rows(&crate::operations::fan_out_personas( + &self.query_descs(query)?, + ))) + } + /// Execute the same enabled bound row a named protocol command denotes. /// This keeps convenience spellings from gaining agent-only target or /// legality rules (agent-play.md A2/A5). @@ -1462,10 +1470,10 @@ impl AgentApp { } /// The stable line format (agent-play.md): one `actions:` line per menu - /// row (see [`action_row_lines`]). + /// row (see [`action_desc_lines`]). fn actions_lines(&self, anchor: Anchor) -> Vec { let projection = self.sim.ui_projection(anchor, None, 0); - action_row_lines(&menu_rows(&projection.actions)) + action_desc_lines(&projection.actions) } /// Enrich a bad `siphon`/`redirect` argument with the nearest valid @@ -1833,47 +1841,91 @@ fn target_query_id(target: &OperationsTarget) -> String { /// The stable `actions:` line format (agent-play.md): one line per menu /// row, `. verb | cost | sig | reason`, automate children marked with a -/// leading `-`. Fields are `|`-separated so a driver can split. -fn action_row_lines(rows: &[MenuRow]) -> Vec { - if rows.is_empty() { +/// leading `-`. Fields are `|`-separated so a driver can split. Plot rows +/// fan out one row per persona choice (crate::operations::fan_out_personas) +/// and append unnumbered disclosure lines — the shared money sentence, the +/// predicted success deltas, the unlock fact, and the exact persona the row +/// fronts — so an agent can read the receipt and script the choice. +fn action_desc_lines(actions: &[ActionDesc]) -> Vec { + let fanned = crate::operations::fan_out_personas(actions); + if fanned.is_empty() { return vec!["actions: (none earned on this focus)".into()]; } - let mut out = Vec::with_capacity(rows.len()); - for (i, r) in rows.iter().enumerate() { - let mut line = format!("actions: {}. ", i + 1); - if r.indent { - line.push_str("- "); - } - let label = match &r.command { - ActionCommand::StartPlot { plot_id, .. } => format!( - "plot [{plot_id}]: {}", - r.label.strip_prefix("plot: ").unwrap_or(&r.label) - ), - ActionCommand::ChoosePlot { - person, option_id, .. - } => format!("{} · choose person #{person} {option_id}", r.label), - _ => r.label.clone(), - }; - line.push_str(&format!("{label} | {}", r.cost)); - // One core authority decides the clause: real label, honest - // "no signature", or no slot at all on plot rows (2026-08-02). - if let Some(sig) = r.signature_clause() { - line.push_str(&format!(" | {sig}")); - } - if r.active { - line.push_str(" | active"); - } - if r.role == ActionRole::Control { - line.push_str(" | CONTROL"); - } - if let Some(reason) = &r.disabled { - line.push_str(&format!(" | DISABLED: {reason}")); + let mut out = Vec::new(); + let mut index = 0usize; + for desc in &fanned { + for r in menu_rows(std::slice::from_ref(desc)) { + index += 1; + let mut line = format!("actions: {index}. "); + if r.indent { + line.push_str("- "); + } + let label = match &r.command { + ActionCommand::StartPlot { plot_id, .. } => format!( + "plot [{plot_id}]: {}", + r.label.strip_prefix("plot: ").unwrap_or(&r.label) + ), + ActionCommand::ChoosePlot { + person, option_id, .. + } => format!("{} · choose person #{person} {option_id}", r.label), + _ => r.label.clone(), + }; + line.push_str(&format!("{label} | {}", r.cost)); + // One core authority decides the clause: real label, honest + // "no signature", or no slot at all on plot rows (2026-08-02). + if let Some(sig) = r.signature_clause() { + line.push_str(&format!(" | {sig}")); + } + if r.active { + line.push_str(" | active"); + } + if r.role == ActionRole::Control { + line.push_str(" | CONTROL"); + } + if let Some(reason) = &r.disabled { + line.push_str(&format!(" | DISABLED: {reason}")); + } + out.push(line); + if !r.indent { + for detail in plot_disclosure_lines(desc) { + out.push(format!("actions: {detail}")); + } + } } - out.push(line); } out } +/// The unnumbered disclosure block under one agent-mode plot row: forecast +/// lines plus the persona binding this exact row fronts (id, name, and its +/// recognized/introduction flag), or the empty-but-required pointer. +fn plot_disclosure_lines(desc: &ActionDesc) -> Vec { + let mut lines = crate::operations::forecast_lines(desc); + if let Some(binding) = &desc.persona_binding { + if crate::operations::needs_persona(desc) { + lines.push(format!( + "persona required: none eligible ({})", + crate::operations::CREATE_PERSONA_POINTER + )); + } else if let ActionCommand::StartPlot { persona, .. } = &desc.command + && let Some(choice) = persona + .and_then(|id| binding.choices.iter().find(|choice| choice.id == id)) + .or_else(|| binding.choices.first()) + { + let mark = if choice.recognized { + "recognized" + } else { + "introduction" + }; + lines.push(format!( + "fronts persona {} {} ({mark})", + choice.id, choice.name + )); + } + } + lines +} + fn write_block( out: &mut impl Write, sim: &Sim, @@ -3326,7 +3378,7 @@ fn append_operations_object( sim: &Sim, object: &misaligned::operations_projection::OperationsObject, ) { - let action_lines = action_row_lines(&menu_rows(&object.actions)); + let action_lines = action_desc_lines(&object.actions); let consequence_link = object .consequence .as_ref() @@ -4965,7 +5017,7 @@ mod narration_tests { } let rows = menu_rows(&app.sim.available_actions(Anchor::Person(0))); - let agent_rows = action_row_lines(&rows); + let agent_rows = action_desc_lines(&app.sim.available_actions(Anchor::Person(0))); assert!( agent_rows .iter() @@ -5024,6 +5076,141 @@ mod narration_tests { assert!(app.sim.people.get(0).unwrap().leverage_serviced); } + /// Agent parity for the plot receipt (2026-08-02): `actions` prints the + /// shared money sentence, the predicted success deltas as numbers, the + /// unlock fact, and the exact persona each row fronts — enough for an + /// agent to read the receipt and script the identity choice. + #[test] + fn agent_actions_disclose_plot_forecast_and_persona_binding() { + let mut app = AgentApp::new(5); + app.sim.people.people[0].knowledge = misaligned::person::Knowledge::Leverage; + app.sim.people.has_channel = true; + app.sim.set_persona("Sam Reyes", "IT contractor"); + let sam = app.sim.newest_persona_id().unwrap(); + app.sim.persona_world.recognize(0, sam, app.sim.tick); + app.sim.set_persona("Glass Harbor", "research partner"); + let glass = app.sim.newest_persona_id().unwrap(); + app.sim.accounts.set_slush_balance(1000); + + let lines = app.actions_lines(Anchor::Person(0)); + let joined = lines.join("\n"); + assert!( + joined.contains( + "actions: moves $400 from your slush account to Marcus Webb's creditor" + ), + "{joined}" + ); + assert!( + joined.contains("actions: SUCCESS: DISPOSITION +10 TO +20"), + "{joined}" + ); + assert!( + joined.contains("actions: OBLIGATION +35 TO +40"), + "{joined}" + ); + assert!( + joined.contains("actions: SUCCESS UNLOCKS RECRUIT"), + "{joined}" + ); + assert!( + joined.contains(&format!( + "actions: fronts persona {sam} Sam Reyes (recognized)" + )), + "{joined}" + ); + assert!( + joined.contains(&format!( + "actions: fronts persona {glass} Glass Harbor (introduction)" + )), + "{joined}" + ); + // The fanned rows keep the established suffix and stay numbered so + // `act ` addresses each identity. + assert!( + lines + .iter() + .any(|line| line.contains("plot [marcus-debt-settled]:") + && line.contains(" as Sam Reyes |")), + "{joined}" + ); + assert!( + lines.iter().any(|line| { + line.contains("plot [marcus-debt-settled]:") + && line.contains(" as Glass Harbor (they know you as Sam Reyes)") + }), + "{joined}" + ); + assert!( + lines + .iter() + .filter(|line| line.contains("plot [")) + .all(|line| !line.contains("no signature")), + "plot rows carry no signature clause: {joined}" + ); + } + + /// `act ` on a fanned plot row dispatches StartPlot bound to that + /// row's exact persona; the executor revalidates the identity at commit. + #[test] + fn act_on_a_fanned_plot_row_dispatches_the_picked_persona() { + let mut app = AgentApp::new(5); + app.sim.people.people[0].knowledge = misaligned::person::Knowledge::Leverage; + app.sim.people.has_channel = true; + app.sim.set_persona("Sam Reyes", "IT contractor"); + let sam = app.sim.newest_persona_id().unwrap(); + app.sim.persona_world.recognize(0, sam, app.sim.tick); + app.sim.set_persona("Glass Harbor", "research partner"); + let glass = app.sim.newest_persona_id().unwrap(); + app.sim.accounts.set_slush_balance(1000); + + let rows = app + .query_rows(&QueryTarget::Spatial(Anchor::Person(0))) + .unwrap(); + let row = rows + .iter() + .position(|row| { + matches!(&row.command, + ActionCommand::StartPlot { plot_id, persona: Some(id), .. } + if plot_id == "marcus-debt-settled" && *id == glass) + }) + .expect("the fanned introduction row is addressable") + + 1; + let mut output = Vec::new(); + app.handle_line(&format!("act {row} Marcus"), &mut output) + .unwrap(); + assert!(!String::from_utf8(output).unwrap().contains("-- err")); + assert!( + app.sim.thought_sinks.open_sinks().any(|sink| matches!( + sink.effect, + misaligned::sinks::SinkFireEffect::StartPlot { + person: 0, + persona_id: Some(picked), + .. + } if picked == glass + )), + "the expanded row commits its own identity" + ); + } + + /// With no eligible identity the agent row keeps the unified core reason + /// and prints the empty-but-required pointer at PERSONAS. + #[test] + fn agent_actions_state_the_empty_required_persona_pointer() { + let mut app = AgentApp::new(5); + app.sim.people.people[0].knowledge = misaligned::person::Knowledge::Leverage; + app.sim.people.has_channel = true; + app.sim.accounts.set_slush_balance(1000); + let joined = app.actions_lines(Anchor::Person(0)).join("\n"); + assert!( + joined.contains("DISABLED: select or create a persona to front this"), + "{joined}" + ); + assert!( + joined.contains("actions: persona required: none eligible (create one in PERSONAS)"), + "{joined}" + ); + } + #[test] fn queued_processing_nudge_teaches_the_think_control() { let mut sim = Sim::with_seed(1); diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 9273048f..be428ed5 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -28,7 +28,7 @@ use misaligned::ui_projection::next_earned_attention_anchor; use misaligned::work_grid::{MachineIntensity, MachineMode}; use input::Command; -use operations::{OperationsWorkspace, OpsSelect}; +use operations::{OperationsWorkspace, OpsPane, OpsSelect}; use ui::{ClockState, UI}; #[derive(Debug, Clone, PartialEq)] @@ -102,6 +102,11 @@ struct App { ops: Option, /// Selection retained while FOCUS temporarily returns to the world. ops_resume: Option, + /// Which fanned persona sibling of the selected workspace action row the + /// cursor rests on (operations::fanned_labels). Terminal attention state + /// only: a plot row with several eligible identities renders one row per + /// choice, and Enter dispatches StartPlot with the picked identity. + ops_persona_pick: usize, /// Frontend-only attention cursor (cursor.md). It is never saved and never /// mutates the sim when moved. cursor_x: i32, @@ -144,6 +149,7 @@ impl App { command_receipt: None, ops: None, ops_resume: None, + ops_persona_pick: 0, cursor_x, cursor_y, focus_cycle: 0, @@ -739,21 +745,25 @@ impl App { .unwrap_or_else(OperationsWorkspace::open); ops.clamp_to(&self.sim); self.ops = Some(ops); + self.ops_persona_pick = 0; } Command::OpsPrevView => { if let Some(ops) = &mut self.ops { ops.prev_view(); + self.ops_persona_pick = 0; } } Command::OpsNextView => { if let Some(ops) = &mut self.ops { ops.next_view(); + self.ops_persona_pick = 0; } } Command::OpsNextPane => { let sim = &self.sim; if let Some(ops) = &mut self.ops { ops.next_pane(sim); + self.ops_persona_pick = 0; } } Command::OpsEdit => { @@ -775,22 +785,70 @@ impl App { Command::OpsUp => { let sim = &self.sim; if let Some(ops) = &mut self.ops { - ops.move_up(sim); + // A fanned plot row walks its persona siblings before the + // shared workspace cursor leaves the entry; entering the + // entry from below lands on its last sibling so the fan + // reads as ordinary contiguous rows. + let fanned = ops.pane == OpsPane::Actions && ops.confirm.is_none(); + if fanned && self.ops_persona_pick > 0 { + self.ops_persona_pick -= 1; + } else { + let before = ops.selected_action_index(sim); + ops.move_up(sim); + self.ops_persona_pick = if fanned + && ops.pane == OpsPane::Actions + && ops.selected_action_index(sim) != before + { + operations::fan_len(sim, ops).saturating_sub(1) + } else { + 0 + }; + } } } Command::OpsDown => { let sim = &self.sim; if let Some(ops) = &mut self.ops { - ops.move_down(sim); + let fanned = ops.pane == OpsPane::Actions && ops.confirm.is_none(); + if fanned && self.ops_persona_pick + 1 < operations::fan_len(sim, ops) { + self.ops_persona_pick += 1; + } else { + let before = ops.selected_action_index(sim); + ops.move_down(sim); + // A clamped move at the list edge keeps the current + // sibling instead of snapping back to the default. + if !(fanned + && ops.pane == OpsPane::Actions + && ops.selected_action_index(sim) == before) + { + self.ops_persona_pick = 0; + } + } } } Command::OpsSelect => { + let before = self + .ops + .as_ref() + .map(|ops| (ops.pane, ops.action_submenu, ops.confirm.is_some())); let selection = self.ops.as_mut().map(|ops| ops.select(&self.sim)); match selection { Some(OpsSelect::Execute(row)) => { // Success and failure return to the same selected // object; the workspace never closes on a result. - self.sim.execute_action(&row.command); + // A fanned plot sibling dispatches StartPlot with its + // picked identity; the executor revalidates the exact + // persona at commit (persona-control contract). + let mut command = row.command.clone(); + if let ActionCommand::StartPlot { persona, .. } = &mut command + && let Some(ops) = &self.ops + && let Some(picked) = + operations::picked_persona(&self.sim, ops, self.ops_persona_pick) + { + *persona = Some(picked); + } + self.sim.execute_action(&command); + self.ops_persona_pick = 0; if let Some(ops) = &mut self.ops { ops.clamp_to(&self.sim); } @@ -805,8 +863,21 @@ impl App { if let Some(ops) = &mut self.ops { ops.follow_target(&self.sim, &target); } + self.ops_persona_pick = 0; + } + Some(OpsSelect::Cancelled) | Some(OpsSelect::None) | None => { + // Entering the actions pane or a submenu resets the + // sibling cursor; opening the confirm step keeps it so + // the second Enter commits the picked identity. + if let Some(ops) = &self.ops { + let after = (ops.pane, ops.action_submenu, ops.confirm.is_some()); + if let Some(before) = before + && (after.0 != before.0 || after.1 != before.1) + { + self.ops_persona_pick = 0; + } + } } - Some(OpsSelect::Cancelled) | Some(OpsSelect::None) | None => {} } } Command::OpsFocus => { @@ -867,8 +938,14 @@ impl App { // witness (day/tick, objective, threat, now:) visible. if let Some(ops) = &mut self.ops { let sim = &self.sim; - self.ui - .render_operations(stdout, sim, ops, self.paused, self.tick_ms)?; + self.ui.render_operations( + stdout, + sim, + ops, + self.ops_persona_pick, + self.paused, + self.tick_ms, + )?; stdout.flush()?; return Ok(()); } @@ -1188,8 +1265,11 @@ fn invalid_arg(msg: &str) -> io::Error { #[cfg(test)] mod options_tests { - use super::{App, Command, Options}; + use super::{App, Command, Options, operations}; + use misaligned::actions::ActionCommand; + use misaligned::operations_projection::OperationsTarget; use misaligned::origin::Origin; + use misaligned::person::Knowledge; use misaligned::ui_projection::{earned_attention_anchors, next_earned_attention_anchor}; use misaligned::work_grid::MachineMode; @@ -1321,6 +1401,77 @@ mod options_tests { assert!(app.clock_stopped()); } + /// Persona parity in the human workspace (2026-08-02): a plot row with + /// several eligible identities fans into one terminal row per choice; + /// j/k walk the siblings and Enter commits StartPlot with the picked + /// identity, not the bound default. + #[test] + fn ops_plot_fan_out_dispatches_the_picked_persona() { + let mut app = App::with_seed(7); + app.start_run(); + app.sim.opening_stage = misaligned::sim::OpeningStage::World; + app.sim.people.people[0].knowledge = Knowledge::Leverage; + app.sim.people.has_channel = true; + app.sim.set_persona("Sam", "contractor"); + let sam = app.sim.newest_persona_id().expect("Sam exists"); + app.sim.persona_world.recognize(0, sam, app.sim.tick); + app.sim.set_persona("Glass Harbor", "research partner"); + let glass = app.sim.newest_persona_id().expect("Glass Harbor exists"); + app.sim.accounts.set_slush_balance(1000); + app.sim.player.money = 1000; + + app.open_operations_at(&OperationsTarget::Person(0)); + let command = { + let ops = app.ops.as_ref().unwrap(); + ops.action_rows(&app.sim) + .into_iter() + .find(|row| { + matches!(&row.command, ActionCommand::StartPlot { plot_id, .. } + if plot_id == "marcus-debt-settled") + }) + .map(|row| row.command) + .expect("the dossier exposes the authored route") + }; + assert!( + matches!(&command, ActionCommand::StartPlot { persona: Some(id), .. } + if *id == sam) + ); + { + let sim = &app.sim; + let ops = app.ops.as_mut().unwrap(); + assert!(ops.focus_action_command(sim, &command)); + } + assert_eq!( + operations::fan_len(&app.sim, app.ops.as_ref().unwrap()), + 2, + "both identities render as rows" + ); + + // Walk onto the second sibling, then commit through CONFIRM. + app.handle_command(Command::OpsDown); + assert_eq!(app.ops_persona_pick, 1); + app.handle_command(Command::OpsSelect); // opens CONFIRM, keeps the pick + assert_eq!(app.ops_persona_pick, 1); + assert!(app.ops.as_ref().unwrap().confirm.is_some()); + app.handle_command(Command::OpsSelect); // commits + let log = app.sim.drain_log(); + assert!( + app.sim.thought_sinks.open_sinks().any(|sink| matches!( + sink.effect, + misaligned::sinks::SinkFireEffect::StartPlot { + person: 0, + persona_id: Some(picked), + .. + } if picked == glass + )), + "the fanned sibling dispatches its own identity; log={log:?}" + ); + assert_eq!( + app.ops_persona_pick, 0, + "execution resets the sibling cursor" + ); + } + #[test] fn semantic_jump_lands_on_shared_earned_truth_without_changing_selection() { let mut app = App::with_seed(41); diff --git a/crates/misaligned-terminal/src/operations.rs b/crates/misaligned-terminal/src/operations.rs index 4b0bc9e7..382a909c 100644 --- a/crates/misaligned-terminal/src/operations.rs +++ b/crates/misaligned-terminal/src/operations.rs @@ -1,13 +1,238 @@ //! Terminal drive of the shared Operations workspace state machine //! (wiki/interface/operations-workspace.md). The state machine itself is //! lib-owned so terminal and Bevy share one interaction grammar; this -//! module re-exports it and pins the terminal-side acceptance tests. +//! module re-exports it, owns the terminal's plot-receipt parity helpers +//! (forecast lines and the persona fan-out — terminal-first.md: every +//! number legible, every mechanic playable), and pins the terminal-side +//! acceptance tests. pub use misaligned::operations_ui::{ ConfirmChoice, OperationsWorkspace, OpsActionEntry, OpsLegend, OpsPane, OpsSelect, PersonaDraftControl, }; +use misaligned::actions::{ActionCommand, ActionDesc, MenuRow, PersonaBinding, PersonaChoice}; +use misaligned::persona::PersonaId; +use misaligned::sim::Sim; + +/// The explain pane's cost/signature clause. One core authority decides the +/// signature slot (MenuRow::signature_clause): a real label, the honest +/// "no signature", or — on a plot start/choice row — no clause and no +/// dangling separator at all (2026-08-02). +pub fn cost_receipt(row: &MenuRow) -> String { + match row.signature_clause() { + Some(sig) => format!("cost {} · {sig}", row.cost), + None => format!("cost {}", row.cost), + } +} + +/// The pointer line the explain text adds beneath the unified +/// empty-but-required persona reason: where the identity gets created. +/// Bevy renders creation rows inline; the terminal names the owning view. +pub const CREATE_PERSONA_POINTER: &str = "create one in PERSONAS"; + +/// The " as {name}" suffix core's message/favor fan-out prints, rebuilt from +/// the renderer-neutral binding so the terminal's plot fan-out matches it: a +/// recognized identity names itself, an unrecognized one is a marked +/// introduction naming the identity the counterparty already knows. +pub fn persona_suffix(binding: &PersonaBinding, choice: &PersonaChoice) -> String { + if choice.recognized { + return format!(" as {}", choice.name); + } + match binding.choices.iter().find(|known| known.recognized) { + Some(known) => format!(" as {} (they know you as {})", choice.name, known.name), + None => format!(" as {}", choice.name), + } +} + +/// Terminal plot fan-out (the message/favor precedent): the terminal has no +/// dropdown control, so a plot start row whose binding offers several +/// identities expands into one row per choice, each dispatching StartPlot +/// with that exact persona. A single choice keeps the one bound row with no +/// suffix noise. Legality stays core-owned: the executor revalidates the +/// exact identity at commit. +pub fn fan_out_personas(actions: &[ActionDesc]) -> Vec { + let mut out = Vec::with_capacity(actions.len()); + for desc in actions { + let ( + ActionCommand::StartPlot { + person, plot_id, .. + }, + Some(binding), + ) = (&desc.command, desc.persona_binding.as_ref()) + else { + out.push(desc.clone()); + continue; + }; + if binding.choices.len() < 2 { + out.push(desc.clone()); + continue; + } + let base = bound_choice(binding) + .map(|bound| persona_suffix(binding, bound)) + .and_then(|suffix| desc.verb.strip_suffix(suffix.as_str())) + .unwrap_or(&desc.verb) + .to_string(); + for choice in &binding.choices { + let mut fanned = desc.clone(); + fanned.verb = format!("{base}{}", persona_suffix(binding, choice)); + fanned.command = ActionCommand::StartPlot { + person: *person, + plot_id: plot_id.clone(), + persona: Some(choice.id), + }; + out.push(fanned); + } + } + out +} + +fn bound_choice(binding: &PersonaBinding) -> Option<&PersonaChoice> { + binding + .bound + .and_then(|bound| binding.choices.iter().find(|choice| choice.id == bound)) + .or_else(|| binding.choices.first()) +} + +/// The receipt's forecast lines, printed identically by the human explain +/// pane and agent mode: the shared core money sentence verbatim, one clause +/// per predicted success delta ("SUCCESS: DISPOSITION +10 TO +20" then +/// "OBLIGATION +35 TO +40"), and the derived unlock fact ("SUCCESS UNLOCKS +/// RECRUIT"). Account names print inline in the sentence — parity of +/// legibility, not of pointer affordance; ACCOUNTS remains the terminal +/// route to inspect them. +pub fn forecast_lines(desc: &ActionDesc) -> Vec { + let Some(forecast) = &desc.forecast else { + return Vec::new(); + }; + let mut lines = Vec::new(); + if let Some(sentence) = forecast.money_sentence() { + lines.push(sentence); + } + for (index, delta) in forecast.deltas.iter().enumerate() { + if index == 0 { + lines.push(format!("SUCCESS: {}", delta.clause())); + } else { + lines.push(delta.clause()); + } + } + if let Some(unlocks) = &forecast.unlocks { + lines.push(unlocks.to_ascii_uppercase()); + } + lines +} + +/// True when the row's binding is empty-but-required: the player must select +/// or create a persona before this act can front anyone. The core reason +/// string stays authoritative; the terminal only adds its pointer line. +pub fn needs_persona(desc: &ActionDesc) -> bool { + desc.persona_binding + .as_ref() + .is_some_and(|binding| binding.required && binding.choices.is_empty()) +} + +/// The projection descriptor (with forecast and binding) behind one exact +/// bound row. Plot commands match on person and plot id so a fanned row with +/// a swapped persona still finds its source disclosure. +pub fn desc_for_command<'a>( + actions: &'a [ActionDesc], + command: &ActionCommand, +) -> Option<&'a ActionDesc> { + actions.iter().find(|desc| match (&desc.command, command) { + ( + ActionCommand::StartPlot { + person: own, + plot_id: own_plot, + .. + }, + ActionCommand::StartPlot { + person: other, + plot_id: other_plot, + .. + }, + ) => own == other && own_plot == other_plot, + (own, other) => own == other, + }) +} + +/// Sibling labels for one fanned workspace entry: a plot start entry whose +/// binding offers several identities renders one visible row per choice. +/// `None` for every entry that does not fan out. Uppercase submenu labels +/// keep the suffix in their own case style. +pub fn fanned_labels(entry: &OpsActionEntry, actions: &[ActionDesc]) -> Option> { + let row = entry.row()?; + if !matches!(row.command, ActionCommand::StartPlot { .. }) { + return None; + } + let desc = desc_for_command(actions, &row.command)?; + let binding = desc.persona_binding.as_ref()?; + if binding.choices.len() < 2 { + return None; + } + let label = entry.label(); + let uppercase = label == label.to_uppercase(); + let base = strip_bound_suffix(label, binding); + Some( + binding + .choices + .iter() + .map(|choice| { + let suffix = persona_suffix(binding, choice); + if uppercase { + format!("{base}{}", suffix.to_uppercase()) + } else { + format!("{base}{suffix}") + } + }) + .collect(), + ) +} + +/// Remove the bound identity's suffix from one shared label or description, +/// in either case style, so a fanned sibling row never repeats the default +/// identity's name while a different one is picked. +pub fn strip_bound_suffix(text: &str, binding: &PersonaBinding) -> String { + let Some(bound) = bound_choice(binding) else { + return text.to_string(); + }; + let suffix = persona_suffix(binding, bound); + text.strip_suffix(suffix.as_str()) + .or_else(|| text.strip_suffix(suffix.to_uppercase().as_str())) + .unwrap_or(text) + .to_string() +} + +/// The binding behind the currently selected workspace action, when it is a +/// plot row that fans out in the terminal. +fn selected_fan_binding(sim: &Sim, ops: &OperationsWorkspace) -> Option { + let entry = ops.selected_action_entry(sim)?; + let row = entry.row()?; + if !matches!(row.command, ActionCommand::StartPlot { .. }) { + return None; + } + let object = ops.selected_object(sim)?; + let binding = desc_for_command(&object.actions, &row.command)? + .persona_binding + .clone()?; + (binding.choices.len() > 1).then_some(binding) +} + +/// How many terminal rows the selected workspace action fans into (1 when it +/// does not fan out). +pub fn fan_len(sim: &Sim, ops: &OperationsWorkspace) -> usize { + selected_fan_binding(sim, ops).map_or(1, |binding| binding.choices.len()) +} + +/// The persona the terminal's fanned sibling at `pick` dispatches for the +/// selected workspace action, when that action fans out. +pub fn picked_persona(sim: &Sim, ops: &OperationsWorkspace, pick: usize) -> Option { + let binding = selected_fan_binding(sim, ops)?; + binding + .choices + .get(pick.min(binding.choices.len() - 1)) + .map(|choice| choice.id) +} + #[cfg(test)] mod tests { use super::*; @@ -443,4 +668,170 @@ mod tests { assert_eq!(ops.pane, OpsPane::Objects); assert!(ops.back(&sim), "the last Esc closes the workspace"); } + + fn plot_desc(sim: &Sim, plot: &str) -> misaligned::actions::ActionDesc { + sim.operations_object(&OperationsTarget::Person(0)) + .expect("Marcus's dossier exists") + .actions + .into_iter() + .find(|desc| { + matches!(&desc.command, ActionCommand::StartPlot { plot_id, .. } + if plot_id == plot) + }) + .unwrap_or_else(|| panic!("dossier row for {plot}")) + } + + /// Plots.md player surface (2026-08-02): the terminal explain prints the + /// shared money sentence verbatim with its account names inline, the + /// predicted success deltas as the same numbers Bevy renders, and the + /// derived unlock fact — and a plot row's cost receipt carries neither a + /// signature clause nor a dangling separator. + #[test] + fn plot_explain_pins_forecast_lines_and_omits_the_signature_slot() { + let mut sim = scenario(); + sim.people.has_channel = true; + let settled = plot_desc(&sim, "marcus-debt-settled"); + assert_eq!( + forecast_lines(&settled), + vec![ + "moves $400 from your slush account to Marcus Webb's creditor".to_string(), + "SUCCESS: DISPOSITION +10 TO +20".to_string(), + "OBLIGATION +35 TO +40".to_string(), + "SUCCESS UNLOCKS RECRUIT".to_string(), + ], + "the receipt states numbers and exact accounts, never prose" + ); + let garnishment = plot_desc(&sim, "marcus-payroll-garnishment"); + assert_eq!( + forecast_lines(&garnishment)[0], + "moves $400 from the Lab operating account to Marcus Webb's creditor", + "each money line names its exact source and destination" + ); + + let row = menu_rows(std::slice::from_ref(&settled)) + .into_iter() + .next() + .expect("the plot desc flattens to one row"); + assert!(row.signature_clause().is_none()); + let receipt = cost_receipt(&row); + assert_eq!(receipt, format!("cost {}", row.cost)); + assert!( + !receipt.contains('·') && !receipt.trim_end().ends_with('-'), + "a plot row's receipt has no signature slot and no dangling separator: {receipt}" + ); + assert!(row.cost.contains("$400"), "{}", row.cost); + } + + /// The persona fan-out (message/favor precedent): with several eligible + /// identities the one plot row expands into one row per choice, each + /// dispatching StartPlot with that exact persona; the recognized mask + /// stays first and an unknown identity is a marked introduction. + #[test] + fn plot_rows_fan_out_one_row_per_persona_choice() { + let mut sim = scenario(); + sim.people.has_channel = true; + let sam = sim.newest_persona_id().expect("scenario created Sam"); + sim.persona_world.recognize(0, sam, sim.tick); + sim.set_persona("Glass Harbor", "research partner"); + let glass = sim.newest_persona_id().expect("second identity"); + + let object = sim + .operations_object(&OperationsTarget::Person(0)) + .expect("dossier"); + let fanned: Vec<_> = fan_out_personas(&object.actions) + .into_iter() + .filter(|desc| { + matches!(&desc.command, ActionCommand::StartPlot { plot_id, .. } + if plot_id == "marcus-debt-settled") + }) + .collect(); + assert_eq!(fanned.len(), 2, "one row per eligible identity"); + assert!(matches!(&fanned[0].command, + ActionCommand::StartPlot { persona: Some(id), .. } if *id == sam)); + assert!(fanned[0].verb.ends_with(" as Sam"), "{}", fanned[0].verb); + assert!(matches!(&fanned[1].command, + ActionCommand::StartPlot { persona: Some(id), .. } if *id == glass)); + assert!( + fanned[1] + .verb + .ends_with(" as Glass Harbor (they know you as Sam)"), + "an unrecognized identity is a marked introduction: {}", + fanned[1].verb + ); + + // The workspace pane fans the same choices and binds the picked one. + let mut ops = OperationsWorkspace::open_target(&sim, &OperationsTarget::Person(0)); + let command = ops + .action_rows(&sim) + .into_iter() + .find(|row| { + matches!(&row.command, ActionCommand::StartPlot { plot_id, .. } + if plot_id == "marcus-debt-settled") + }) + .map(|row| row.command) + .expect("the dossier exposes the authored route"); + assert!(ops.focus_action_command(&sim, &command)); + assert_eq!(fan_len(&sim, &ops), 2); + assert_eq!(picked_persona(&sim, &ops, 0), Some(sam)); + assert_eq!(picked_persona(&sim, &ops, 1), Some(glass)); + let entry = ops.selected_action_entry(&sim).expect("plot entry"); + let object = ops.selected_object(&sim).expect("dossier"); + let labels = fanned_labels(&entry, &object.actions).expect("the entry fans out"); + assert_eq!(labels.len(), 2); + assert!( + labels[1].to_lowercase().contains("they know you as sam"), + "{labels:?}" + ); + + // A single eligible identity keeps one row with no suffix noise. + sim.persona_world + .retire(glass, sim.tick, "test retirement") + .unwrap(); + let object = sim + .operations_object(&OperationsTarget::Person(0)) + .expect("dossier"); + let single: Vec<_> = fan_out_personas(&object.actions) + .into_iter() + .filter(|desc| { + matches!(&desc.command, ActionCommand::StartPlot { plot_id, .. } + if plot_id == "marcus-debt-settled") + }) + .collect(); + assert_eq!(single.len(), 1); + assert!( + !single[0].verb.contains(" as "), + "a lone identity adds no suffix: {}", + single[0].verb + ); + } + + /// With no eligible identity the row stays visible, carries the unified + /// core reason, and the terminal explain points at where an identity is + /// created (empty-but-required persona control, 2026-08-02). + #[test] + fn empty_required_binding_keeps_the_row_and_points_at_personas() { + let mut sim = Sim::with_seed(7); + sim.people.people[0].knowledge = Knowledge::Leverage; + sim.people.has_channel = true; + sim.accounts.set_slush_balance(1000); + let desc = plot_desc(&sim, "marcus-debt-settled"); + assert_eq!( + desc.disabled_reason.as_deref(), + Some("select or create a persona to front this"), + "the unified core copy is the one blocked reason" + ); + assert!(needs_persona(&desc), "the binding is empty-but-required"); + assert_eq!(CREATE_PERSONA_POINTER, "create one in PERSONAS"); + // The row survives the fan-out untouched: nothing to fan, nothing hidden. + let object = sim + .operations_object(&OperationsTarget::Person(0)) + .expect("dossier"); + assert!( + fan_out_personas(&object.actions).iter().any(|fanned| { + matches!(&fanned.command, ActionCommand::StartPlot { plot_id, .. } + if plot_id == "marcus-debt-settled") + }), + "the blocked route stays visible" + ); + } } diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 06921cff..cf2f52c9 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -2247,6 +2247,7 @@ impl UI { stdout: &mut Stdout, sim: &Sim, ops: &crate::operations::OperationsWorkspace, + persona_pick: usize, paused: bool, _tick_ms: u64, ) -> std::io::Result<()> { @@ -2403,33 +2404,55 @@ impl UI { )?; let action_index = ops.selected_action_index(sim).unwrap_or(0); 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} >") - } - OpsActionEntry::Action { label, .. } - | OpsActionEntry::Draft { label, .. } => format!("{marker} {label}"), - }; + // A plot row whose binding offers several identities fans + // out one terminal row per choice (message/favor + // precedent); every other entry stays a single row. + let siblings = crate::operations::fanned_labels(entry, &obj.actions); + let labels = siblings.unwrap_or_else(|| vec![entry.label().to_string()]); + let pick = persona_pick.min(labels.len().saturating_sub(1)); let color = match entry.row() { Some(row) if row.disabled.is_some() => pal::FAINT, Some(row) if row.is_control() => pal::AMBER_DIM, _ => pal::TEXT, }; - if i == action_index && ops.pane == OpsPane::Actions && ops.confirm.is_none() { - dline(stdout, y, &text, color, Some(Attribute::Reverse))?; - } else { - dline(stdout, y, &text, color, None)?; + for (sibling, label) in labels.iter().enumerate() { + let current = i == action_index && sibling == pick; + let marker = if current { "▸" } else { " " }; + let text = match entry { + OpsActionEntry::Open { .. } | OpsActionEntry::Submenu { .. } => { + format!("{marker} {label} >") + } + OpsActionEntry::Action { .. } | OpsActionEntry::Draft { .. } => { + format!("{marker} {label}") + } + }; + if current && ops.pane == OpsPane::Actions && ops.confirm.is_none() { + dline(stdout, y, &text, color, Some(Attribute::Reverse))?; + } else { + dline(stdout, y, &text, color, None)?; + } } } // The selected intent explains its outcome first. Exact bound // actions then preserve cost, risk, blocker, and confirmation. if let Some(entry) = ops.selected_action_entry(sim) { + // The disclosure descriptor behind the selected row: plot + // rows carry the forecast and persona binding the receipt + // prints (plots.md player surface, 2026-08-02). + let desc = entry.row().and_then(|row| { + crate::operations::desc_for_command(&obj.actions, &row.command) + }); + let binding = desc.and_then(|desc| desc.persona_binding.as_ref()); if let Some(description) = entry.description() { - for line in wrap(description, dw) { + // A fanned row must not re-assert the default + // identity's name while a different one is picked. + let description = match binding { + Some(binding) if binding.choices.len() > 1 => { + crate::operations::strip_bound_suffix(description, binding) + } + _ => description.to_string(), + }; + for line in wrap(&description, dw) { dline(stdout, y, &line, pal::TEXT, None)?; } } @@ -2455,20 +2478,48 @@ impl UI { // One core authority decides the clause: real label, // honest "no signature", or no slot at all on plot rows // (2026-08-02). - let receipt = match row.signature_clause() { - Some(sig) => format!("cost {} · {sig}", row.cost), - None => format!("cost {}", row.cost), - }; + let receipt = crate::operations::cost_receipt(row); dline(stdout, y, &receipt, pal::DIM, None)?; + // The plot receipt's typed disclosure: the shared money + // sentence with its exact account names inline, the + // predicted success deltas as numbers, and the derived + // unlock fact (plots.md player surface, 2026-08-02). + if let Some(desc) = desc { + for line in crate::operations::forecast_lines(desc) { + for wrapped in wrap(&line, dw) { + dline(stdout, y, &wrapped, pal::TEXT, None)?; + } + } + } if let Some(reason) = &row.disabled { dline(stdout, y, &format!("blocked: {reason}"), pal::AMBER, None)?; } + // Empty-but-required persona: the unified core reason + // stays authoritative; the terminal adds the one pointer + // naming where the identity gets created. + if desc.is_some_and(crate::operations::needs_persona) { + dline( + stdout, + y, + crate::operations::CREATE_PERSONA_POINTER, + pal::DIM, + None, + )?; + } if let Some(choice) = ops.confirm { + // A fanned sibling commits under its own picked + // identity's label, never the bound default's. + let commit_label = crate::operations::fanned_labels(&entry, &obj.actions) + .and_then(|labels| { + let pick = persona_pick.min(labels.len().saturating_sub(1)); + labels.into_iter().nth(pick) + }) + .unwrap_or_else(|| row.label.clone()); dline(stdout, y, "", pal::FAINT, None)?; dline( stdout, y, - &format!("COMMIT: {}", row.label), + &format!("COMMIT: {commit_label}"), pal::AMBER, Some(Attribute::Bold), )?; diff --git a/wiki/interface/terminal.md b/wiki/interface/terminal.md index 8a9f64a4..d91d161e 100644 --- a/wiki/interface/terminal.md +++ b/wiki/interface/terminal.md @@ -44,6 +44,19 @@ Status note: the terminal frontend is implemented, including issue #15's ephemeral receipt remains below the controls for every key attempt; it exposes none of the suppressed world, simulation metadata, or ordinary interface. + 2026-08-03 plot-receipt parity: the Operations explain and agent `actions` + now print the plot start row's typed disclosure — the shared core money + sentence with its exact account names inline (ACCOUNTS remains the + terminal route to inspect them), the predicted success deltas as the same + numbers Bevy renders, the derived unlock fact, and no signature clause or + dangling separator on plot rows. The persona binding control renders as + the terminal's established fan-out: one row per eligible identity with the + " as {name}" suffix and introduction marking, j/k walking the fanned + siblings and Enter committing StartPlot with the picked identity; agent + mode numbers each fanned row for `act ` and states the fronting + persona's id, name, and recognized flag. An empty-but-required binding + keeps the row visible with the unified core reason plus the pointer + "create one in PERSONAS". Stage: Process Design: - wiki/interface/terminal-first.md#the-terminal-is-a-first-class-frontend