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