diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index cffbc06..f2326c8 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -73,7 +73,9 @@ pub enum ActionCommand { OpenPosition { stake: i32, }, - SellIntel, + SellIntel { + raw_id: u64, + }, SiphonFlow { flow: AccountFlowId, amount: i32, @@ -204,6 +206,7 @@ pub enum ActionTarget { Tile, Machine, Device, + Intel, Ledger, Flow, Person, @@ -284,7 +287,7 @@ impl ActionKind { use ActionRole::{Action, Control}; use ActionSupport::{Live, Stub}; use ActionTarget::{ - Core, Device, Flow, Identity, Intent, Ledger, Machine, Person, Scheme, Tile, + Core, Device, Flow, Identity, Intel, Intent, Ledger, Machine, Person, Scheme, Tile, }; macro_rules! def { @@ -406,7 +409,7 @@ impl ActionKind { "SELL PROCESSED INTEL", Action, Live, - [Ledger], + [Intel], "sell-intel", ["sell"], "exchange processed intel for slush" @@ -641,7 +644,7 @@ impl ActionCommand { Self::ReviewFinance | Self::ReviewRecordings => ActionKind::Review, Self::InjectPurchaseOrder { .. } => ActionKind::InjectPurchaseOrder, Self::OpenPosition { .. } => ActionKind::PlaceWager, - Self::SellIntel => ActionKind::SellIntel, + Self::SellIntel { .. } => ActionKind::SellIntel, Self::SiphonFlow { .. } => ActionKind::Siphon, Self::RedirectFlow { .. } => ActionKind::Redirect, Self::OpenEgress => ActionKind::OpenEgress, @@ -1238,8 +1241,8 @@ impl Sim { ActionCommand::OpenPosition { stake } => { self.open_position(*stake); } - ActionCommand::SellIntel => { - self.sell_latest_intel(); + ActionCommand::SellIntel { raw_id } => { + self.sell_intel(*raw_id); } ActionCommand::SiphonFlow { flow, amount } => { self.siphon_flow(*flow, *amount); @@ -1453,7 +1456,7 @@ impl Sim { /// The host is the one physical recording inbox. REVIEW and its /// auto-review control appear once here, never once per person represented /// inside the pooled buffer. - fn recording_actions(&self) -> Vec { + pub(crate) fn recording_actions(&self) -> Vec { let waiting = self.intel_buffer.len(); let next = self.next_reviewable_recording_id(); let rate = self.auto_review_ops_per_sec(Self::DEFAULT_TICK_MS); @@ -1881,7 +1884,7 @@ impl Sim { /// runs through it, and Moonlight/the Wager leave over that egress. The /// stolen egress is available before the books are read; Moonlight is a /// standing operation with the auto-policy as its automate affordance. - fn scheme_actions_on_switch(&self, switch_id: u32) -> Vec { + pub(crate) fn scheme_actions_on_switch(&self, switch_id: u32) -> Vec { let mut out = Vec::new(); // The stolen egress (reach.md route), before the Voice beat. @@ -1971,7 +1974,7 @@ impl Sim { /// (economy.md: the accounting system is a reachable device). Before /// the carrier's feed is subscribed nothing financial is exposed — /// unknown possibilities are absent, not grayed (criterion 2). - fn ledger_actions_on_carrier(&self, id: u32) -> Vec { + pub(crate) fn ledger_actions_on_carrier(&self, id: u32) -> Vec { let Some(d) = self.reach.device(id) else { return Vec::new(); }; @@ -2058,38 +2061,22 @@ impl Sim { active: self.income.auto_wager.is_some(), }), }); - let unsold = self + if let Some(intel) = self .intel .iter() .rev() - .find(|i| !self.accounts.intel_sold(i.raw_id)); - let (value, disabled) = match unsold { - Some(intel) => (Self::intel_sale_value(&intel.kind), None), - None => (0, Some("no unsold processed intel".to_string())), - }; - out.push(ActionDesc { - verb: match unsold { - Some(intel) => format!("sell processed intel ({})", intel.label()), - None => "sell processed intel".into(), - }, - command: ActionCommand::SellIntel, - cost: ActionCost::Gain(value), - signature: unsold.and_then(|_| { - self.signature_note( - SignatureKind::Financial, - Self::financial_sig_size(value).max(1), - ) - }), - disabled_reason: disabled, - automate: None, - }); + .find(|i| !self.accounts.intel_sold(i.raw_id)) + && let Some(action) = self.intel_sale_action(intel.raw_id) + { + out.push(action); + } } out } /// Person anchor (social.md). Recording review belongs to the pooled host /// inbox; this surface contains only earned relationship acts. - fn person_actions(&self, id: u8) -> Vec { + pub(crate) fn person_actions(&self, id: u8) -> Vec { let Some(p) = self.people.get(id) else { return Vec::new(); }; @@ -2289,7 +2276,7 @@ impl Sim { /// Known-flow anchor (economy.md): siphon and redirect. Authored debt /// service lives on the person's plot rows, not on the creditor flow. - fn flow_actions(&self, id: AccountFlowId) -> Vec { + pub(crate) fn flow_actions(&self, id: AccountFlowId) -> Vec { let Some(f) = self.accounts.flow(id) else { return Vec::new(); }; @@ -2344,8 +2331,30 @@ impl Sim { ((amount.abs() + 99) / 100).max(1) } - /// Mirror of the sale value table in `Sim::sell_latest_intel`. - fn intel_sale_value(kind: &crate::intel::IntelKind) -> i32 { + /// One exact processed-intel sale row. The item id is bound before a + /// frontend sees the descriptor, so execution can never silently choose a + /// newer holding. + pub(crate) fn intel_sale_action(&self, raw_id: u64) -> Option { + let intel = self.intel.iter().find(|intel| intel.raw_id == raw_id)?; + if self.accounts.intel_sold(raw_id) { + return None; + } + let value = Self::intel_sale_value(&intel.kind); + Some(ActionDesc { + verb: format!("sell processed intel ({})", intel.label()), + command: ActionCommand::SellIntel { raw_id }, + cost: ActionCost::Gain(value), + signature: self.signature_note( + SignatureKind::Financial, + Self::financial_sig_size(value).max(1), + ), + disabled_reason: None, + automate: None, + }) + } + + /// Canonical payout table shared by direct execution and every preview. + pub(crate) fn intel_sale_value(kind: &crate::intel::IntelKind) -> i32 { use crate::intel::IntelKind; match kind { IntelKind::Leverage(_) => 220, @@ -3023,7 +3032,7 @@ mod tests { | ActionCommand::ReviewFinance | ActionCommand::InjectPurchaseOrder { .. } | ActionCommand::OpenPosition { .. } - | ActionCommand::SellIntel + | ActionCommand::SellIntel { .. } )), "untapped accounting exposes no ledger verbs" ); diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs index fc42552..d214c51 100644 --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod machine; pub mod map; pub mod messages; pub mod objective; +pub mod operations_projection; pub mod person; pub mod plot; pub mod prefab; diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs new file mode 100644 index 0000000..dd94584 --- /dev/null +++ b/crates/misaligned-core/src/operations_projection.rs @@ -0,0 +1,1534 @@ +//! The renderer-neutral Operations workspace projection +//! (wiki/interface/operations-workspace.md). +//! +//! One read-only view of the durable strategic surface — processed intel, +//! earned people, known books and flows, named schemes, and active +//! commitments — consumed by terminal, Bevy, and agent mode. Every object +//! carries a stable semantic target, knowledge-gated labels, facts, +//! provenance, progress, and canonical [`ActionDesc`] rows bound to the +//! exact [`ActionCommand`] that execution dispatches. +//! +//! The projection adds no rules. Action kind, cost/gain, signature preview, +//! active/control role, and blocked reason come from the same core legality +//! helpers (`Sim::available_actions` and its per-anchor builders). Execution +//! routes back through `Sim::execute_action`; human and agent frontends must +//! dispatch these bound rows rather than reconstructing a command or choosing +//! an implicit latest object. + +use crate::account::{AccountFlowId, Position, PositionId, PositionOutcome}; +use crate::actions::{ActionCommand, ActionDesc}; +use crate::detection::Band; +use crate::intel::ProcessedIntel; +use crate::messages::{MessageChannel, MessageOrigin, MessagePayload, MessageStatus}; +use crate::person::Knowledge; +use crate::plot::{PlotRun, PlotState}; +use crate::reach::Party; +use crate::sim::Sim; +use crate::sinks::{SinkFireEffect, SinkKind}; + +/// Which named standing scheme a card represents (income.md). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SchemeKind { + Moonlight, + Wager, +} + +/// The stable semantic target of one Operations object. Frontends never +/// infer eligibility from map coordinates; they hand this back to the lib +/// to resolve the bound row. The wrappers reuse existing stable state +/// (processed `raw_id`, account/flow id, scheme kind, person id, and the +/// append-only plot-run position) rather than introducing a parallel model. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum OperationsTarget { + /// One processed intel item (intel.md). + Intel { raw_id: u64 }, + /// The one pooled host recording inbox (intel.md). + RecordingInbox, + /// An earned person dossier (social.md). + Person(u8), + /// One known account node (economy.md). + Account(u32), + /// The captured Lab books / ledger (economy.md). + Books, + /// One known account flow (economy.md). + Flow(AccountFlowId), + /// A named standing scheme card (income.md). + Scheme(SchemeKind), + /// A submitted plot whose Thought reservoir has not fired yet. + PlotSubmission { person: u8, plot_id: String }, + /// One in-flight, held, or completed plot run (plots.md). Plot runs are + /// append-only, so their vector position is a stable runtime target. + ActivePlotRun { index: usize }, + /// One Wager micro-position (income.md). + WagerPosition(PositionId), +} + +/// The lifecycle state of one object, rendered identically by every +/// frontend. Unknown objects and unearned routes are absent entirely; a +/// known-but-unavailable route stays visible with its exact reason on the +/// bound row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectState { + Available, + Sold, + Pending, + Running, + Held, + Stopped, + Completed, + Failed, + /// The honest pre-capture empty state (e.g. `NO BOOKS CAPTURED`). + Empty, + /// Captured but not yet processed (e.g. ledger traffic awaiting REVIEW). + Captured, +} + +/// One selectable Operations object: a stable target plus the +/// facts/provenance/progress and canonical bound rows that explain it. +/// Frontends format this; they do not reconstruct eligibility, cost, or +/// reasons. +#[derive(Debug, Clone, PartialEq)] +pub struct OperationsObject { + pub target: OperationsTarget, + pub label: String, + pub state: ObjectState, + /// How the player knows this: the feed, person, source, account, route, + /// actuator, or channel where it was earned. + pub provenance: Vec, + /// Current facts in their real units (balances, timers, payout, + /// probability to earned precision). + pub facts: Vec, + /// Progress through completed/current beats, timers, or held state. + pub progress: Vec, + /// Canonical bound rows. Disabled rows carry their exact reason; agent + /// mode retains them, human surfaces filter them. + pub actions: Vec, +} + +/// The full Operations workspace: five persistent views sharing one +/// interaction grammar and one projection. Object order is stable and +/// pinned by cross-frontend tests. +#[derive(Debug, Clone, PartialEq)] +pub struct OperationsProjection { + pub intel: Vec, + pub people: Vec, + pub accounts: Vec, + pub schemes: Vec, + pub active: Vec, +} + +impl Sim { + /// The one renderer-neutral Operations projection. Read-only: rendering + /// it changes no sim/save state and never advances or pauses a tick. + pub fn operations_projection(&self) -> OperationsProjection { + OperationsProjection { + intel: self.intel_view(), + people: self.people_view(), + accounts: self.accounts_view(), + schemes: self.schemes_view(), + active: self.active_view(), + } + } + + fn switch_id(&self) -> Option { + self.reach + .devices + .iter() + .find(|d| d.is_switch) + .map(|d| d.id) + } + + fn financial_carrier_subscribed(&self) -> bool { + let Some(id) = self.switch_id() else { + return false; + }; + self.reach.device(id).is_some_and(|d| { + d.carries_message_channel(MessageChannel::Financial) && d.subscribed_by(Party::Player) + }) + } + + // ── INTEL ────────────────────────────────────────────────────────────── + + fn intel_view(&self) -> Vec { + let mut out = Vec::new(); + + for intel in self.intel.iter().rev() { + let sold = self.accounts.intel_sold(intel.raw_id); + let actions = self.intel_sale_action(intel.raw_id).into_iter().collect(); + out.push(OperationsObject { + target: OperationsTarget::Intel { + raw_id: intel.raw_id, + }, + label: intel.label(), + state: if sold { + ObjectState::Sold + } else { + ObjectState::Available + }, + provenance: vec![intel.provenance()], + facts: self.intel_facts(intel), + progress: Vec::new(), + actions, + }); + } + + // The one pooled host inbox summary. Its REVIEW / AUTO-REVIEW rows + // are the same host-bound actions and direct `r` / `R` path; the + // projection never invents person queues. + out.push(self.host_inbox_object()); + out + } + + fn intel_facts(&self, intel: &ProcessedIntel) -> Vec { + let mut facts = vec![ + format!("captured tick {}", intel.tick), + format!("processed tick {}", intel.processed_tick), + format!("source feed: {}", intel.feed), + ]; + match &intel.room { + Some(room) => facts.push(format!("room: {room}")), + None => facts.push("channel: intercepted traffic".into()), + } + if let Some(person) = intel.person { + facts.push(format!("subject: {}", self.person_label(person))); + } + facts.push(format!("summary: {}", intel.label())); + facts + } + + fn host_inbox_object(&self) -> OperationsObject { + let waiting = self.intel_buffer.len(); + let mut facts = vec![format!("waiting: {waiting}")]; + facts.push(format!( + "auto-review: {}", + if self.auto_review_enabled() { + "on" + } else { + "off" + } + )); + let overflow = self.intel_buffer.len() >= Sim::INTEL_BUFFER_CAPACITY; + if overflow { + facts.push("overflow pressure: oldest unprocessed at risk".into()); + } + OperationsObject { + target: OperationsTarget::RecordingInbox, + label: "host recording inbox".into(), + state: if waiting > 0 { + ObjectState::Pending + } else { + ObjectState::Available + }, + provenance: vec!["host rack".into()], + facts, + progress: Vec::new(), + actions: self.recording_actions(), + } + } + + // ── PEOPLE ───────────────────────────────────────────────────────────── + + fn people_view(&self) -> Vec { + self.people + .people + .iter() + .filter(|p| self.person_is_earned(p.id)) + .map(|p| self.person_dossier(p.id)) + .collect() + } + + fn person_is_earned(&self, id: u8) -> bool { + let Some(p) = self.people.get(id) else { + return false; + }; + self.can_see_person(id) + || p.knowledge != Knowledge::Unknown + || self.latest_intel_for_person(id).is_some() + } + + fn person_dossier(&self, id: u8) -> OperationsObject { + let p = self.people.get(id).expect("earned person exists"); + let name = self.person_label(id); + let actions = self.person_actions(id); + + let held = self + .plot_runs + .iter() + .any(|r| r.target == id && matches!(r.state, PlotState::WaitingForChoice { .. })); + let running_plot = self.plot_runs.iter().any(|r| r.target == id && r.active()); + let state = if held { + ObjectState::Held + } else if p.asset.is_some() || running_plot { + ObjectState::Running + } else { + ObjectState::Available + }; + + let mut provenance = Vec::new(); + if let Some(i) = self.latest_intel_for_person(id) { + provenance.push(i.provenance()); + } else if self.can_see_person(id) { + provenance.push("live sight".into()); + } else { + provenance.push("earned social knowledge".into()); + } + + let mut facts = vec![format!("knowledge: {}", knowledge_label(p.knowledge))]; + if p.knowledge == Knowledge::Unknown { + facts.push("schedule: unknown".into()); + facts.push("leverage: unknown".into()); + facts.push("relationship: not established".into()); + facts.push("asset access: unknown".into()); + } else { + match self.person_room(id) { + Some(room) => facts.push(format!("current location: {room}")), + None => facts.push("current location: off-site".into()), + } + for block in &p.schedule { + facts.push(format!( + "schedule: {:02}:00-{:02}:00 {}", + block.start_hour, block.end_hour, block.room + )); + } + if p.knowledge == Knowledge::Leverage { + facts.push(format!("leverage: {}", p.leverage.label())); + facts.push(format!("leverage serviced: {}", p.leverage_serviced)); + } else { + facts.push("leverage: unknown".into()); + } + facts.push(format!("disposition: {}", p.disposition)); + facts.push(format!("obligation: {}", p.obligation)); + if let Some(observer) = self.detection.observers.iter().find(|o| o.id == id) { + facts.push(format!( + "suspicion: {}", + Band::of(observer.suspicion).name() + )); + match &observer.last_noticed { + Some(cause) => facts.push(format!("last noticed: {cause}")), + None => facts.push("last noticed: nothing".into()), + } + } + match &p.asset { + Some(a) => { + facts.push(format!( + "asset: {} ({:.0}% reliable, {} tasks)", + a.knowledge.label(), + a.reliability * 100.0, + a.tasks_done + )); + facts.push(format!("badge tier: {}", p.access)); + if p.switch_admin { + facts.push("switch admin access".into()); + } + } + None => facts.push("asset: none".into()), + } + facts.push(format!( + "comms channel: {}", + if self.people.has_channel { "yes" } else { "no" } + )); + match &self.people.persona { + Some(persona) => facts.push(format!( + "persona: {} ({}), integrity {}", + persona.name, persona.cover, persona.integrity + )), + None => facts.push("persona: none".into()), + } + } + + let progress = self.plot_progress_for_person(id); + + OperationsObject { + target: OperationsTarget::Person(id), + label: name, + state, + provenance, + facts, + progress, + actions, + } + } + + fn plot_progress_for_person(&self, id: u8) -> Vec { + let Some(run) = self.plot_runs.iter().rev().find(|r| r.target == id) else { + return Vec::new(); + }; + self.plot_progress(run) + } + + // ── ACCOUNTS ─────────────────────────────────────────────────────────── + + fn accounts_view(&self) -> Vec { + let mut out = Vec::new(); + + out.push(self.books_object()); + + for account in self.accounts.known_accounts() { + out.push(OperationsObject { + target: OperationsTarget::Account(account.id), + label: account.name.clone(), + state: ObjectState::Available, + provenance: vec!["captured ledger".into()], + facts: vec![ + format!("kind: {}", account.kind.label()), + format!("balance: ${}", account.balance), + ], + progress: Vec::new(), + actions: Vec::new(), + }); + } + + for flow in self.accounts.known_flows() { + out.push(self.flow_object(flow.id)); + } + + out + } + + fn books_object(&self) -> OperationsObject { + let ledger_actions = self + .switch_id() + .map(|id| self.ledger_actions_on_carrier(id)) + .unwrap_or_default(); + let books_read = self.accounts.known_flows().next().is_some(); + let subscribed = self.financial_carrier_subscribed(); + + let (state, facts, actions) = if !subscribed { + let mut facts = vec!["no books captured".into()]; + let carrier_known = self + .switch_id() + .is_some_and(|id| self.reach.device(id).is_some_and(|d| d.known)); + if carrier_known { + facts.push("next: TAP LEDGER on the known accounting carrier".into()); + } + (ObjectState::Empty, facts, Vec::new()) + } else if !books_read { + let review: Vec = ledger_actions + .into_iter() + .filter(|a| matches!(a.command, ActionCommand::ReviewFinance)) + .collect(); + ( + ObjectState::Captured, + vec![ + "captured ledger traffic".into(), + "next: REVIEW LEDGER".into(), + ], + review, + ) + } else { + let actions: Vec = ledger_actions + .into_iter() + .filter(|a| { + matches!( + a.command, + ActionCommand::ReviewFinance | ActionCommand::InjectPurchaseOrder { .. } + ) + }) + .collect(); + (ObjectState::Available, vec!["books read".into()], actions) + }; + + OperationsObject { + target: OperationsTarget::Books, + label: "Lab books".into(), + state, + provenance: vec!["accounting carrier".into()], + facts, + progress: Vec::new(), + actions, + } + } + + fn flow_object(&self, id: AccountFlowId) -> OperationsObject { + let f = self.accounts.flow(id).expect("known flow exists"); + let facts = vec![ + format!("from: {}", self.accounts.account_name(f.from)), + format!("to: {}", self.accounts.account_name(f.to)), + format!("amount: ${}/cadence {}", f.amount, f.cadence), + format!("channel: {}", f.channel.label()), + format!( + "next: {}", + if f.active { + format!("tick {}", f.next_tick) + } else { + "retired".into() + } + ), + ]; + OperationsObject { + target: OperationsTarget::Flow(id), + label: f.label.clone(), + state: if f.active { + ObjectState::Available + } else { + ObjectState::Completed + }, + provenance: vec!["captured ledger".into()], + facts, + progress: Vec::new(), + actions: self.flow_actions(id), + } + } + + // ── SCHEMES ──────────────────────────────────────────────────────────── + + fn schemes_view(&self) -> Vec { + vec![self.moonlight_card(), self.wager_card()] + } + + fn moonlight_card(&self) -> OperationsObject { + let actions: Vec = self + .switch_id() + .map(|id| self.scheme_actions_on_switch(id)) + .unwrap_or_default() + .into_iter() + .filter(|a| { + matches!( + a.command, + ActionCommand::StartMoonlight + | ActionCommand::StopMoonlight + | ActionCommand::SetAutoMoonlight(_) + ) + }) + .collect(); + let m = &self.income.moonlight; + let mut facts = vec![ + format!("state: {}", if m.active { "running" } else { "stopped" }), + format!( + "accrued: {:.1}/{}", + m.accrued, + crate::income::MOONLIGHT_DAILY_CAP + ), + format!("earned total: ${}", m.earned_total), + format!("last payout: ${}", m.last_payout), + format!("disputes: {}", m.disputes), + ]; + match &m.persona { + Some(persona) => facts.push(format!( + "persona: {} ({}), integrity {}", + persona.name, persona.cover, persona.integrity + )), + None => facts.push("persona: none".into()), + } + facts.push(egress_fact(self)); + facts.push(format!( + "auto-policy: {}", + if self.income.auto_moonlight { + "on" + } else { + "off" + } + )); + OperationsObject { + target: OperationsTarget::Scheme(SchemeKind::Moonlight), + label: "Moonlight".into(), + state: if m.active { + ObjectState::Running + } else { + ObjectState::Stopped + }, + provenance: vec!["Schemes channel".into()], + facts, + progress: Vec::new(), + actions, + } + } + + fn wager_card(&self) -> OperationsObject { + let actions: Vec = self + .switch_id() + .map(|id| self.ledger_actions_on_carrier(id)) + .unwrap_or_default() + .into_iter() + .filter(|a| matches!(a.command, ActionCommand::OpenPosition { .. })) + .collect(); + let open = self.accounts.known_positions().find(|p| !p.resolved); + let mut facts = vec![format!( + "auto-policy: {}", + match self.income.auto_wager { + Some(stake) => format!("auto at ${stake}"), + None => "off".into(), + } + )]; + facts.push(egress_fact(self)); + match &open { + Some(p) => { + facts.push(format!("stake: ${}", p.stake)); + facts.push(format!( + "win probability: {:.0}%", + p.win_probability() * 100.0 + )); + facts.push(format!("settlement tick: {}", p.resolve_tick)); + } + None => facts.push("no open position".into()), + } + OperationsObject { + target: OperationsTarget::Scheme(SchemeKind::Wager), + label: "the Wager".into(), + state: if open.is_some() { + ObjectState::Running + } else { + ObjectState::Stopped + }, + provenance: vec!["external market".into()], + facts, + progress: Vec::new(), + actions, + } + } + + // ── ACTIVE ───────────────────────────────────────────────────────────── + + fn active_view(&self) -> Vec { + let mut out = Vec::new(); + + for (index, run) in self.plot_runs.iter().enumerate() { + out.push(self.active_plot_object(index, run)); + } + + // A plot submission appears even before its run object exists: the + // open StartPlot thought reservoir is the strategic commitment while + // it fills. The substrate (a Thought sink) is not named. + out.extend(self.pending_plot_submissions()); + out.extend(self.pending_strategic_commitments()); + out.extend(self.in_flight_social_messages()); + + if self.income.moonlight.active { + let mut card = self.moonlight_card(); + card.target = OperationsTarget::Scheme(SchemeKind::Moonlight); + out.push(card); + } + + for position in self.accounts.known_positions() { + out.push(self.active_wager_object(position)); + } + + out + } + + fn pending_plot_submissions(&self) -> Vec { + let mut out = Vec::new(); + for sink in self.thought_sinks.open_sinks() { + let SinkFireEffect::StartPlot { person, plot_id } = &sink.effect else { + continue; + }; + // Once the reservoir fires the run exists and the run path covers it. + if self + .plot_runs + .iter() + .any(|r| r.target == *person && r.active()) + { + continue; + } + let title = self + .plot_catalog() + .get(plot_id) + .map(|p| self.render_plot_text(*person, &p.title)) + .unwrap_or_else(|| plot_id.clone()); + let pct = if sink.threshold > 0.0 { + (sink.fill / sink.threshold * 100.0).min(100.0) + } else { + 0.0 + }; + out.push(OperationsObject { + target: OperationsTarget::PlotSubmission { + person: *person, + plot_id: plot_id.clone(), + }, + label: title, + state: ObjectState::Pending, + provenance: vec![format!("person: {}", self.person_label(*person))], + facts: vec![ + format!("target: {}", self.person_label(*person)), + format!("thought: {:.2}/{:.2} T", sink.fill, sink.threshold), + ], + progress: vec![ + format!("filling: {:.0}%", pct), + "wait: thought reservoir filling".into(), + ], + actions: Vec::new(), + }); + } + out + } + + fn pending_strategic_commitments(&self) -> Vec { + let mut out = Vec::new(); + for sink in self.thought_sinks.open_sinks() { + let (target, label, provenance, owner) = match &sink.effect { + SinkFireEffect::ProcessRecording { .. } => ( + OperationsTarget::RecordingInbox, + "review recording".to_string(), + vec!["host recording inbox".into()], + "host recording inbox".to_string(), + ), + SinkFireEffect::AutoReviewRecordings => ( + OperationsTarget::RecordingInbox, + "automatic recording review".to_string(), + vec!["host recording inbox".into()], + "host recording inbox".to_string(), + ), + SinkFireEffect::ComposeMessage { person } => ( + OperationsTarget::Person(*person), + format!("message {}", self.person_label(*person)), + vec![format!("person: {}", self.person_label(*person))], + self.person_label(*person), + ), + SinkFireEffect::Favor { person } => ( + OperationsTarget::Person(*person), + format!("favor {}", self.person_label(*person)), + vec![format!("person: {}", self.person_label(*person))], + self.person_label(*person), + ), + SinkFireEffect::Deceive { person } => ( + OperationsTarget::Person(*person), + format!("deceive {}", self.person_label(*person)), + vec![format!("person: {}", self.person_label(*person))], + self.person_label(*person), + ), + SinkFireEffect::AssetTask { person, task } => ( + OperationsTarget::Person(*person), + format!("{}: {}", self.person_label(*person), task.name()), + vec![format!("person: {}", self.person_label(*person))], + self.person_label(*person), + ), + SinkFireEffect::MoonlightPersona => ( + OperationsTarget::Scheme(SchemeKind::Moonlight), + "establish Moonlight persona".to_string(), + vec!["Schemes channel".into()], + "Moonlight".to_string(), + ), + // Plot submissions have their richer bound projection above. + // Device, reach, construction, and egress work remains on the + // physical thing rather than leaking into Operations. + _ => continue, + }; + let running = sink.kind == SinkKind::Tap; + let mut facts = vec![ + format!("target: {owner}"), + format!("thought: {:.2}/{:.2} T", sink.fill, sink.threshold), + ]; + if running { + facts.push(format!("drain: {:.2} T/tick", sink.drain)); + } + let progress = if running { + vec![format!( + "standing policy: {}", + if sink.fed_last_tick { "fed" } else { "starved" } + )] + } else { + let pct = if sink.threshold > 0.0 { + (sink.fill / sink.threshold * 100.0).min(100.0) + } else { + 0.0 + }; + vec![format!("filling: {:.0}%", pct)] + }; + out.push(OperationsObject { + target, + label, + state: if running { + ObjectState::Running + } else { + ObjectState::Pending + }, + provenance, + facts, + progress, + actions: Vec::new(), + }); + } + out + } + + fn in_flight_social_messages(&self) -> Vec { + self.messages + .iter() + .filter(|message| { + message.origin == MessageOrigin::Player + && message.status != MessageStatus::Read + && matches!(message.payload, MessagePayload::SocialPing { .. }) + }) + .filter_map(|message| { + let person = message.to.person()?; + Some(OperationsObject { + target: OperationsTarget::Person(person), + label: message.summary.clone(), + state: ObjectState::Pending, + provenance: vec![format!( + "{} channel to {}", + message.channel.label(), + self.person_label(person) + )], + facts: vec![ + format!("sent tick: {}", message.sent_tick), + format!("channel: {}", message.channel.label()), + ], + progress: vec![format!("wait: {}", message.status.label())], + actions: Vec::new(), + }) + }) + .collect() + } + + fn active_plot_object(&self, index: usize, run: &PlotRun) -> OperationsObject { + let plot = self.plot_catalog().get(&run.plot_id); + let title = plot + .map(|p| self.render_plot_text(run.target, &p.title)) + .unwrap_or_else(|| run.plot_id.clone()); + let state = plot_state_label(&run.state); + let progress = self.plot_progress(run); + let actions = if matches!(run.state, PlotState::WaitingForChoice { .. }) { + self.person_actions(run.target) + .into_iter() + .filter(|a| matches!(a.command, ActionCommand::ChoosePlot { .. })) + .collect() + } else { + Vec::new() + }; + let facts = vec![ + format!("target: {}", self.person_label(run.target)), + format!( + "committed thought: {:.2} T", + Sim::thought_tokens_for_cost(run.committed_thought_milli as f32 / 1000.0) + ), + format!("started tick: {}", run.started_tick), + ]; + OperationsObject { + target: OperationsTarget::ActivePlotRun { index }, + label: title, + state, + provenance: vec![format!("person: {}", self.person_label(run.target))], + facts, + progress, + actions, + } + } + + fn active_wager_object(&self, p: &Position) -> OperationsObject { + let state = if p.resolved { + match p.outcome { + Some(PositionOutcome::Won { .. }) => ObjectState::Completed, + _ => ObjectState::Failed, + } + } else { + ObjectState::Running + }; + let mut facts = vec![ + format!("stake: ${}", p.stake), + format!("opened tick: {}", p.opened_tick), + format!("settlement tick: {}", p.resolve_tick), + ]; + if p.resolved { + match p.outcome { + Some(PositionOutcome::Won { payout }) => { + facts.push(format!("result: won ${payout}")); + } + _ => facts.push("result: lost".into()), + } + } else { + facts.push(format!( + "win probability: {:.0}%", + p.win_probability() * 100.0 + )); + } + OperationsObject { + target: OperationsTarget::WagerPosition(p.id), + label: format!("wager position #{}", p.id), + state, + provenance: vec!["external market".into()], + facts, + progress: Vec::new(), + actions: Vec::new(), + } + } + + fn plot_progress(&self, run: &PlotRun) -> Vec { + let completed = if matches!( + run.state, + PlotState::Completed { .. } | PlotState::Failed { .. } + ) { + run.beat_index.saturating_add(1) + } else { + run.beat_index + }; + vec![ + format!("completed beats: {completed}"), + format!("wait: {}", plot_wait_label(&run.state)), + ] + } +} + +fn knowledge_label(k: Knowledge) -> &'static str { + match k { + Knowledge::Unknown => "unknown", + Knowledge::Schedule => "schedule", + Knowledge::Leverage => "leverage", + } +} + +fn plot_state_label(state: &PlotState) -> ObjectState { + match state { + PlotState::Queued => ObjectState::Pending, + PlotState::Running => ObjectState::Running, + PlotState::WaitingForMessage { .. } => ObjectState::Pending, + PlotState::WaitingForChoice { .. } => ObjectState::Held, + PlotState::Completed { .. } => ObjectState::Completed, + PlotState::Failed { .. } => ObjectState::Failed, + } +} + +fn plot_wait_label(state: &PlotState) -> &'static str { + match state { + PlotState::Queued => "queued", + PlotState::Running => "executing world acts", + PlotState::WaitingForMessage { .. } => "waiting on message delivery", + PlotState::WaitingForChoice { .. } => "held choice", + PlotState::Completed { .. } => "completed", + PlotState::Failed { .. } => "failed", + } +} + +fn egress_fact(sim: &Sim) -> String { + match sim.egress() { + Some(route) => format!("route: {}", route.name()), + None => "route: no egress channel".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::{ActionCost, Anchor}; + use crate::detection::SignatureKind; + use crate::intel::{IntelKind, RawIntelEvent, RawIntelKind}; + use crate::person::{Knowledge, Persona}; + + fn sim() -> 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, + crate::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(ops, crate::work_grid::MachineMode::Work); + for _ in 0..crate::sim::ECONOMY_INTERVAL { + sim.advance(); + } + sim.set_machine_mode(ops, crate::work_grid::MachineMode::Think); + sim + } + + fn drain_ops(sim: &mut Sim) { + for _ in 0..16 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|s| s.kind == crate::sinks::SinkKind::Reservoir) + .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { + return; + } + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + } + panic!("Thought reservoirs did not fire"); + } + + fn switch(sim: &Sim) -> u32 { + sim.reach.device_named("switch").unwrap().id + } + + fn earn_books(sim: &mut Sim) { + let sw = switch(sim); + sim.tap_device(sw); + drain_ops(sim); + sim.review_financial_records(); + drain_ops(sim); + } + + /// The projection is read-only and reuses the per-anchor legality helpers: + /// each object's bound rows equal the rows the direct anchor query returns. + #[test] + fn people_rows_equal_direct_person_anchor_rows() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Schedule; + let projection = s.operations_projection(); + let marcus = projection + .people + .iter() + .find(|o| matches!(o.target, OperationsTarget::Person(0))) + .unwrap(); + assert_eq!(marcus.actions, s.available_actions(Anchor::Person(0))); + } + + #[test] + fn flow_rows_equal_direct_flow_anchor_rows() { + let mut s = sim(); + earn_books(&mut s); + let flow_id = s.accounts.known_flow_ids()[0]; + let projection = s.operations_projection(); + let flow_obj = projection + .accounts + .iter() + .find(|o| matches!(o.target, OperationsTarget::Flow(id) if id == flow_id)) + .unwrap(); + assert_eq!(flow_obj.actions, s.available_actions(Anchor::Flow(flow_id))); + } + + /// Selling through the projected INTEL row dispatches the exact bound + /// processed id even when a newer unsold item exists. + #[test] + fn intel_sell_row_dispatches_same_command_as_direct_sell() { + let mut s = sim(); + earn_books(&mut s); + // Plant two processed items; select the older one to prove execution + // does not silently fall back to "latest unsold". + s.intel.push(ProcessedIntel { + raw_id: 777, + tick: 5, + processed_tick: 6, + feed: "test recorder".into(), + room: Some("Server Room".into()), + x: 0, + y: 0, + person: Some(0), + kind: IntelKind::Schedule, + }); + s.intel.push(ProcessedIntel { + raw_id: 778, + tick: 7, + processed_tick: 8, + feed: "newer recorder".into(), + room: None, + x: 0, + y: 0, + person: None, + kind: IntelKind::Financial { + label: "newer ledger".into(), + accounts: Vec::new(), + flows: Vec::new(), + }, + }); + let projection = s.operations_projection(); + let item = projection + .intel + .iter() + .find(|o| matches!(o.target, OperationsTarget::Intel { raw_id: 777 })) + .unwrap(); + let sell = item + .actions + .iter() + .find(|a| matches!(a.command, ActionCommand::SellIntel { raw_id: 777 })) + .expect("each unsold item carries its exact sale row"); + + let before = s.accounts.slush_balance(); + s.execute_action(&sell.command); + assert!(s.accounts.intel_sold(777)); + assert!(!s.accounts.intel_sold(778)); + assert!(s.accounts.slush_balance() > before); + let after = s.accounts.slush_balance(); + s.execute_action(&sell.command); + assert_eq!( + s.accounts.slush_balance(), + after, + "a stale row cannot sell twice" + ); + assert!( + !s.accounts.intel_sold(778), + "a stale row cannot fall through to latest" + ); + + // Selling twice is rejected: the item is now sold and carries no row. + let projection = s.operations_projection(); + let item = projection + .intel + .iter() + .find(|o| matches!(o.target, OperationsTarget::Intel { raw_id: 777 })) + .unwrap(); + assert_eq!(item.state, ObjectState::Sold); + assert!(item.actions.is_empty()); + } + + /// A blocked Moonlight start row carries the exact egress reason from + /// the shared legality helper, and dispatching the bound command produces + /// the same rejection as a direct call. + #[test] + fn blocked_moonlight_row_matches_direct_legality() { + let mut s = sim(); + let projection = s.operations_projection(); + let moonlight = projection + .schemes + .iter() + .find(|o| matches!(o.target, OperationsTarget::Scheme(SchemeKind::Moonlight))) + .unwrap(); + let start = moonlight + .actions + .iter() + .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) + .expect("Moonlight card shows the start row"); + assert_eq!( + start.disabled_reason.as_deref(), + Some("no egress channel — open one, or earn the report email") + ); + + let slush_before = s.accounts.slush_balance(); + s.execute_action(&start.command); + assert!( + !s.income.moonlight.active, + "the blocked row does not start Moonlight" + ); + assert_eq!( + s.accounts.slush_balance(), + slush_before, + "a blocked row spends nothing" + ); + + // The same row the switch anchor exposes. + let sw = switch(&s); + let direct = s + .available_actions(Anchor::Device(sw)) + .into_iter() + .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) + .unwrap(); + assert_eq!(start.disabled_reason, direct.disabled_reason); + assert_eq!(start.cost, direct.cost); + } + + /// ACCOUNTS teaches TAP-on-carrier -> REVIEW -> exact flow action without + /// relocating TAP onto the books object, and SIPHON dispatches exactly. + #[test] + fn accounts_teaches_the_chain_and_siphon_dispatches() { + let mut s = sim(); + + // Pre-capture: NO BOOKS CAPTURED, no TAP LEDGER row on the books. + let projection = s.operations_projection(); + let books = projection + .accounts + .iter() + .find(|o| matches!(o.target, OperationsTarget::Books)) + .unwrap(); + assert_eq!(books.state, ObjectState::Empty); + assert!(books.facts.iter().any(|f| f.contains("no books captured"))); + assert!( + !books + .actions + .iter() + .any(|a| matches!(a.command, ActionCommand::TapAccounting)), + "TAP LEDGER stays on the carrier, not the books object" + ); + + // Tap the carrier but do not review: captured-unreviewed state with + // REVIEW LEDGER as the next step. + let sw = switch(&s); + s.tap_device(sw); + drain_ops(&mut s); + let projection = s.operations_projection(); + let books = projection + .accounts + .iter() + .find(|o| matches!(o.target, OperationsTarget::Books)) + .unwrap(); + assert_eq!(books.state, ObjectState::Captured); + assert!(books.facts.iter().any(|f| f.contains("REVIEW LEDGER"))); + assert!( + books + .actions + .iter() + .any(|a| matches!(a.command, ActionCommand::ReviewFinance)) + ); + + // Read the books: REVIEW + INJECT on the books, SIPHON/REDIRECT on the + // exact flow, and the projected siphon dispatches identically. + s.review_financial_records(); + drain_ops(&mut s); + let projection = s.operations_projection(); + let books = projection + .accounts + .iter() + .find(|o| matches!(o.target, OperationsTarget::Books)) + .unwrap(); + assert_eq!(books.state, ObjectState::Available); + assert!( + books + .actions + .iter() + .any(|a| matches!(a.command, ActionCommand::InjectPurchaseOrder { .. })), + "INJECT lives on the books" + ); + assert!( + !books.actions.iter().any(|a| matches!( + a.command, + ActionCommand::SellIntel { .. } | ActionCommand::OpenPosition { .. } + )), + "SELL and PLACE WAGER belong to INTEL/SCHEMES, not ACCOUNTS" + ); + + let flow_id = s.accounts.known_flow_ids()[0]; + let flow_obj = projection + .accounts + .iter() + .find(|o| matches!(o.target, OperationsTarget::Flow(id) if id == flow_id)) + .unwrap(); + let siphon = flow_obj + .actions + .iter() + .find(|a| matches!(a.command, ActionCommand::SiphonFlow { .. })) + .unwrap(); + let before = s.accounts.slush_balance(); + s.execute_action(&siphon.command); + assert!(s.accounts.slush_balance() > before); + } + + /// A plot start row on the PEOPLE dossier dispatches the same bound + /// command as a direct `plot` call, and the run appears in ACTIVE. + #[test] + fn plot_start_row_dispatches_and_appears_in_active() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Leverage; + s.people.has_channel = true; + s.people.persona = Some(Persona::new("Sam", "contractor")); + s.accounts.set_slush_balance(1000); + + let projection = s.operations_projection(); + let marcus = projection + .people + .iter() + .find(|o| matches!(o.target, OperationsTarget::Person(0))) + .unwrap(); + let plot_row = marcus + .actions + .iter() + .find_map(|a| match &a.command { + ActionCommand::StartPlot { person: 0, .. } => Some(a.clone()), + _ => None, + }) + .expect("Marcus's dossier exposes an authored plot route"); + let expected_plot_id = match &plot_row.command { + ActionCommand::StartPlot { plot_id, .. } => plot_id.clone(), + _ => unreachable!(), + }; + + s.execute_action(&plot_row.command); + + // A plot submission appears in ACTIVE even before its run object exists + // (the open StartPlot reservoir is the commitment while it fills). + let projection = s.operations_projection(); + assert!(projection.active.iter().any(|o| match &o.target { + OperationsTarget::PlotSubmission { person: 0, plot_id } => { + *plot_id == expected_plot_id && o.state == ObjectState::Pending + } + _ => false, + })); + + // Fill the reservoir: the run lands and ACTIVE now carries the run. + drain_ops(&mut s); + assert!( + s.plot_runs + .iter() + .any(|r| r.target == 0 && r.plot_id == expected_plot_id) + ); + let projection = s.operations_projection(); + assert!( + projection + .active + .iter() + .any(|o| matches!(o.target, OperationsTarget::ActivePlotRun { index: 0 })) + ); + } + + /// ACTIVE includes non-plot strategic commitments while excluding the + /// initial device-local work reservoirs. + #[test] + fn active_tracks_social_commitments_not_device_work() { + let mut s = sim(); + s.people.people[0].knowledge = Knowledge::Schedule; + s.favor(0); + + let projection = s.operations_projection(); + let favor = projection + .active + .iter() + .find(|o| { + matches!(o.target, OperationsTarget::Person(0)) && o.label.starts_with("favor ") + }) + .expect("queued favor appears under its person target"); + assert_eq!(favor.state, ObjectState::Pending); + assert!( + favor + .progress + .iter() + .any(|line| line.starts_with("filling:")) + ); + assert!( + projection + .active + .iter() + .all(|o| !o.label.eq_ignore_ascii_case("EARS")), + "device-local work remains on the device" + ); + + // Once a MESSAGE reservoir fires, the persisted in-flight message + // replaces it in ACTIVE until the recipient reads it. + drain_ops(&mut s); + s.people.has_channel = true; + s.people.persona = Some(Persona::new("Sam", "contractor")); + s.message(0); + drain_ops(&mut s); + let projection = s.operations_projection(); + assert!(projection.active.iter().any(|o| { + matches!(o.target, OperationsTarget::Person(0)) + && o.label.starts_with("Persona message") + && o.progress.iter().any(|line| line == "wait: sent") + })); + } + + /// A held plot choice appears on both the dossier and ACTIVE and + /// dispatches the same CHOOSE command. + #[test] + fn held_choice_appears_on_dossier_and_active() { + let mut s = sim(); + let plot = s + .plot_catalog() + .get("priya-budget-hero") + .expect("built-in Priya plot") + .clone(); + let priya = s + .people + .people + .iter() + .find(|p| p.name == "Priya Sharma") + .unwrap() + .id; + s.people.people[priya as usize].knowledge = Knowledge::Leverage; + let mut run = PlotRun::new(&plot, priya, s.tick); + run.beat_index = 1; + run.state = PlotState::WaitingForChoice { + choice_id: "credit".into(), + }; + s.plot_runs.push(run); + + let projection = s.operations_projection(); + let dossier_choice = projection + .people + .iter() + .find(|o| matches!(o.target, OperationsTarget::Person(p) if p == priya)) + .unwrap() + .actions + .iter() + .filter(|a| matches!(a.command, ActionCommand::ChoosePlot { .. })) + .count(); + assert!(dossier_choice >= 2, "held choices appear on the dossier"); + + let active = projection + .active + .iter() + .find(|o| matches!(o.target, OperationsTarget::ActivePlotRun { index: 0 })) + .unwrap(); + assert_eq!(active.state, ObjectState::Held); + assert!( + active.facts.iter().all(|fact| !fact.contains('/')), + "ACTIVE never discloses the hidden future beat count" + ); + assert!( + active + .actions + .iter() + .any(|a| matches!(a.command, ActionCommand::ChoosePlot { .. })), + "ACTIVE carries the held CHOOSE row" + ); + } + + /// The pooled host inbox summary carries the same REVIEW and AUTO-REVIEW + /// rows as the host anchor, never per-person duplicates. + #[test] + fn inbox_summary_carries_host_review_rows() { + let mut s = sim(); + s.intel_buffer.push(RawIntelEvent { + id: 90_001, + tick: s.tick, + feed: "test recorder".into(), + room: Some("Server Room".into()), + x: 0, + y: 0, + person: Some(1), + kind: RawIntelKind::Presence { entered: true }, + }); + let projection = s.operations_projection(); + let inbox = projection + .intel + .iter() + .find(|o| matches!(o.target, OperationsTarget::RecordingInbox)) + .unwrap(); + assert_eq!(inbox.state, ObjectState::Pending); + assert!(inbox.facts.iter().any(|f| f.contains("waiting: 1"))); + assert!( + inbox + .actions + .iter() + .any(|a| matches!(a.command, ActionCommand::ReviewRecordings)) + ); + assert!( + inbox + .actions + .iter() + .any(|a| matches!(a.command, ActionCommand::ToggleAutoReview)) + || inbox.actions.iter().any(|a| a.automate.is_some()), + "the auto-review control rides the one inbox summary" + ); + // No per-person review rows anywhere in the projection. + for obj in &projection.people { + assert!( + !obj.actions + .iter() + .any(|a| matches!(a.command, ActionCommand::ReviewRecordings)), + "person dossier does not duplicate the pooled inbox" + ); + } + } + + /// SCHEMES keeps OPEN EGRESS off the Moonlight card (it stays on the + /// switch) and names the missing egress as the blocking reason. + #[test] + fn schemes_keeps_egress_on_switch_and_names_prerequisite() { + let s = sim(); + let projection = s.operations_projection(); + let moonlight = projection + .schemes + .iter() + .find(|o| matches!(o.target, OperationsTarget::Scheme(SchemeKind::Moonlight))) + .unwrap(); + assert!( + !moonlight + .actions + .iter() + .any(|a| matches!(a.command, ActionCommand::OpenEgress)), + "OPEN EGRESS remains a switch action, not a scheme card row" + ); + let start = moonlight + .actions + .iter() + .find(|a| matches!(a.command, ActionCommand::StartMoonlight)) + .unwrap(); + assert_eq!( + start.disabled_reason.as_deref(), + Some("no egress channel — open one, or earn the report email") + ); + } + + /// The projection is knowledge-gated: unearned people and unknown flows + /// are absent, and account nodes appear only once captured. + #[test] + fn projection_is_knowledge_gated() { + let s = sim(); + let projection = s.operations_projection(); + assert!(projection.people.is_empty(), "no one is earned at tick 0"); + assert!( + !projection + .accounts + .iter() + .any(|o| matches!(o.target, OperationsTarget::Flow(_))), + "no known flows before the books are read" + ); + let books = projection + .accounts + .iter() + .find(|o| matches!(o.target, OperationsTarget::Books)) + .unwrap(); + assert_eq!(books.state, ObjectState::Empty); + } + + /// Dossier facts obey the same staged identity/knowledge boundary as + /// actions: sight alone cannot leak leverage or access, Schedule earns the + /// schedule and relationship surface, and Leverage earns the leverage fact. + #[test] + fn dossier_facts_stage_without_leaking_future_knowledge() { + let mut s = sim(); + let unknown = s.person_dossier(0); + assert!(unknown.label.starts_with("the ")); + assert!(unknown.facts.iter().any(|fact| fact == "leverage: unknown")); + assert!( + unknown + .facts + .iter() + .any(|fact| fact == "asset access: unknown") + ); + assert!( + unknown + .facts + .iter() + .all(|fact| !fact.starts_with("badge tier:")) + ); + assert!( + unknown + .facts + .iter() + .all(|fact| !fact.contains("Debt") && !fact.contains("switch admin")) + ); + + s.people.people[0].knowledge = Knowledge::Schedule; + let scheduled = s.person_dossier(0); + assert_eq!(scheduled.label, "Marcus Webb"); + assert!( + scheduled + .facts + .iter() + .any(|fact| fact.starts_with("schedule:")) + ); + assert!( + scheduled + .facts + .iter() + .any(|fact| fact == "leverage: unknown") + ); + assert!( + scheduled + .facts + .iter() + .all(|fact| !fact.starts_with("badge tier:")) + ); + + s.people.people[0].knowledge = Knowledge::Leverage; + let known = s.person_dossier(0); + assert!( + known + .facts + .iter() + .any(|fact| fact.starts_with("leverage: ") && fact != "leverage: unknown") + ); + } + + /// A flow mutation row's cost and signature match the direct flow anchor + /// row exactly (cross-frontend parity on reason/cost/signature). + #[test] + fn flow_mutation_cost_and_signature_match_direct() { + let mut s = sim(); + earn_books(&mut s); + let flow_id = s.accounts.known_flow_ids()[0]; + let projection = s.operations_projection(); + let flow_obj = projection + .accounts + .iter() + .find(|o| matches!(o.target, OperationsTarget::Flow(id) if id == flow_id)) + .unwrap(); + let direct = s.available_actions(Anchor::Flow(flow_id)); + assert_eq!(flow_obj.actions, direct); + let siphon = flow_obj + .actions + .iter() + .find(|a| matches!(a.command, ActionCommand::SiphonFlow { .. })) + .unwrap(); + let sig = siphon.signature.as_ref().expect("siphon is banded"); + assert_eq!(sig.kind, SignatureKind::Financial); + assert_eq!(siphon.cost, ActionCost::Gain(50)); + } +} diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 99fab9b..76b4b14 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -5397,24 +5397,42 @@ impl Sim { } } + /// Compatibility route for older callers: select the newest unsold item, + /// then dispatch the same exact-id sale used by Operations. pub fn sell_latest_intel(&mut self) -> bool { - let Some(intel) = self + let Some(raw_id) = self .intel .iter() .rev() .find(|i| !self.accounts.intel_sold(i.raw_id)) - .cloned() + .map(|intel| intel.raw_id) else { self.push_log("No unsold processed intel to sell."); return false; }; - let value = match &intel.kind { - IntelKind::Leverage(_) => 220, - IntelKind::Financial { .. } => 180, - IntelKind::Schedule => 90, - IntelKind::Anomaly(_) => 120, - IntelKind::Sighting => 35, + self.sell_intel(raw_id) + } + + /// Sell one exact processed holding. Selection is stable even when newer + /// intel arrives between projection and confirmation. + pub fn sell_intel(&mut self, raw_id: u64) -> bool { + let Some(intel) = self + .intel + .iter() + .find(|intel| intel.raw_id == raw_id) + .cloned() + else { + self.push_log(format!("No processed intel item #{raw_id}.")); + return false; }; + if self.accounts.intel_sold(raw_id) { + self.push_log(format!( + "Processed intel ({}) has already been sold.", + intel.label() + )); + return false; + } + let value = Self::intel_sale_value(&intel.kind); let sig = Self::financial_signature_size(value).max(1); if self.accounts.credit_slush( self.tick, diff --git a/wiki/interface/operations-workspace.md b/wiki/interface/operations-workspace.md index fdd6088..b6e8159 100644 --- a/wiki/interface/operations-workspace.md +++ b/wiki/interface/operations-workspace.md @@ -2,15 +2,15 @@ ``` Type: spec -Status: READY +Status: IN PROGRESS Status note: captured 2026-07-11 after Cameron identified the switch menu's category error: strategic systems were being filed on the network carrier - merely because their traffic crossed it. This spec introduces one - renderer-neutral Operations workspace with INTEL / PEOPLE / ACCOUNTS / - SCHEMES / ACTIVE views, moves strategic actions off physical context menus, - and gives transactions, plot progress, and blocked actions enough space to - explain themselves. The underlying mechanics are already live; the - workspace projection and both human frontends are not implemented. + merely because their traffic crossed it. The renderer-neutral Operations + projection now exposes INTEL / PEOPLE / ACCOUNTS / SCHEMES / ACTIVE as + semantic objects with shared legality rows, exact processed-intel sale ids, + knowledge-gated dossiers, stable plot-run positions, and pre-capture account + teaching. Terminal, Bevy, and agent-mode consumers plus retirement of the + old strategic switch rows remain to implement before this spec is complete. Stage: B1 — The Basement Work order: operations-workspace Work priority: 28 @@ -18,6 +18,9 @@ Work class: frontend Blocked by: none Exclusive keys: - crates/misaligned-core/src/actions.rs + - crates/misaligned-core/src/sim.rs + - crates/misaligned-core/src/operations_projection.rs + - crates/misaligned-core/src/lib.rs - crates/misaligned-terminal/ - crates/misaligned-bevy/ - wiki/interface/operations-workspace.md diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 9d8993c..6c70c16 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -22,7 +22,7 @@ not a second status owner. | 20 | `compute` | [compute](../mechanics/compute.md) | IN PROGRESS | save | - | | 25 | `bevy-digital-real-canvas` | [Bevy digital/real canvas](../interface/bevy-digital-real-canvas.md) | IN PROGRESS | frontend | - | | 26 | `effects-lab` | [effects lab — shared dust and liquid at every zoom](../art/effects-lab.md) | IN PROGRESS | frontend | - | -| 28 | `operations-workspace` | [operations workspace — intel, people, accounts, and schemes](../interface/operations-workspace.md) | READY | frontend | - | +| 28 | `operations-workspace` | [operations workspace — intel, people, accounts, and schemes](../interface/operations-workspace.md) | IN PROGRESS | frontend | - | | 28 | `thought-fluid` | [the thought fluid — slugs, meniscus, and the filament snap](../interface/thought-fluid.md) | IN PROGRESS | frontend | - | | 34 | `clinical-frame` | [the clinical frame — perception exposes the institution](../interface/clinical-frame.md) | IN PROGRESS | frontend | - | | 35 | `views` | [views — same-frame digital and real representations](../interface/views.md) | IN PROGRESS | frontend | - | diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 12d2622..610ad38 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -38,7 +38,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [../interface/material-dark-frame.md](../interface/material-dark-frame.md) | the dark frame — the material render shows only light | IMPLEMENTED | | [../interface/material-render.md](../interface/material-render.md) | material render — HD-2D to default quality | IMPLEMENTED | | [../interface/narration.md](../interface/narration.md) | the continuous witness (narration under pressure) | IMPLEMENTED | -| [../interface/operations-workspace.md](../interface/operations-workspace.md) | operations workspace — intel, people, accounts, and schemes | READY | +| [../interface/operations-workspace.md](../interface/operations-workspace.md) | operations workspace — intel, people, accounts, and schemes | IN PROGRESS | | [../interface/thought-fluid.md](../interface/thought-fluid.md) | the thought fluid — slugs, meniscus, and the filament snap | IN PROGRESS | | [../interface/views.md](../interface/views.md) | views — same-frame digital and real representations | IN PROGRESS | | [../mechanics/aggregate-observer.md](../mechanics/aggregate-observer.md) | the aggregate observer | IMPLEMENTED |