From 0ce47e926ffb3bb4ee719e1377b3e6b395dd3b4d Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 4 Aug 2026 10:07:49 -0700 Subject: [PATCH] Print the plot receipt and fan the persona control in the terminal. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal frontend now renders the 2026-08-02 knowledge-use receipt with full parity. The Operations explain pane and agent-mode `actions` print a plot start row's typed disclosure on its own lines: the compact cost label, the shared core money sentence verbatim with its exact account names inline (ACCOUNTS remains the terminal route to inspect them), one clause per predicted success delta through the new core PredictedDelta::clause() — the only core change, a read-only formatting accessor so terminal and Bevy print identical numbers ("SUCCESS: DISPOSITION +10 TO +20", min == max collapsing to the single number) — and the derived unlock fact ("SUCCESS UNLOCKS RECRUIT"). Plot rows keep no signature clause and no dangling separator; MenuRow::signature_clause stays the one authority via the shared cost_receipt helper. The persona binding control becomes operable through the terminal's established fan-out precedent: a plot row whose binding offers several identities expands locally into one row per choice with the " as {name}" suffix and "(they know you as X)" introduction marking, each dispatching StartPlot with that exact persona. In the human workspace j/k walk the fanned siblings (App-held pick state over the shared core workspace, reset on navigation, kept across the CONFIRM step) and Enter commits the picked identity, which the executor revalidates. Agent mode numbers each fanned row so `act ` and the typed `plot` dispatch address every identity, and each plot row's disclosure block names the fronting persona id, name, and recognized/introduction flag. An empty-but-required binding keeps the row visible with the unified core reason and adds the pointer "create one in PERSONAS". New tests pin the exact Marcus forecast lines, the fan-out commands and suffixes, the workspace sibling dispatch, the agent output, and the empty-required pointer; the full terminal and core suites pass. wiki/engineering/current-build.md's line-count claim is refreshed to ~117k (mechanical: this commit tips the corpus gate past its 15% drift threshold). Defense: implements the terminal half of the 2026-08-02 plots.md player-surface amendment (typed predicted consequences as numbers, exact account naming, omitted signature slot) under terminal-first.md's parity law — every mechanic playable in Bevy must be playable, and every number legible, in the terminal and in agent mode. The fan-out rather than a dropdown is the terminal's native control form, mirroring the existing message/favor identity rows; legality and the unified missing-persona copy stay core-owned, and the terminal adds only rendering and the picked-persona swap that the executor revalidates. wiki/interface/terminal.md's status note records the parity as implemented 2026-08-03. --- crates/misaligned-core/src/actions.rs | 40 ++ crates/misaligned-terminal/src/agent.rs | 291 +++++++++++--- crates/misaligned-terminal/src/main.rs | 167 +++++++- crates/misaligned-terminal/src/operations.rs | 393 ++++++++++++++++++- crates/misaligned-terminal/src/ui.rs | 93 ++++- wiki/interface/terminal.md | 13 + 6 files changed, 915 insertions(+), 82 deletions(-) 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 -- 2.51.2