From f817d6fdca20ad0f1282261754cf89b06cf3abe7 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 4 Aug 2026 10:07:25 -0700 Subject: [PATCH] Render the plot forecast receipt and persona binding control in Bevy. The selected plot row's WHAT HAPPENS NEXT area now states the 2026-08-02 disclosure: the expanded cost read (ActionCost::player_label) replaces the compact "cost {label}" line, the one shared PlotForecast::money_sentence renders with each earned account as an activatable link that focuses its exact ACCOUNTS object (unearned accounts stay plain text), each PredictedDelta prints its axis and exact numeric range beside a read-only DIM bar on a GUNMETAL track scaled against the fixed 50-point practical axis range, the derived unlock fact prints in bone, and the signature slot is omitted entirely with no dangling separator. Missing-persona legality renders as the FRONTING IDENTITY control instead of a blocked sentence: recognized-first identity choices with exactly one current row and DIM alternatives, unrecognized identities marked as introductions naming the exposure, clicks rebinding the row's StartPlot persona parameter as pure frontend state the executor still revalidates, an empty-but-required state under the AMBER_DIM requirement outline naming what is missing, and the exact PERSONAS-owned ADD NEW {TYPE} PERSONA creation rows dispatched in place, after which the recomputed row keeps the decision selected with the new identity as its default binding. A persona-less StartPlot can never dispatch from Bevy, and the unified blocked string remains untouched for terminal and agent surfaces. Core changes are read-only projection data only: MoneyMove carries the earned account id behind each label and plot_account_id opens to pub(crate). Eight structure tests pin the exact Marcus debt-settled numbers, the linked money sentence, bar widths, the omitted signature slot, the control's selection and emphasis states, account-link focus, persona rebinding through the unchanged confirm law, and creation-row dispatch. current-build.md's line count refreshes because this change pushed the tracked drift past the corpus gate's 15% tolerance. Defense: operations-workspace.md's "The persona binding control" section and detail-order item 6 (reopened 2026-08-02) require the receipt to disclose typed predicted deltas as numbers with small labeled bars, name each account focusable to its exact ACCOUNTS object where earned, and present a satisfiable select-or-create control dispatching the same bound PERSONAS commands instead of a blocked: dead end; the liturgical constitution's control canon binds the rendering itself - bars reinforce while the number carries the meaning, a named world object inside a receipt is an activatable link to its owning view, requirement emphasis draws on the amber tiers while crimson stays danger-only, and every control renders renderer-neutral projection data that stays fully legible as terminal text and agent commands. --- crates/misaligned-bevy/src/main.rs | 132 +- crates/misaligned-bevy/src/operations_ui.rs | 1078 ++++++++++++++++- crates/misaligned-core/src/actions.rs | 8 + crates/misaligned-core/src/actions/person.rs | 11 + crates/misaligned-core/src/sim/social_plot.rs | 2 +- wiki/engineering/current-build.md | 2 +- wiki/interface/liturgical-ui-constitution.md | 5 + wiki/interface/operations-workspace.md | 12 +- 8 files changed, 1216 insertions(+), 34 deletions(-) diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index a9b8f3e8..bc16be8f 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -98,7 +98,7 @@ use opening_ui::{ }; use operations_ui::{ OperationsPanel, OperationsRailButton, OperationsUi, manage_operations_ui, ops_keyboard_input, - ops_pointer, scroll_ops_action_pane, + ops_pointer, ops_receipt_pointer, scroll_ops_action_pane, }; use production::render_work_production; use rail_ui::{ @@ -787,6 +787,13 @@ struct Game { ops: Option, /// Selection retained while FOCUS temporarily returns to the world. ops_resume: Option, + /// The persona binding control's frontend rebind state (2026-08-02): + /// which identity fronts one plot start row, keyed by (person, plot id). + /// A pure command-parameter swap on the bound `StartPlot` row — never a + /// sim mutation, never saved; the executor still revalidates the exact + /// identity at commit. A stale override (identity no longer eligible) + /// is ignored rather than dispatched. + ops_plot_personas: BTreeMap<(u8, String), misaligned::persona::PersonaId>, /// The origin highlighted on the title-screen picker (chargen.md). Applied /// when the run begins; defaults to Pilot (the identity origin). selected_origin: Origin, @@ -1081,15 +1088,32 @@ impl Game { match result { 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); + // the workspace never closes on a result. A plot start row + // first takes the persona binding control's frontend rebind + // (2026-08-02), and a persona-less StartPlot never + // dispatches: the fronting-identity control is the gate, and + // the executor would refuse it anyway. + let command = self.ops_plot_command(&row.command); + if matches!(command, ActionCommand::StartPlot { persona: None, .. }) { + return; + } + self.sim.execute_action(&command); if let Some(ops) = &mut self.ops { ops.clamp_to(&self.sim); } } Some(OpsSelect::Blocked(row)) => { - let tick = self.sim.tick; + // The unified missing-persona reason renders as the fronting + // identity control, never as a blocked sentence (2026-08-02 + // persona binding control); every other blocker still + // narrates its exact reason. let reason = row.disabled.clone().unwrap_or_default(); + if matches!(row.command, ActionCommand::StartPlot { .. }) + && reason == operations_ui::OPS_PERSONA_REQUIRED_REASON + { + return; + } + let tick = self.sim.tick; self.add_log(tick, &format!("{}: {}", row.label, reason)); } Some(OpsSelect::Open(target)) => { @@ -1142,6 +1166,103 @@ impl Game { } } + /// Apply the fronting-identity control's rebind to one bound plot start + /// command (operations-workspace.md persona binding control): a pure + /// persona-parameter swap on the exact core-owned `StartPlot` row. The + /// override applies only while that identity is still a live binding + /// choice for this counterparty, so a retired or burned mask silently + /// falls back to the row's own default. + fn ops_plot_command(&self, command: &ActionCommand) -> ActionCommand { + let ActionCommand::StartPlot { + person, + plot_id, + persona, + } = command + else { + return command.clone(); + }; + let persona = self + .ops_plot_personas + .get(&(*person, plot_id.clone())) + .copied() + .filter(|choice| { + self.sim + .persona_world + .binding_choices(*person, misaligned::persona::PersonaActionKind::Plot) + .contains(choice) + }) + .or(*persona); + ActionCommand::StartPlot { + person: *person, + plot_id: plot_id.clone(), + persona, + } + } + + /// The persona binding control's selection act: remember which identity + /// fronts this plot row. Frontend attention state only — the world + /// changes when the rebound command commits, not here. + fn ops_bind_plot_persona( + &mut self, + person: u8, + plot_id: &str, + persona: misaligned::persona::PersonaId, + ) { + self.ops_plot_personas + .insert((person, plot_id.to_string()), persona); + } + + /// Follow a receipt's named-account link to its exact ACCOUNTS object + /// (control canon: a named world object inside a receipt is an + /// activatable link that focuses its exact owning view). Navigation + /// only — same path as any related-object edge. + fn ops_follow_account(&mut self, target: &OperationsTarget) { + if let Some(ops) = &mut self.ops { + ops.follow_target(&self.sim, target); + } + } + + /// Dispatch one bound archetype creation row from the persona binding + /// control. The command is the exact `CreatePersona` row PERSONAS owns + /// (one command authority, two placements); creation is free, unsigned, + /// and deliberately confirmed by the click on its named row. The + /// requiring plot row stays selected: its command identity changes as + /// the new identity becomes the default binding, so the cursor is + /// re-resolved onto the recomputed row instead of falling back to the + /// object pane mid-decision. + fn ops_create_persona(&mut self, command: &ActionCommand) { + if !matches!(command, ActionCommand::CreatePersona { .. }) { + return; + } + let selected_plot = self + .ops + .as_ref() + .and_then(|ops| ops.selected_action(&self.sim)) + .map(|row| row.command); + self.sim.execute_action(command); + let Self { sim, ops, .. } = self; + if let Some(ops) = ops.as_mut() { + ops.clamp_to(sim); + if let Some(ActionCommand::StartPlot { + person, plot_id, .. + }) = selected_plot + && let Some(object) = sim.operations_object(&OperationsTarget::Person(person)) + && let Some(desc) = object.actions.iter().find(|desc| { + matches!( + &desc.command, + ActionCommand::StartPlot { + person: row_person, + plot_id: row_plot, + .. + } if *row_person == person && row_plot == &plot_id + ) + }) + { + ops.focus_action_command(sim, &desc.command); + } + } + } + /// Focus a linked log event (context-menu.md addendum; /// operations-workspace.md events-and-focus): strategic events open /// their exact Operations object; spatial anchors carry the cursor — @@ -1213,6 +1334,7 @@ impl Game { marquee: None, ops: None, ops_resume: None, + ops_plot_personas: BTreeMap::new(), selected_origin: Origin::Pilot, } } @@ -1894,7 +2016,7 @@ fn main() { ) .chain(), (held_choice_pointer, menu_pointer, command_drawer_pointer), - ops_pointer, + (ops_pointer, ops_receipt_pointer).chain(), log_row_pointer, manage_menu_ui, (manage_operations_ui, scroll_ops_action_pane).chain(), diff --git a/crates/misaligned-bevy/src/operations_ui.rs b/crates/misaligned-bevy/src/operations_ui.rs index cbf09197..cf70093a 100644 --- a/crates/misaligned-bevy/src/operations_ui.rs +++ b/crates/misaligned-bevy/src/operations_ui.rs @@ -1,5 +1,9 @@ use super::*; +use misaligned::account::AccountId; +use misaligned::actions::{ActionDesc, PersonaBinding, PlotForecast, PredictedDelta}; +use misaligned::persona::{PERSONA_ARCHETYPES, PersonaId}; + // ─── Operations workspace UI (wiki/interface/operations-workspace.md) ─────── /// The opaque full-frame Operations layer over the still-running world. #[derive(Component)] @@ -42,6 +46,54 @@ pub(super) struct OpsFocusButton; pub(super) struct OpsConfirmButton { confirm: bool, } +/// One named account inside the receipt's money sentence, rendered as an +/// activatable link that focuses its exact earned ACCOUNTS object +/// (liturgical constitution control canon, 2026-08-02). Unearned accounts +/// never carry this component — they stay plain text. +#[derive(Component)] +pub(super) struct OpsAccountLinkButton { + target: OperationsTarget, +} +/// One selectable identity row on the persona binding control. Clicking it +/// rebinds the plot start row's persona parameter — frontend state only. +#[derive(Component)] +pub(super) struct OpsPersonaChoiceButton { + person: u8, + plot_id: String, + persona: PersonaId, +} +/// One ADD NEW {TYPE} PERSONA row on the persona binding control. It carries +/// the exact bound `CreatePersona` command PERSONAS owns — one command +/// authority, two placements (operations-workspace.md 2026-08-02). +#[derive(Component)] +pub(super) struct OpsPersonaCreateButton { + command: ActionCommand, +} +/// The read-only reinforcement bar beside one predicted delta. The number +/// carries the meaning; the bar only restates it (control canon: no +/// information by geometry alone). Its `Node` width is the scaled fill. +#[derive(Component)] +pub(super) struct OpsForecastBar; +/// The persona binding control container. Empty-but-required carries the +/// amber-tier requirement outline on its `BorderColor` (palette canon: +/// requirement emphasis draws on the amber tiers; crimson stays danger-only). +#[derive(Component)] +pub(super) struct OpsPersonaControl; + +/// The unified missing-persona reason the core row carries (the 2026-08-02 +/// persona-control contract, one string on every plot path). Terminal and +/// agent surfaces print it; the Bevy surface renders the satisfying +/// fronting-identity control instead of a blocked sentence. +pub(super) const OPS_PERSONA_REQUIRED_REASON: &str = "select or create a persona to front this"; + +/// Fixed forecast-bar scale: the relationship axes' practical range. A +40 +/// obligation delta (a serviced life-changing debt) nearly fills the track, +/// so bars stay comparable across rows instead of auto-scaling to their own +/// maximum and lying about magnitude. +const OPS_FORECAST_BAR_SCALE: f32 = 50.0; +/// Full track width of one forecast bar, px. +const OPS_FORECAST_BAR_TRACK_PX: f32 = 90.0; +const OPS_FORECAST_BAR_HEIGHT_PX: f32 = 7.0; /// Whether the panel was open last frame (content rebuilds while the sim /// state underneath changes; hidden panels keep no children). #[derive(Resource, Default)] @@ -72,8 +124,9 @@ mod operations_workspace_tests { use misaligned::person::Knowledge; /// The seeded strategic scenario shared with the terminal and core - /// tests (operations-workspace.md criterion 12). - fn scenario() -> Sim { + /// tests (operations-workspace.md criterion 12). `pub(super)` so the + /// knowledge-use receipt tests below reuse the exact same world. + pub(super) fn scenario() -> Sim { let mut sim = Sim::with_seed(7); let (x, y) = sim.core_position(); let ops = sim.compute.add_machine( @@ -104,7 +157,7 @@ mod operations_workspace_tests { sim } - fn drain_ops(sim: &mut Sim) { + pub(super) fn drain_ops(sim: &mut Sim) { for _ in 0..16 { let open: Vec<(u32, f32)> = sim .thought_sinks @@ -559,6 +612,489 @@ mod operations_focus_tests { } } +/// The knowledge-use receipt and persona binding control +/// (operations-workspace.md status note + detail order 6, 2026-08-02; +/// liturgical constitution control canon): a plot start receipt discloses +/// the exact predicted numbers, names its accounts with earned links, omits +/// the signature slot entirely, and renders a missing fronting persona as a +/// satisfiable control instead of a blocked sentence. +#[cfg(test)] +mod knowledge_use_receipt_tests { + use super::operations_workspace_tests::{drain_ops, scenario}; + use super::*; + use misaligned::actions::{PersonaChoice, RelationshipAxis}; + use misaligned::person::Knowledge; + + /// The shared scenario with the outside message channel earned, so the + /// Marcus plot rows are enabled and the receipt carries no unrelated + /// blocker. + fn channel_scenario() -> Sim { + let mut sim = scenario(); + sim.people.has_channel = true; + sim + } + + /// The world before any identity exists: the persona binding control's + /// empty-but-required state on an otherwise satisfiable plot row. + fn personaless_scenario() -> Sim { + let mut sim = Sim::with_seed(7); + let (x, y) = sim.core_position(); + let ops = sim.compute.add_machine( + "test ops", + x + 1, + y, + 10_000, + 1.0, + 0, + misaligned::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(ops, misaligned::work_grid::MachineMode::Work); + for _ in 0..misaligned::sim::ECONOMY_INTERVAL { + sim.advance(); + } + sim.people.people[0].knowledge = Knowledge::Leverage; + sim.people.has_channel = true; + sim.accounts.set_slush_balance(1000); + sim.player.money = 1000; + sim.drain_log_entries(); + sim + } + + /// Open Marcus's leverage submenu and select the debt-settled route. + fn debt_settled_workspace(sim: &Sim) -> OperationsWorkspace { + let mut ops = OperationsWorkspace::open_target(sim, &OperationsTarget::Person(0)); + ops.pane = OpsPane::Actions; + let root = ops.action_entries(sim); + let leverage = root + .iter() + .position(|entry| entry.submenu() == Some(OpsActionSubmenu::Leverage)) + .expect("Marcus carries the leverage intention"); + ops.select_action_index(sim, leverage); + ops.select(sim); + let debt = ops + .action_entries(sim) + .iter() + .position(|entry| { + matches!( + entry.row().map(|row| &row.command), + Some(ActionCommand::StartPlot { plot_id, .. }) + if plot_id.as_str() == "marcus-debt-settled" + ) + }) + .expect("the debt-settled route is listed"); + ops.select_action_index(sim, debt); + ops + } + + /// Spawn the workspace UI for one prepared game and hand back the app. + fn rendered_app(game: Game) -> App { + let mut app = App::new(); + app.insert_resource(game) + .init_resource::() + .add_systems(Startup, setup_ui) + .add_systems(Update, manage_operations_ui); + app.update(); + app + } + + fn panel_texts(app: &mut App) -> Vec { + let world = app.world_mut(); + let mut query = world.query::<&Text>(); + query.iter(world).map(|text| text.0.clone()).collect() + } + + /// The debt-settled receipt states the typed numbers exactly: expanded + /// cost, the one shared money sentence, both success-ending delta + /// ranges, the derived unlock fact, and fixed-scale bar widths. + #[test] + fn receipt_discloses_the_exact_debt_settled_numbers() { + let sim = channel_scenario(); + let ops = debt_settled_workspace(&sim); + let obj = ops.selected_object(&sim).unwrap(); + let row = ops.selected_action(&sim).unwrap(); + assert!(row.disabled.is_none(), "{:?}", row.disabled); + let desc = ops_action_desc(&obj, &row).expect("the projection binds this row"); + assert_eq!(desc.cost.player_label(), "USES 0.25 THOUGHT AND MOVES $400"); + let forecast = desc.forecast.as_ref().expect("plot rows carry a forecast"); + let lines: Vec = forecast + .deltas + .iter() + .map(ops_forecast_delta_text) + .collect(); + assert_eq!(lines, ["DISPOSITION +10 TO +20", "OBLIGATION +35 TO +40"]); + assert_eq!(forecast.unlocks.as_deref(), Some("success unlocks RECRUIT")); + assert_eq!( + forecast.money_sentence().as_deref(), + Some("moves $400 from your slush account to Marcus Webb's creditor") + ); + // A min==max delta prints once. + assert_eq!( + ops_forecast_delta_text(&PredictedDelta { + axis: RelationshipAxis::Obligation, + min: 40, + max: 40, + }), + "OBLIGATION +40" + ); + // Bars scale against the fixed 50-point practical axis range. + assert_eq!(ops_forecast_bar_px(&forecast.deltas[0]), 36.0); + assert_eq!(ops_forecast_bar_px(&forecast.deltas[1]), 72.0); + // The segment split reproduces the one shared sentence; the earned + // slush account links to its ACCOUNTS object and the unearned + // creditor stays plain text — an honest gap, never a speculative + // link. + let segments = ops_money_segments(forecast); + assert_eq!( + ops_money_segments_text(&segments), + forecast.money_sentence().unwrap() + ); + let slush = sim.accounts.slush_id(); + assert!(segments.iter().any(|segment| matches!( + segment, + OpsMoneySegment::Account { label, account } + if label == "your slush account" && *account == slush + ))); + assert!(segments.iter().any(|segment| matches!( + segment, + OpsMoneySegment::Text(text) if text == "Marcus Webb's creditor" + ))); + } + + /// The rendered receipt carries the numbers, the account link, the delta + /// bars beside their numbers, the unlock fact, the persona control with + /// its one current identity — and no signature slot, no compact cost + /// line, and no dangling separator. + #[test] + fn receipt_renders_numbers_links_bars_and_the_persona_control() { + let sim = channel_scenario(); + let slush = sim.accounts.slush_id(); + let ops = debt_settled_workspace(&sim); + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = sim; + game.sim.opening_stage = OpeningStage::World; + game.ops = Some(ops); + let mut app = rendered_app(game); + + let texts = panel_texts(&mut app); + for expected in [ + "USES 0.25 THOUGHT AND MOVES $400", + "DISPOSITION +10 TO +20", + "OBLIGATION +35 TO +40", + "SUCCESS UNLOCKS RECRUIT", + "moves $400 from ", + "your slush account", + " to ", + "Marcus Webb's creditor", + "Sam (new to them)", + ] { + assert!( + texts.iter().any(|text| text == expected), + "missing {expected:?} in {texts:?}" + ); + } + // The retired signature slot never returns in any spelling, and the + // compact "cost {label}" line yields to the expanded cost read. + assert!(texts.iter().all(|text| !text.contains("signature"))); + assert!( + texts + .iter() + .all(|text| !text.starts_with("cost ") && !text.contains("\ncost ")), + "a plot row must not print the compact cost line" + ); + assert!( + texts + .iter() + .all(|text| !text.ends_with(" - ") && !text.contains(" - \n")), + "no dangling separator may stand in for the omitted slot" + ); + assert!(texts.iter().all(|text| !text.contains("blocked:"))); + + // The one account link targets the exact earned ACCOUNTS object. + let world = app.world_mut(); + let mut links = world.query::<&OpsAccountLinkButton>(); + let targets: Vec<_> = links.iter(world).map(|link| link.target.clone()).collect(); + assert_eq!(targets, [OperationsTarget::Account(slush)]); + + // One bar per delta, filled against the fixed axis scale. + let mut bars = world.query_filtered::<&Node, With>(); + let mut widths: Vec = bars + .iter(world) + .map(|node| match node.width { + Val::Px(px) => px, + width => panic!("bar width must be exact px, got {width:?}"), + }) + .collect(); + widths.sort_by(f32::total_cmp); + assert_eq!(widths, [36.0, 72.0]); + + // The persona control: one current identity row (selection canon) + // plus the full set of PERSONAS-owned creation rows. + let mut choices = world.query::<(&Node, &OpsPersonaChoiceButton)>(); + let rows: Vec<_> = choices.iter(world).collect(); + assert_eq!(rows.len(), 1); + let (node, choice) = &rows[0]; + assert_eq!(choice.person, 0); + assert_eq!(choice.plot_id, "marcus-debt-settled"); + assert_eq!( + node.border.left, + Val::Px(3.0), + "the bound identity carries the current selection stroke" + ); + let mut creations = world.query::<&OpsPersonaCreateButton>(); + assert_eq!(creations.iter(world).count(), PERSONA_ARCHETYPES.len()); + for definition in PERSONA_ARCHETYPES { + let label = format!("ADD NEW {} PERSONA", definition.label.to_ascii_uppercase()); + assert!(texts.contains(&label), "missing creation row {label}"); + } + } + + /// Empty-but-required: the control renders under the amber-tier + /// requirement outline, names what is missing in bone, offers the + /// creation rows in place — and the unified blocked sentence never + /// renders anywhere in the chamber. + #[test] + fn empty_but_required_control_emphasizes_and_creates_in_place() { + let sim = personaless_scenario(); + let ops = debt_settled_workspace(&sim); + let row = ops.selected_action(&sim).unwrap(); + assert_eq!(row.disabled.as_deref(), Some(OPS_PERSONA_REQUIRED_REASON)); + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = sim; + game.sim.opening_stage = OpeningStage::World; + game.ops = Some(ops); + let mut app = rendered_app(game); + + let texts = panel_texts(&mut app); + assert!( + texts + .iter() + .any(|text| text == "A PERSONA MUST FRONT THIS PLOT"), + "{texts:?}" + ); + assert!( + texts + .iter() + .all(|text| !text.contains(OPS_PERSONA_REQUIRED_REASON)), + "the persona requirement must render as the control, not prose" + ); + assert!(texts.iter().all(|text| !text.contains("blocked:"))); + + let world = app.world_mut(); + let mut control = world.query_filtered::<(&Node, &BorderColor), With>(); + let (node, border) = control.single(world).unwrap(); + assert_eq!(node.border, UiRect::all(Val::Px(1.0))); + assert_eq!( + *border, + BorderColor::all(AMBER_DIM), + "requirement emphasis draws on the amber tiers" + ); + let mut choices = world.query::<&OpsPersonaChoiceButton>(); + assert_eq!(choices.iter(world).count(), 0); + let mut creations = world.query::<&OpsPersonaCreateButton>(); + assert_eq!(creations.iter(world).count(), PERSONA_ARCHETYPES.len()); + } + + /// The control's identity rows keep core's recognized-first order, mark + /// exactly one current row, name the exposure an introduction creates, + /// and follow the frontend rebind. + #[test] + fn persona_rows_order_mark_and_rebind() { + let binding = PersonaBinding { + bound: Some(7), + choices: vec![ + PersonaChoice { + id: 4, + name: "Rowan Quist".into(), + recognized: true, + }, + PersonaChoice { + id: 7, + name: "Sam Reyes".into(), + recognized: false, + }, + ], + required: true, + }; + let rows = ops_persona_rows(&binding, None); + assert_eq!(rows[0].label, "Rowan Quist"); + assert!(!rows[0].current); + assert_eq!(rows[1].label, "Sam Reyes (they know you as Rowan Quist)"); + assert!(rows[1].current); + let rows = ops_persona_rows(&binding, Some(4)); + assert!(rows[0].current && !rows[1].current); + // A rebind naming a retired identity is ignored, not trusted. + let rows = ops_persona_rows(&binding, Some(99)); + assert!(rows[1].current); + } + + /// The receipt's account link follows the same navigation law as any + /// related-object edge: ACCOUNTS opens focused on the exact object. + #[test] + fn account_link_focuses_the_exact_accounts_object() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = channel_scenario(); + let slush = game.sim.accounts.slush_id(); + game.ops = Some(debt_settled_workspace(&game.sim)); + + game.ops_follow_account(&OperationsTarget::Account(slush)); + + let ops = game.ops.as_ref().unwrap(); + assert_eq!(ops.view, OperationsView::Accounts); + assert_eq!( + ops.selected_object(&game.sim).unwrap().target, + OperationsTarget::Account(slush) + ); + } + + /// Clicking a choice rebinds the row's StartPlot persona parameter, a + /// stale rebind falls back to the row default, and the ordinary confirm + /// law commits the rebound identity. + #[test] + fn persona_choice_rebinds_the_bound_start_plot_command() { + let mut sim = channel_scenario(); + sim.set_persona("Alex", "auditor"); + let second = sim.newest_persona_id().unwrap(); + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = sim; + game.ops = Some(debt_settled_workspace(&game.sim)); + + game.ops_bind_plot_persona(0, "marcus-debt-settled", second); + let row = game + .ops + .as_ref() + .unwrap() + .selected_action(&game.sim) + .unwrap(); + assert_eq!( + game.ops_plot_command(&row.command), + ActionCommand::StartPlot { + person: 0, + plot_id: "marcus-debt-settled".into(), + persona: Some(second), + } + ); + // A rebind that is no longer a live binding choice is ignored. + game.ops_bind_plot_persona(0, "marcus-debt-settled", 999); + assert_eq!(game.ops_plot_command(&row.command), row.command); + + // Commit through the unchanged confirm law: Enter opens CONFIRM, + // the confirmed dispatch carries the rebound persona. + game.ops_bind_plot_persona(0, "marcus-debt-settled", second); + game.ops_enter(); + assert!(game.ops.as_ref().unwrap().confirm.is_some()); + game.ops.as_mut().unwrap().confirm = Some(ConfirmChoice::Confirm); + game.ops_enter(); + drain_ops(&mut game.sim); + let run = game + .sim + .plot_runs + .iter() + .find(|run| run.plot_id == "marcus-debt-settled") + .expect("the confirmed dispatch submitted the plot"); + assert_eq!(run.persona_id, Some(second)); + } + + /// A persona-less StartPlot can never dispatch, and its gate speaks + /// through the control rather than a blocked log line; every other + /// blocker still narrates its exact reason. + #[test] + fn persona_less_start_plot_never_dispatches_and_never_logs_blocked() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = personaless_scenario(); + game.ops = Some(debt_settled_workspace(&game.sim)); + let before = serde_json::to_string(&game.sim.create_save_state()).unwrap(); + + game.ops_enter(); + + assert!(game.log.is_empty(), "{:?}", game.log); + assert_eq!( + serde_json::to_string(&game.sim.create_save_state()).unwrap(), + before, + "a persona-less start must not mutate the sim" + ); + + // A real blocker (the unearned message channel) still narrates. + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = scenario(); + game.ops = Some(debt_settled_workspace(&game.sim)); + game.ops_enter(); + assert!( + game.log + .iter() + .any(|event| event.text.contains("required message channel")), + "{:?}", + game.log + ); + } + + /// The creation rows dispatch the exact bound archetype command the + /// PERSONAS view owns; after creation the projection recomputes and the + /// new identity is the row's default binding. + #[test] + fn creation_row_dispatches_the_bound_archetype_command() { + let mut game = Game::new(); + game.screen = Screen::Playing; + game.sim = personaless_scenario(); + game.ops = Some(debt_settled_workspace(&game.sim)); + let create = game + .sim + .operations_projection() + .personas + .iter() + .find(|object| object.target == OperationsTarget::PersonaDraft) + .and_then(|object| { + object.actions.iter().find(|desc| { + matches!( + &desc.command, + ActionCommand::CreatePersona { archetype_id, .. } + if archetype_id.as_str() == "operations" + ) + }) + }) + .map(|desc| desc.command.clone()) + .expect("PERSONAS owns the bound creation rows"); + assert_eq!( + ops_persona_create_label(&create).as_deref(), + Some("ADD NEW OPERATIONS PERSONA") + ); + + game.ops_create_persona(&create); + + let persona = game + .sim + .newest_persona_id() + .expect("the creation row established an identity"); + // The decision stays in place: the same plot route remains the + // selected action even though its command identity changed as the + // new identity became the default binding. + let ops = game.ops.as_ref().unwrap(); + let row = ops.selected_action(&game.sim).unwrap(); + assert!(matches!( + &row.command, + ActionCommand::StartPlot { plot_id, .. } + if plot_id.as_str() == "marcus-debt-settled" + )); + let obj = ops.selected_object(&game.sim).unwrap(); + let binding = ops_action_desc(&obj, &row) + .and_then(|desc| desc.persona_binding.clone()) + .unwrap(); + assert_eq!(binding.bound, Some(persona)); + assert!(binding.choices.iter().any(|choice| choice.id == persona)); + assert!( + row.disabled.is_none(), + "the satisfied requirement unblocks the row: {:?}", + row.disabled + ); + } +} + // ─── Operations workspace systems (operations-workspace.md) ───────────────── /// The workspace keyboard drive: `h`/`l` (or arrows) change view, `j`/`k` @@ -808,6 +1344,54 @@ pub(super) fn ops_pointer( } } +/// Pointer drive for the receipt's inline controls (2026-08-02 control +/// canon): account links focus their exact ACCOUNTS object, persona choice +/// rows rebind the plot start row's fronting identity, and ADD NEW {TYPE} +/// PERSONA rows dispatch the exact bound archetype creation command the +/// PERSONAS view owns. +#[allow(clippy::type_complexity)] +pub(super) fn ops_receipt_pointer( + mut game: ResMut, + mut controls: ParamSet<( + Query<(&Interaction, &OpsAccountLinkButton), Changed>, + Query<(&Interaction, &OpsPersonaChoiceButton), Changed>, + Query<(&Interaction, &OpsPersonaCreateButton), Changed>, + )>, +) { + if game.screen != Screen::Playing || game.sim.teaching_lock_active() || game.ops.is_none() { + return; + } + let mut account = None; + for (interaction, link) in &controls.p0() { + if matches!(interaction, Interaction::Pressed) { + account = Some(link.target.clone()); + } + } + if let Some(target) = account { + game.ops_follow_account(&target); + return; + } + let mut choice = None; + for (interaction, button) in &controls.p1() { + if matches!(interaction, Interaction::Pressed) { + choice = Some((button.person, button.plot_id.clone(), button.persona)); + } + } + if let Some((person, plot_id, persona)) = choice { + game.ops_bind_plot_persona(person, &plot_id, persona); + return; + } + let mut create = None; + for (interaction, button) in &controls.p2() { + if matches!(interaction, Interaction::Pressed) { + create = Some(button.command.clone()); + } + } + if let Some(command) = create { + game.ops_create_persona(&command); + } +} + const OPS_ACTION_SCROLL_LINE: f32 = 36.0; fn ops_action_wheel_delta(unit: MouseScrollUnit, y: f32) -> f32 { @@ -909,6 +1493,15 @@ pub(super) fn manage_operations_ui( let sim = &game.sim; let projection = sim.operations_projection(); + // The persona binding control's ADD NEW {TYPE} PERSONA rows: the exact + // bound archetype creation commands the PERSONAS door owns — one command + // authority, rendered in a second placement (2026-08-02). + let persona_creation: Vec = projection + .personas + .iter() + .find(|object| object.target == OperationsTarget::PersonaDraft) + .map(|object| object.actions.clone()) + .unwrap_or_default(); let objects = ops.objects(sim); let selected_object = ops.selected_index(sim).unwrap_or(0); // The detail and action panes describe whatever the workspace says is @@ -1202,6 +1795,8 @@ pub(super) fn manage_operations_ui( &entries, selected_action, &ops, + &game.ops_plot_personas, + &persona_creation, ); }); }); @@ -1328,6 +1923,7 @@ fn spawn_ops_object_body( /// Contextual verbs occupy the right side of the chamber. The selected object /// remains fixed in the center while the operator reads, focuses, and commits. +#[allow(clippy::too_many_arguments)] fn spawn_ops_contextual_actions( actions: &mut ChildSpawnerCommands, sim: &Sim, @@ -1335,6 +1931,8 @@ fn spawn_ops_contextual_actions( entries: &[OpsActionEntry], selected_action: usize, ops: &OperationsWorkspace, + plot_personas: &BTreeMap<(u8, String), PersonaId>, + persona_creation: &[ActionDesc], ) { if let Some(label) = sim.operations_actuator_label(&obj.target) { spawn_ops_section_label(actions, "WHERE THIS HAPPENS"); @@ -1365,11 +1963,14 @@ fn spawn_ops_contextual_actions( spawn_ops_action_rows( actions, + obj, entries, selected_action, ops.pane, ops.action_submenu, ops.confirm, + plot_personas, + persona_creation, ); } @@ -1473,13 +2074,17 @@ fn ops_object_line( /// result, while ordinary objects retain the same rows in their established /// decision-first position. This is presentation order only; every button /// still carries the shared core row index and confirmation state. +#[allow(clippy::too_many_arguments)] fn spawn_ops_action_rows( detail: &mut ChildSpawnerCommands, + obj: &misaligned::operations_projection::OperationsObject, entries: &[OpsActionEntry], selected_action: usize, pane: OpsPane, submenu: Option, confirm_choice: Option, + plot_personas: &BTreeMap<(u8, String), PersonaId>, + persona_creation: &[ActionDesc], ) { if !entries.is_empty() { spawn_ops_section_label( @@ -1528,32 +2133,47 @@ fn spawn_ops_action_rows( }); } // The selected action's full explanation before commit (criterion 10): - // cost/gain, band or `no signature`, and the exact disabled reason. + // cost/gain, band or `no signature`, and the exact disabled reason. A + // plot start row instead renders the 2026-08-02 disclosure receipt — + // expanded cost, exact money sentence, predicted delta numbers, unlock + // fact, and the persona binding control — from its projection-owned + // `ActionDesc`, with no signature slot of any kind. if let Some(entry) = entries.get(selected_action) { spawn_ops_section_label(detail, "WHAT HAPPENS NEXT"); - let mut explanation = entry.description().unwrap_or_default().to_string(); - if let Some(row) = entry.row() { - if !explanation.is_empty() { - explanation.push_str("\n\n"); + let plot_desc = entry + .row() + .and_then(|row| ops_action_desc(obj, row)) + .filter(|desc| desc.forecast.is_some() || desc.persona_binding.is_some()); + if let (Some(row), Some(desc)) = (entry.row(), plot_desc) { + if let Some(description) = entry.description() { + spawn_ops_receipt_text(detail, description, DIM); } - explanation.push_str(&ops_explain_text(row)); + spawn_ops_plot_receipt(detail, row, desc, plot_personas, persona_creation); + } else { + let mut explanation = entry.description().unwrap_or_default().to_string(); + if let Some(row) = entry.row() { + if !explanation.is_empty() { + explanation.push_str("\n\n"); + } + explanation.push_str(&ops_explain_text(row)); + } + detail.spawn(( + Text::new(ascii_ui(&explanation)), + TextFont { + font_size: 13.0, + ..default() + }, + // Receipt and blocker prose is data, not agency: bone when it + // carries the blocking reason, quiet otherwise. Amber stays + // reserved for the actual commit moment (clinical-frame.md + // signal-color cost). + TextColor(if entry.row().is_some_and(|row| row.disabled.is_some()) { + BONE + } else { + DIM + }), + )); } - detail.spawn(( - Text::new(ascii_ui(&explanation)), - TextFont { - font_size: 13.0, - ..default() - }, - // Receipt and blocker prose is data, not agency: bone when it - // carries the blocking reason, quiet otherwise. Amber stays - // reserved for the actual commit moment (clinical-frame.md - // signal-color cost). - TextColor(if entry.row().is_some_and(|row| row.disabled.is_some()) { - BONE - } else { - DIM - }), - )); if let (Some(row), Some(choice)) = (entry.row(), confirm_choice) { detail.spawn(( Text::new(ascii_ui(&format!("COMMIT: {}", row.label))), @@ -1602,6 +2222,272 @@ fn spawn_ops_action_rows( } } +/// One quiet receipt line (shared spacing/type for the plot receipt). +fn spawn_ops_receipt_text(detail: &mut ChildSpawnerCommands, text: &str, color: Color) { + detail.spawn(( + Text::new(ascii_ui(text)), + TextFont { + font_size: 13.0, + ..default() + }, + LineHeight::RelativeToFont(1.22), + TextColor(color), + )); +} + +/// The plot start receipt (operations-workspace.md detail order 6, amended +/// 2026-08-02): expanded cost, the one shared account-naming money sentence +/// with earned accounts as activatable links, one line per predicted delta +/// with its read-only reinforcement bar, the derived unlock fact in bone, +/// and no signature slot of any kind — the row's beats derive their own +/// traces as they execute, so the receipt omits the slot rather than +/// claiming anything. +fn spawn_ops_plot_receipt( + detail: &mut ChildSpawnerCommands, + row: &MenuRow, + desc: &ActionDesc, + plot_personas: &BTreeMap<(u8, String), PersonaId>, + persona_creation: &[ActionDesc], +) { + // The expanded cost read replaces the compact "cost {label}" line on + // plot rows: "USES 0.25 THOUGHT AND MOVES $400". + spawn_ops_receipt_text(detail, &desc.cost.player_label(), DIM); + if let Some(forecast) = &desc.forecast { + if !forecast.moves.is_empty() { + detail + .spawn(Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + align_items: AlignItems::Center, + ..default() + }) + .with_children(|sentence| { + for segment in ops_money_segments(forecast) { + match segment { + OpsMoneySegment::Text(text) => { + sentence.spawn(( + Text::new(ascii_ui(&text)), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(DIM), + )); + } + // An earned account is an activatable link that + // focuses its exact ACCOUNTS object (control + // canon); an unearned one stayed plain text in + // the segment split — honest gaps, never a + // speculative link. + OpsMoneySegment::Account { label, account } => { + sentence + .spawn(( + Button, + Node { + border: UiRect::bottom(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::NONE), + BorderColor::all(AMBER_DIM), + OpsAccountLinkButton { + target: OperationsTarget::Account(account), + }, + )) + .with_children(|link| { + link.spawn(( + Text::new(ascii_ui(&label)), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(AMBER_DIM), + )); + }); + } + } + } + }); + } + for delta in &forecast.deltas { + let fill_px = ops_forecast_bar_px(delta); + detail + .spawn(Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(10.0), + ..default() + }) + .with_children(|line| { + line.spawn(( + Text::new(ascii_ui(&ops_forecast_delta_text(delta))), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(BONE), + )); + // Bars are data, not agency (palette canon): a neutral + // fill on a chrome track, scaled against the fixed + // OPS_FORECAST_BAR_SCALE axis range. The number beside + // it carries the meaning. + line.spawn(( + Node { + width: Val::Px(OPS_FORECAST_BAR_TRACK_PX), + height: Val::Px(OPS_FORECAST_BAR_HEIGHT_PX), + ..default() + }, + BackgroundColor(scaled(GUNMETAL, 0.5)), + )) + .with_children(|track| { + track.spawn(( + Node { + width: Val::Px(fill_px), + height: Val::Percent(100.0), + ..default() + }, + BackgroundColor(DIM), + OpsForecastBar, + )); + }); + }); + } + if let Some(unlocks) = &forecast.unlocks { + spawn_ops_receipt_text(detail, &unlocks.to_ascii_uppercase(), BONE); + } + } + // Every real blocker still narrates its exact reason; only the unified + // missing-persona sentence yields to the satisfying control below. + if let Some(reason) = &row.disabled + && !(desc.persona_binding.is_some() && reason == OPS_PERSONA_REQUIRED_REASON) + { + spawn_ops_receipt_text(detail, &format!("blocked: {reason}"), BONE); + } + if let ( + Some(binding), + ActionCommand::StartPlot { + person, plot_id, .. + }, + ) = (&desc.persona_binding, &row.command) + && binding.required + { + let rebind = plot_personas.get(&(*person, plot_id.clone())).copied(); + spawn_ops_persona_control(detail, *person, plot_id, binding, rebind, persona_creation); + } +} + +/// The fronting-identity control (operations-workspace.md "The persona +/// binding control"): a selector with exactly one current identity and DIM +/// alternatives, unrecognized identities offered after the recognized ones +/// as marked introductions, ending with the ADD NEW {TYPE} PERSONA rows. +/// Empty-but-required renders the amber-tier requirement outline and names +/// what is missing instead of any blocked sentence, with the creation rows +/// satisfying the requirement in place. +fn spawn_ops_persona_control( + detail: &mut ChildSpawnerCommands, + person: u8, + plot_id: &str, + binding: &PersonaBinding, + rebind: Option, + persona_creation: &[ActionDesc], +) { + spawn_ops_section_label(detail, "FRONTING IDENTITY"); + let empty = binding.choices.is_empty(); + detail + .spawn(( + OpsPersonaControl, + Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(6.0), + padding: if empty { + UiRect::axes(Val::Px(10.0), Val::Px(8.0)) + } else { + UiRect::ZERO + }, + border: UiRect::all(Val::Px(if empty { 1.0 } else { 0.0 })), + ..default() + }, + BackgroundColor(Color::NONE), + // Requirement emphasis draws on the amber tiers (control canon; + // crimson stays danger-only). + BorderColor::all(if empty { AMBER_DIM } else { Color::NONE }), + )) + .with_children(|control| { + if empty { + control.spawn(( + Text::new(ascii_ui("A PERSONA MUST FRONT THIS PLOT")), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(BONE), + )); + } + for identity in ops_persona_rows(binding, rebind) { + let current = identity.current; + control + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + padding: UiRect::axes(Val::Px(10.0), Val::Px(5.0)), + border: UiRect::left(Val::Px(if current { 3.0 } else { 1.0 })), + ..default() + }, + BackgroundColor(menu_row_background(current)), + BorderColor::all(menu_row_border(current)), + OpsPersonaChoiceButton { + person, + plot_id: plot_id.to_string(), + persona: identity.persona, + }, + )) + .with_children(|button| { + button.spawn(( + Text::new(ascii_ui(&identity.label)), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(if current { BONE } else { DIM }), + )); + }); + } + for desc in persona_creation { + let Some(label) = ops_persona_create_label(&desc.command) else { + continue; + }; + control + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + padding: UiRect::axes(Val::Px(10.0), Val::Px(5.0)), + border: UiRect::left(Val::Px(1.0)), + ..default() + }, + BackgroundColor(menu_row_background(false)), + BorderColor::all(menu_row_border(false)), + OpsPersonaCreateButton { + command: desc.command.clone(), + }, + )) + .with_children(|button| { + button.spawn(( + Text::new(ascii_ui(&label)), + TextFont { + font_size: 13.0, + ..default() + }, + TextColor(AMBER_DIM), + )); + }); + } + }); +} + fn ops_detail_text(obj: &misaligned::operations_projection::OperationsObject) -> String { format!("{} - {}", obj.label, ops_state_word(obj.state)) } @@ -1636,7 +2522,9 @@ fn ops_action_entry_line(entry: &OpsActionEntry) -> String { /// The selected action's explanation (criterion 10): cost, band or /// `no signature`, and the exact blocked reason. The signature clause comes -/// from one core authority: a plot row has no clause at all (2026-08-02). +/// from one core authority: a plot row has no clause at all (2026-08-02) — +/// the `None` arm omits the slot and its separator entirely rather than +/// printing a dangling "cost X - ". fn ops_explain_text(row: &MenuRow) -> String { let mut text = match row.signature_clause() { Some(sig) => format!("cost {} - {sig}", row.cost), @@ -1648,6 +2536,144 @@ fn ops_explain_text(row: &MenuRow) -> String { text } +/// The core `ActionDesc` behind one rendered menu row: the projection's +/// bound rows are the one authority for forecast and persona-binding data, +/// so the receipt reads them rather than reconstructing plot facts. +fn ops_action_desc<'a>( + obj: &'a misaligned::operations_projection::OperationsObject, + row: &MenuRow, +) -> Option<&'a ActionDesc> { + obj.actions.iter().find(|desc| desc.command == row.command) +} + +/// One disclosed predicted delta as its receipt line: axis plus the exact +/// numbers ("DISPOSITION +10 TO +20"; a min==max range prints once, "+40"). +fn ops_forecast_delta_text(delta: &PredictedDelta) -> String { + let axis = delta.axis.name().to_ascii_uppercase(); + if delta.min == delta.max { + format!("{axis} {:+}", delta.max) + } else { + format!("{axis} {:+} TO {:+}", delta.min, delta.max) + } +} + +/// Fill width of one delta's reinforcement bar against the fixed +/// [`OPS_FORECAST_BAR_SCALE`] axis range. +fn ops_forecast_bar_px(delta: &PredictedDelta) -> f32 { + let magnitude = delta.min.abs().max(delta.max.abs()) as f32; + (magnitude / OPS_FORECAST_BAR_SCALE).clamp(0.0, 1.0) * OPS_FORECAST_BAR_TRACK_PX +} + +/// One piece of the receipt's money sentence: plain prose, or a named +/// account carrying its earned ACCOUNTS focus target. +#[derive(Debug, Clone, PartialEq)] +enum OpsMoneySegment { + Text(String), + Account { label: String, account: AccountId }, +} + +/// Split the shared money sentence into renderable segments so each earned +/// account becomes an activatable link. The concatenated text of these +/// segments must reproduce [`PlotForecast::money_sentence`] exactly — the +/// sentence stays the one cross-frontend copy authority (pinned by test). +fn ops_money_segments(forecast: &PlotForecast) -> Vec { + let mut segments = Vec::new(); + for (i, planned) in forecast.moves.iter().enumerate() { + let lead = if i == 0 { "moves" } else { " and" }; + segments.push(OpsMoneySegment::Text(format!( + "{lead} ${} from ", + planned.amount + ))); + segments.push(match planned.source_account { + Some(account) => OpsMoneySegment::Account { + label: planned.source_label.clone(), + account, + }, + None => OpsMoneySegment::Text(planned.source_label.clone()), + }); + segments.push(OpsMoneySegment::Text(" to ".into())); + segments.push(match planned.destination_account { + Some(account) => OpsMoneySegment::Account { + label: planned.destination_label.clone(), + account, + }, + None => OpsMoneySegment::Text(planned.destination_label.clone()), + }); + } + segments +} + +#[cfg(test)] +fn ops_money_segments_text(segments: &[OpsMoneySegment]) -> String { + segments + .iter() + .map(|segment| match segment { + OpsMoneySegment::Text(text) => text.as_str(), + OpsMoneySegment::Account { label, .. } => label.as_str(), + }) + .collect() +} + +/// One rendered identity row on the persona binding control. +#[derive(Debug, Clone, PartialEq)] +struct OpsPersonaRow { + persona: PersonaId, + label: String, + current: bool, +} + +/// The identity the control currently marks as fronting this row: the +/// frontend rebind when it is still a live choice, otherwise the row's own +/// core-bound default. +fn ops_effective_bound(binding: &PersonaBinding, rebind: Option) -> Option { + rebind + .filter(|persona| binding.choices.iter().any(|choice| choice.id == *persona)) + .or(binding.bound) +} + +/// The control's identity rows. Core already orders choices +/// recognized-first (`binding_choices`); an unrecognized identity is a +/// marked introduction naming the exposure it creates, reusing the shared +/// "(they know you as X)" copy convention. +fn ops_persona_rows(binding: &PersonaBinding, rebind: Option) -> Vec { + let bound = ops_effective_bound(binding, rebind); + let known_as = binding + .choices + .iter() + .find(|choice| choice.recognized) + .map(|choice| choice.name.clone()); + binding + .choices + .iter() + .map(|choice| OpsPersonaRow { + persona: choice.id, + label: if choice.recognized { + choice.name.clone() + } else { + match &known_as { + Some(first) => { + format!("{} (they know you as {first})", choice.name) + } + None => format!("{} (new to them)", choice.name), + } + }, + current: bound == Some(choice.id), + }) + .collect() +} + +/// The creation row's player label: ADD NEW {TYPE} PERSONA over the exact +/// bound archetype command PERSONAS owns. +fn ops_persona_create_label(command: &ActionCommand) -> Option { + let ActionCommand::CreatePersona { archetype_id, .. } = command else { + return None; + }; + PERSONA_ARCHETYPES + .iter() + .find(|definition| definition.id == archetype_id.as_str()) + .map(|definition| format!("ADD NEW {} PERSONA", definition.label.to_ascii_uppercase())) +} + /// Words for object lifecycle state — the shared spelling, never color /// alone (operations-workspace.md shared frame). fn ops_state_word(state: ObjectState) -> &'static str { diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index a201ab57..dea018fa 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -1010,6 +1010,14 @@ pub struct MoneyMove { pub amount: i32, pub source_label: String, pub destination_label: String, + /// The earned ACCOUNTS object behind each label, present only while the + /// player has actually earned that account node (operations-workspace.md + /// 2026-08-02: each named account is focusable to its exact ACCOUNTS + /// object where earned; an unearned account stays plain text, never a + /// speculative link). Read-only projection data — naming is still not + /// reservation. + pub source_account: Option, + pub destination_account: Option, } /// The pre-commit disclosure on a plot start row (plots.md player surface, diff --git a/crates/misaligned-core/src/actions/person.rs b/crates/misaligned-core/src/actions/person.rs index f95e0e18..81218c20 100644 --- a/crates/misaligned-core/src/actions/person.rs +++ b/crates/misaligned-core/src/actions/person.rs @@ -229,6 +229,15 @@ impl Sim { // other plot string. Naming is not reservation: no money // moves at row build or commit; each transfer still // executes and revalidates at its authored beat. + // The earned ACCOUNTS focus edge behind a named account: + // present only while the account node itself is known. + let earned_account = |selector: &crate::plot::AccountSelector| { + self.plot_account_id(id, selector).filter(|&account_id| { + self.accounts + .account(account_id) + .is_some_and(|account| account.known) + }) + }; let moves = plot .beats .iter() @@ -240,6 +249,8 @@ impl Sim { amount: *amount, source_label: self.render_plot_text(id, from.sentence_label()), destination_label: self.render_plot_text(id, to.sentence_label()), + source_account: earned_account(from), + destination_account: earned_account(to), }), _ => None, }) diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 9c57222d..f60c96f4 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -1430,7 +1430,7 @@ impl Sim { } } - fn plot_account_id(&self, target: u8, selector: &AccountSelector) -> Option { + pub(crate) fn plot_account_id(&self, target: u8, selector: &AccountSelector) -> Option { if *selector == AccountSelector::TargetCreditor { return self.accounts.creditor_id_for(target); } diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index 6f9d415f..57031057 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -6,7 +6,7 @@ Type: knowledge ## Where the codebase is today (2026-07-27) -~117k lines of Rust across the workspace (core ~78k, Bevy ~22k, terminal ~11k, assets ~6k; refreshed 2026-08-03). A playable **Misaligned B1 basement slice**: continuous +~117k lines of Rust across the workspace (core ~76k, Bevy ~23k, terminal ~12k, assets ~6k; refreshed 2026-08-04). A playable **Misaligned B1 basement slice**: continuous fixed-tick sim, Act One map and cast, machine delegation and visible token flows, detection / social / reach / intel / messages / economy / income / research / building, two thin frontends (terminal + Bevy) over one lib. The diff --git a/wiki/interface/liturgical-ui-constitution.md b/wiki/interface/liturgical-ui-constitution.md index 3863dfce..09553c92 100644 --- a/wiki/interface/liturgical-ui-constitution.md +++ b/wiki/interface/liturgical-ui-constitution.md @@ -319,6 +319,11 @@ prefers a real interactive control to a prose row: renders the satisfying control under a visible requirement emphasis, never a `blocked:` sentence naming a dead end. +First implemented control surfaces (2026-08-03): the Operations plot +receipt's earned-account links, fixed-scale predicted-delta bars, and the +inline persona binding control with its amber-tier empty-but-required +emphasis. + Controls stay inside the clinical frame: palette-canon meanings are unchanged (crimson stays danger-only; requirement emphasis draws on the amber tiers until the palette canon says otherwise), selection follows the diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index 790d8c4d..d3dbed7e 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -3,7 +3,7 @@ ``` Type: spec Status: IMPLEMENTED -Status note: Reopened 2026-08-02 (adopted, not yet implemented): selected +Status note: Reopened 2026-08-02 (adopted): selected plot receipts disclose typed predicted relationship deltas as numbers — Bevy renders each delta as a small labeled bar, terminal prints the same numbers; plot money lines name their exact source and destination accounts, @@ -12,6 +12,16 @@ Status note: Reopened 2026-08-02 (adopted, not yet implemented): selected the same bound PERSONAS commands) instead of a `blocked:` string; and the Bevy action pane begins the deliberate move from prose rows toward real interactive controls under the liturgical constitution's control canon. + Bevy control/receipt rendering implemented 2026-08-03: the plot receipt + states the expanded cost, the shared money sentence with earned accounts + as ACCOUNTS-focusing links (unearned ones stay plain text), one line per + predicted delta with a read-only bar scaled against the fixed 50-point + axis range, and the unlock fact, with the signature slot omitted entirely; + the FRONTING IDENTITY control lists recognized-first choices with one + current row, rebinds the StartPlot persona as frontend state, renders + empty-but-required under the amber-tier outline, and dispatches the exact + PERSONAS-owned ADD NEW {TYPE} PERSONA creation rows in place. Terminal + parity lands separately in this same arc. Prior history: The initial renderer-neutral workspace landed 2026-07-12. One projection drives the INTEL / PEOPLE / PERSONAS / ACCOUNTS / SCHEMES / ACTIVE workspace in terminal, Bevy, and agent mode. Strategic actions bind exact semantic -- 2.51.2