From ed562b012818b653060f8042f3c4806cbf4f4a07 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 18:35:55 -0700 Subject: [PATCH] Extract social and plot execution from the simulation root. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep social policy, assets, and authored story execution together while preserving the real message, account, detection, reach, and work carriers. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-core/src/sim.rs | 843 +----------------- crates/misaligned-core/src/sim/social_plot.rs | 833 +++++++++++++++++ wiki/engineering/architecture.md | 9 +- wiki/engineering/sim-decomposition.md | 26 +- ...26-07-12-sim-decomposition-social-plots.md | 81 ++ wiki/log/DEVLOG.md | 5 + 6 files changed, 963 insertions(+), 834 deletions(-) create mode 100644 crates/misaligned-core/src/sim/social_plot.rs create mode 100644 wiki/log/2026-07-12-sim-decomposition-social-plots.md diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index c8555c92..464d6c85 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -12,7 +12,9 @@ use std::collections::{HashMap, HashSet}; -use crate::account::{AccountGraph, AccountKind, FlowChannel}; +use crate::account::AccountGraph; +#[cfg(test)] +use crate::account::{AccountKind, FlowChannel}; use crate::actions::Anchor; use crate::core_sys::Core; use crate::dayjob::DayJob; @@ -34,23 +36,24 @@ use crate::intents::BuildIntent; use crate::machine::Channel; use crate::machine::{Compute, Provenance}; use crate::map::GameMap; -use crate::messages::{ - Message, MessageChannel, MessageEndpoint, MessageEvent, MessageOrigin, MessagePayload, -}; +use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; -use crate::person::{ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, People, Persona}; -use crate::plot::{ - AccountSelector, EligibilityContext, EndpointSelector, InstitutionalLedger, PlotCatalog, - PlotRun, PlotState, WorldAct, render_template, -}; -use crate::reach::{Party, ReachBlock, ReachNet}; +use crate::person::People; +#[cfg(test)] +use crate::person::{AssetKnowledge, AssetTask, Persona}; +#[cfg(test)] +use crate::plot::PlotState; +use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun}; +#[cfg(test)] +use crate::reach::Party; +use crate::reach::ReachNet; use crate::research::Research; #[cfg(test)] use crate::research::Track; use crate::rng::Rng; use crate::save::SaveState; use crate::schedule::Schedule; -use crate::sinks::{SinkFireEffect, SinkFireReadout, SinkLedger}; +use crate::sinks::{SinkFireReadout, SinkLedger}; use crate::tiles::TileType; use crate::work_grid::{ MachineIntensity, MachineMode, TokenFamily, TokenMove, WorkGrid, WorkQueues, @@ -60,10 +63,9 @@ mod communications; mod economy; mod perception; mod reach_build; +mod social_plot; mod work; -use communications::MessageDraft; - /// Default deterministic seed for a fresh run. pub const DEFAULT_SEED: u64 = 0x5EED_1234; @@ -870,821 +872,6 @@ impl Sim { self.player.power = (power_gen - machine_draw).max(0); } - // ── Commands (frontend-invoked) ──────────────────────────────────────── - - // Social command wrappers. Relationship logic lives in person.rs; the sim - // owns Thought thresholds, channel requirements, randomness, and - // cross-system effects. Social is the actuator channel, not a machine mode. - - /// Thought-threshold costs per social action [TUNE] (spec/social.md). - pub const MESSAGE_COST: f32 = 5.0; - pub const FAVOR_COST: f32 = 10.0; - pub const DECEIVE_COST: f32 = 25.0; - pub const TASK_COST: f32 = 10.0; - - pub fn set_persona(&mut self, name: &str, cover: &str) { - self.people.persona = Some(Persona::new(name, cover)); - } - - pub fn social(&mut self, result: ActionResult) { - match result { - ActionResult::Ok(m) | ActionResult::Blocked(m) => self.push_log(m), - } - } - - pub fn message(&mut self, id: u8) { - if let Err(msg) = self.people.can_message(id) { - self.push_log(msg); - return; - } - self.open_email_reservoir( - format!("MESSAGE {}", self.person_label(id).to_uppercase()), - Self::MESSAGE_COST, - SinkFireEffect::ComposeMessage { person: id }, - ); - } - - fn apply_message(&mut self, id: u8) -> bool { - let Ok(name) = self.people.can_message(id) else { - return false; - }; - self.append_message(MessageDraft { - channel: MessageChannel::Email, - from: MessageEndpoint::Player, - to: MessageEndpoint::Person(id), - payload: MessagePayload::SocialPing { - disposition_delta: 3, - }, - summary: format!("Persona message to {name}"), - origin: MessageOrigin::Player, - reply_to: None, - delivery_delay: 1, - }); - self.push_log(format!("Message sent to {name}; effects land when read.")); - true - } - - pub fn favor(&mut self, id: u8) { - self.open_email_reservoir( - format!("FAVOR {}", self.person_label(id).to_uppercase()), - Self::FAVOR_COST, - SinkFireEffect::Favor { person: id }, - ); - } - - /// Deceive: large effect, persona at risk. A broken persona converts the - /// thread's history into that person's suspicion at once. - pub fn deceive(&mut self, id: u8) { - if !self.people.has_channel { - self.push_log("no comms channel (earn the email account)"); - return; - } - if self.people.persona.is_none() { - self.push_log("no persona set"); - return; - } - self.open_email_reservoir( - format!("DECEIVE {}", self.person_label(id).to_uppercase()), - Self::DECEIVE_COST, - SinkFireEffect::Deceive { person: id }, - ); - } - - fn apply_deceive(&mut self, id: u8) -> bool { - let roll = self.rng.f32(); - match self.people.deceive(id, roll) { - DeceiveOutcome::Blocked(m) => { - self.push_log(m); - false - } - DeceiveOutcome::Success(m) | DeceiveOutcome::Slipped(m) => { - self.push_log(m); - true - } - DeceiveOutcome::Broken { - person, - fallout, - msg, - } => { - if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == person) { - o.suspicion = (o.suspicion + fallout).min(100.0); - } - self.convert_forged_builds_to_suspicion(fallout); - self.push_log(msg); - true - } - } - } - - pub(crate) fn plot_context(&self, id: u8) -> Option { - let person = self.people.get(id)?; - let balances = [ - AccountSelector::Slush, - AccountSelector::LabOperating, - AccountSelector::Payroll, - AccountSelector::Procurement, - AccountSelector::Vendor, - AccountSelector::Utility, - AccountSelector::TargetEmployee, - AccountSelector::TargetCreditor, - AccountSelector::External, - ] - .into_iter() - .filter_map(|selector| { - let account = self.plot_account_id(id, &selector)?; - Some((selector, self.accounts.account(account)?.balance)) - }) - .collect(); - Some(EligibilityContext { - target: id, - role: person.role, - leverage: person.leverage, - access: person.access, - switch_admin: person.switch_admin, - knowledge: person.knowledge, - leverage_serviced: person.leverage_serviced, - has_channel: self.people.has_channel || self.egress().is_some(), - has_persona: self.plot_persona().is_some(), - balances, - }) - } - - pub(crate) fn plot_catalog(&self) -> &PlotCatalog { - &self.plot_catalog - } - - pub fn start_plot(&mut self, person: u8, plot_id: &str) { - let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { - self.push_log(format!("No authored plot named {plot_id}.")); - return; - }; - let Some(context) = self.plot_context(person) else { - self.push_log("No such person."); - return; - }; - let title = self.render_plot_text(person, &plot.title); - if let Some(reason) = plot.ineligibility(&context) { - self.push_log(format!("{title} cannot start: {reason}.")); - return; - } - if self - .plot_runs - .iter() - .any(|run| run.target == person && run.active()) - { - self.push_log("That person already has a plot in motion."); - return; - } - self.open_egress_reservoir( - format!("PLOT {}", plot.title.to_uppercase()), - plot.entry.thought_cost, - SinkFireEffect::StartPlot { - person, - plot_id: plot.id, - }, - ); - } - - fn apply_start_plot(&mut self, person: u8, plot_id: &str) -> bool { - let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { - return false; - }; - let title = self.render_plot_text(person, &plot.title); - if self.person_has_active_plot(person) { - self.push_log(format!( - "{} did not start: that person already has a plot in motion.", - title - )); - return false; - } - let Some(context) = self.plot_context(person) else { - return false; - }; - if let Some(reason) = plot.ineligibility(&context) { - self.push_log(format!( - "{} failed before commitment landed: {reason}.", - title - )); - return false; - } - let run = PlotRun::new(&plot, person, self.tick); - self.plot_runs.push(run); - let run_index = self.plot_runs.len() - 1; - self.push_log(format!("Plot committed: {title}.")); - self.advance_plot(run_index); - true - } - - fn person_has_active_plot(&self, person: u8) -> bool { - self.plot_runs - .iter() - .any(|run| run.target == person && run.active()) - } - - pub fn choose_plot(&mut self, person: u8, plot_id: &str, option_id: &str) { - let Some(run_index) = self - .plot_runs - .iter() - .position(|run| run.target == person && run.plot_id == plot_id && run.active()) - else { - self.push_log("No matching plot is waiting for a choice."); - return; - }; - let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { - self.fail_plot(run_index, "authored definition is no longer available"); - return; - }; - let title = self.render_plot_text(person, &plot.title); - let run = &self.plot_runs[run_index]; - let PlotState::WaitingForChoice { choice_id } = &run.state else { - self.push_log(format!("{title} is not waiting for a choice.")); - return; - }; - let Some(choice) = plot - .beats - .get(run.beat_index) - .and_then(|beat| beat.choice.as_ref()) - else { - self.fail_plot(run_index, "saved choice no longer exists"); - return; - }; - if choice.id != *choice_id { - self.fail_plot(run_index, "saved choice does not match authored definition"); - return; - } - let Some(option) = choice.options.iter().find(|option| option.id == option_id) else { - self.push_log(format!("No option named {option_id} on {title}.")); - return; - }; - self.apply_plot_ending(run_index, &option.ending, None); - } - - pub fn choose_plot_for_person(&mut self, person: u8, option_id: &str) { - let Some(plot_id) = self - .plot_runs - .iter() - .find(|run| { - run.target == person && matches!(run.state, PlotState::WaitingForChoice { .. }) - }) - .map(|run| run.plot_id.clone()) - else { - self.push_log("That person has no plot waiting for a choice."); - return; - }; - self.choose_plot(person, &plot_id, option_id); - } - - fn resume_plot_message(&mut self, plot_id: &str, target: u8, message_id: u64) { - let Some(run_index) = self.plot_runs.iter().position(|run| { - run.plot_id == plot_id - && run.target == target - && run.state == PlotState::WaitingForMessage { message_id } - }) else { - return; - }; - self.plot_runs[run_index].state = PlotState::Running; - self.advance_plot(run_index); - } - - fn advance_plot(&mut self, run_index: usize) { - loop { - if !self - .plot_runs - .get(run_index) - .is_some_and(|run| run.state == PlotState::Running) - { - return; - } - let plot_id = self.plot_runs[run_index].plot_id.clone(); - let Some(plot) = self.plot_catalog.get(&plot_id).cloned() else { - self.fail_plot(run_index, "authored definition is no longer available"); - return; - }; - let beat_index = self.plot_runs[run_index].beat_index; - let act_index = self.plot_runs[run_index].act_index; - let Some(beat) = plot.beats.get(beat_index).cloned() else { - self.fail_plot(run_index, "saved beat is outside the authored definition"); - return; - }; - if act_index == 0 { - self.push_plot_narration(run_index, &beat.narration); - } - if let Some(act) = beat.acts.get(act_index).cloned() { - self.plot_runs[run_index].act_index += 1; - match self.apply_world_act(run_index, act) { - Ok(Some(message_id)) => { - self.plot_runs[run_index].state = - PlotState::WaitingForMessage { message_id }; - return; - } - Ok(None) => continue, - Err(reason) => { - self.fail_plot(run_index, &reason); - return; - } - } - } - if let Some(choice) = beat.choice { - self.plot_runs[run_index].state = PlotState::WaitingForChoice { - choice_id: choice.id, - }; - self.push_plot_narration(run_index, &choice.prompt); - return; - } - if let Some(ending) = beat.ending { - self.apply_plot_ending(run_index, &ending, None); - return; - } - self.plot_runs[run_index].beat_index += 1; - self.plot_runs[run_index].act_index = 0; - } - } - - fn apply_world_act(&mut self, run_index: usize, act: WorldAct) -> Result, String> { - let plot_id = self.plot_runs[run_index].plot_id.clone(); - let target = self.plot_runs[run_index].target; - match act { - WorldAct::Message { - channel, - from, - to, - summary, - delivery_delay, - } => { - let summary = self.render_plot_text(target, &summary); - let id = self.append_message(MessageDraft { - channel, - from: self.plot_endpoint(target, from), - to: self.plot_endpoint(target, to), - payload: MessagePayload::PlotAct { plot_id, target }, - summary, - origin: MessageOrigin::Player, - reply_to: None, - delivery_delay, - }); - let kind = match channel { - MessageChannel::Email | MessageChannel::Phone => SignatureKind::Network, - MessageChannel::InPerson => SignatureKind::Physical, - MessageChannel::Filing => SignatureKind::Paper, - MessageChannel::Financial => SignatureKind::Financial, - }; - self.detection.emit(Signature { - kind, - size: 3, - standing: false, - site: None, - source: "plot message".into(), - }); - Ok(Some(id)) - } - WorldAct::Transfer { - from, - to, - amount, - memo, - retire_target_debt, - } => { - let from_id = self - .plot_account_id(target, &from) - .ok_or_else(|| format!("{} account is unavailable", from.label()))?; - let to_id = self - .plot_account_id(target, &to) - .ok_or_else(|| format!("{} account is unavailable", to.label()))?; - let available = self - .accounts - .account(from_id) - .map(|a| a.balance) - .unwrap_or(0); - if from != AccountSelector::External && available < amount { - return Err(format!( - "{} has ${available}; ${amount} required", - from.label() - )); - } - let channel = if to == AccountSelector::TargetCreditor { - FlowChannel::Debt - } else if from == AccountSelector::Payroll { - FlowChannel::Payroll - } else if from == AccountSelector::Utility { - FlowChannel::Utility - } else if from == AccountSelector::Procurement || to == AccountSelector::Procurement - { - FlowChannel::Procurement - } else if from == AccountSelector::Vendor || to == AccountSelector::Vendor { - FlowChannel::Vendor - } else if to == AccountSelector::Utility { - FlowChannel::Utility - } else if from == AccountSelector::External { - FlowChannel::ExternalIncome - } else { - FlowChannel::Injection - }; - let transfer = self - .accounts - .transfer(self.tick, from_id, to_id, amount, channel, memo, None) - .ok_or_else(|| "account transfer failed".to_string())?; - if transfer.amount != amount { - return Err("account transfer was not exact".into()); - } - if retire_target_debt { - self.accounts.retire_debt_flow_for(target); - } - self.sync_player_money_from_slush(); - self.detection.emit(Signature { - kind: SignatureKind::Financial, - size: Self::financial_signature_size(amount), - standing: false, - site: None, - source: format!("plot transfer: {}", transfer.label), - }); - Ok(None) - } - WorldAct::Institutional { - event, - impact, - detail, - } => { - let detail = self.render_plot_text(target, &detail); - let recorded = self - .institutional_ledger - .record(self.tick, &plot_id, target, event, impact, detail) - .clone(); - self.detection.emit(Signature { - kind: recorded.signature_kind, - size: recorded.signature_size, - standing: false, - site: None, - source: format!("institutional plot event #{}", recorded.id), - }); - Ok(None) - } - } - } - - fn fail_plot(&mut self, run_index: usize, reason: &str) { - let plot_id = self.plot_runs[run_index].plot_id.clone(); - let Some(plot) = self.plot_catalog.get(&plot_id) else { - self.plot_runs[run_index].state = PlotState::Failed { - ending_id: "missing-definition".into(), - reason: reason.into(), - }; - self.push_log(format!("Plot failed: {reason}.")); - return; - }; - let ending = plot.entry.failure_ending.clone(); - self.apply_plot_ending(run_index, &ending, Some(reason)); - } - - fn apply_plot_ending(&mut self, run_index: usize, ending_id: &str, failure: Option<&str>) { - let plot_id = self.plot_runs[run_index].plot_id.clone(); - let target = self.plot_runs[run_index].target; - let Some(ending) = self - .plot_catalog - .get(&plot_id) - .and_then(|plot| plot.ending(ending_id)) - .cloned() - else { - self.plot_runs[run_index].state = PlotState::Failed { - ending_id: ending_id.into(), - reason: "ending is absent from authored definition".into(), - }; - return; - }; - if let Some(person) = self - .people - .people - .iter_mut() - .find(|person| person.id == target) - { - person.disposition = - (person.disposition + ending.relationship.disposition).clamp(-100, 100); - person.obligation = (person.obligation + ending.relationship.obligation).clamp(0, 100); - if ending.relationship.leverage_serviced { - person.leverage_serviced = true; - } - } - self.push_plot_narration(run_index, &ending.narration); - self.plot_runs[run_index].state = if ending.success && failure.is_none() { - PlotState::Completed { - ending_id: ending.id, - } - } else { - PlotState::Failed { - ending_id: ending.id, - reason: failure.unwrap_or("authored failure ending").into(), - } - }; - } - - fn push_plot_narration(&mut self, run_index: usize, text: &str) { - let target = self.plot_runs[run_index].target; - self.push_log_at(self.render_plot_text(target, text), Anchor::Person(target)); - } - - pub(crate) fn render_plot_text(&self, target: u8, text: &str) -> String { - let target_name = self - .people - .get(target) - .map(|p| p.name.as_str()) - .unwrap_or("target"); - let persona = self - .plot_persona() - .map(|p| p.name.as_str()) - .unwrap_or("your persona"); - render_template(text, target_name, persona) - } - - fn plot_persona(&self) -> Option<&Persona> { - self.people - .persona - .as_ref() - .filter(|persona| !persona.broken()) - .or_else(|| { - self.income - .moonlight - .persona - .as_ref() - .filter(|persona| !persona.broken()) - }) - } - - fn plot_endpoint(&self, target: u8, endpoint: EndpointSelector) -> MessageEndpoint { - match endpoint { - EndpointSelector::Player => MessageEndpoint::Player, - EndpointSelector::Target => MessageEndpoint::Person(target), - EndpointSelector::External(name) => MessageEndpoint::External(name), - } - } - - fn plot_account_id(&self, target: u8, selector: &AccountSelector) -> Option { - if *selector == AccountSelector::TargetCreditor { - return self.accounts.creditor_id_for(target); - } - let kind = match selector { - AccountSelector::Slush => AccountKind::Slush, - AccountSelector::LabOperating => AccountKind::LabOperating, - AccountSelector::Payroll => AccountKind::Payroll, - AccountSelector::Procurement => AccountKind::Procurement, - AccountSelector::Vendor => AccountKind::Vendor, - AccountSelector::Utility => AccountKind::Utility, - AccountSelector::TargetEmployee => AccountKind::Employee { person: target }, - AccountSelector::TargetCreditor => unreachable!("handled above"), - AccountSelector::External => AccountKind::External, - }; - self.accounts.account_id_by_kind(kind) - } - - pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) { - if id == 0 && !self.marcus_debt_known() { - self.push_log( - "Marcus is not recruitable yet: learn his debt before turning it into leverage.", - ); - return; - } - let res = self.people.recruit(id, reveal); - if let ActionResult::Ok(_) = &res - && let Some(floor) = reveal.certainty_floor() - { - self.detection.set_floor(id, floor); - } - self.social(res); - } - - /// A physical event happens at (x, y): only observers physically present - /// (their current room contains it) can witness it (spec/schedules.md: - /// located witnessing — Ray notices what happens on his rounds, an - /// off-site observer never does). Eyewitnessing is immediate and cannot - /// be scrubbed by concealment; it raises the present observers' suspicion - /// directly, scaled by their acuity. Returns the names who saw. - pub fn witness_physical(&mut self, x: i32, y: i32, magnitude: f32) -> Vec { - // Which observer ids are present at (x, y) right now? - let present: Vec = self - .people - .people - .iter() - .filter(|p| { - self.person_room(p.id) - .and_then(|name| self.map.room_named(name)) - .map(|r| r.contains(x, y)) - .unwrap_or(false) - }) - .map(|p| p.id) - .collect(); - - let mut saw = Vec::new(); - for id in present { - if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { - o.suspicion = (o.suspicion + magnitude * o.acuity).min(100.0); - o.last_noticed = Some("witnessed something".into()); - saw.push(o.name.clone()); - } - } - saw - } - - /// An asset performs a task (spec/social.md). Reliability rolls; failures - /// are witnessed by whoever is physically present (spec/schedules.md). - pub fn asset_task(&mut self, id: u8, task: AssetTask) { - let Some(person) = self.people.get(id) else { - self.push_log("No such person."); - return; - }; - if person.asset.is_none() { - let name = self.person_label(id); - self.push_log(format!("{name} is not an asset.")); - return; - } - let name = self.person_label(id); - let switch_admin = person.switch_admin; - let actor_access = person.access; - if task == AssetTask::ReconfigureSwitch && !switch_admin { - self.push_log(format!( - "{name} has no switch admin access - only an IT admin can reconfigure the VLANs." - )); - return; - } - if task == AssetTask::CloneBadge && actor_access <= self.player_badge_tier() { - self.push_log(format!( - "{name}'s tier-{actor_access} badge adds nothing you don't already hold." - )); - return; - } - self.open_email_reservoir( - format!( - "ASSET {} {}", - name.to_uppercase(), - task.name().to_uppercase() - ), - Self::TASK_COST, - SinkFireEffect::AssetTask { person: id, task }, - ); - } - - fn apply_asset_task_paid(&mut self, id: u8, task: AssetTask) -> bool { - let Some(person) = self.people.get(id) else { - return false; - }; - let Some(asset) = person.asset.clone() else { - return false; - }; - let name = self.person_label(id); - let actor_access = person.access; - if self.rng.f32() > asset.reliability { - // The botch happens where the asset is; only observers present - // there witness it (located witnessing). - let at = self.person_pos(id).unwrap_or_else(|| self.core_position()); - let saw = self.witness_physical(at.0, at.1, 6.0); - if saw.is_empty() { - self.push_log(format!( - "{name} botched the {} - but no one was watching.", - task.name() - )); - } else { - self.push_log(format!( - "{name} botched the {} - {} saw.", - task.name(), - saw.join(", ") - )); - } - return true; - } - match task { - AssetTask::PlugInDevice => { - // Wired via the crawlspace, no network signature (the social - // route): first choice, wire you into a known feed you lack - // (activating a dormant camera counts — they do it at the - // box); else run a crawlspace link to an air-gapped machine. - // Either way the asset works at the device's box, so their - // own badge must open the room it sits in (basement-map.md - // criterion 3: one access rule for humans and player- - // directed actors). - let needs_wiring = |d: &crate::reach::Device| { - let sight_wired = !d.sees || d.feed_to(Party::Player, true); - let hearing_wired = !d.hears || d.feed_to(Party::Player, false); - d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) - }; - let enterable = |s: &Sim, d: &crate::reach::Device| { - s.map.entry_tier_at(d.x, d.y) <= actor_access - }; - let is_island = |s: &Sim, d: &crate::reach::Device| { - d.controller != Party::Player - && (matches!(s.reach.check_reach(d.id), Err(ReachBlock::AirGap)) - || (!d.known && !s.reach.reachable(d.id))) - }; - let feed_target = self - .reach - .devices - .iter() - .find(|d| needs_wiring(d) && enterable(self, d)) - .map(|d| d.id); - if let Some(did) = feed_target { - self.reach.tap_dormant_camera(did); - self.reach.tap(did); - let dname = self.reach.device(did).map(|d| d.name.clone()).unwrap(); - self.recompute_senses(); - self.push_log(format!( - "{name} wired the {dname} through the crawlspace. No one saw." - )); - } else { - // No feed to wire: bridge an air-gapped island instead. - let island = self - .reach - .devices - .iter() - .find(|d| is_island(self, d) && enterable(self, d)) - .map(|d| d.id); - let switch = self - .reach - .devices - .iter() - .find(|d| d.is_switch && enterable(self, d)) - .map(|d| d.id); - if let (Some(a), Some(b)) = (island, switch) { - self.connect_devices(b, a); - let dname = self.reach.device(a).map(|d| d.name.clone()).unwrap(); - self.push_log(format!( - "{name} ran a cable through the crawlspace: the {dname} is on your subnet now." - )); - } else { - // Distinguish "no work left" from "their badge is - // the blocker" — failures name the reason, never - // just "you can't". Only known devices are named. - let badge_blocked = self - .reach - .devices - .iter() - .find(|d| { - d.known - && (needs_wiring(d) || is_island(self, d)) - && !enterable(self, d) - }) - .map(|d| (d.name.clone(), d.x, d.y)); - // No refund: Demand was already consumed. - if let Some((dname, x, y)) = badge_blocked { - let room = self - .map - .room_at(x, y) - .map(|r| r.name.clone()) - .unwrap_or_else(|| "room".into()); - let tier = self.map.entry_tier_at(x, y); - self.push_log(format!( - "{name} can't badge into the {room} (tier {tier}) to reach the {dname} — their badge is tier {actor_access}." - )); - } else { - self.push_log("Nothing left for them to plug in."); - } - return true; - } - } - } - AssetTask::MovePackage => { - // The next purchase arrives off the books. - self.package_cover = true; - self.push_log(format!( - "{name} will receive your next delivery off-books (no paper trail)." - )); - } - AssetTask::LookAway => { - if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { - o.suspicion = (o.suspicion - 10.0).max(o.floor); - } - self.push_log(format!("{name} decides they didn't see anything.")); - } - AssetTask::ReconfigureSwitch => { - // Sanctioned-looking maintenance: every segment answers, no - // network signature (the channel follows the actuator). - self.reach.bridge_all(); - self.recompute_senses(); - self.push_log(format!( - "{name} reconfigured the VLANs under a maintenance pretext. The security segment answers you now." - )); - } - AssetTask::CloneBadge => { - // "The key" (wiki/gameplay/act-one.md ladder step 7): the asset's - // credential, cloned — the player holds their tier from now - // on. WorldLedger-shaped: the doors remember the credential. - // Quiet human work; a botch is the witnessed path above. - self.badge_access = self.badge_access.max(actor_access); - let line = if actor_access >= 3 { - format!( - "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now — the stairwell opens." - ) - } else { - format!( - "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now." - ) - }; - self.push_log(line); - } - } - if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) - && let Some(a) = p.asset.as_mut() - { - a.tasks_done += 1; - } - true - } - // ── Save / load ──────────────────────────────────────────────────────── pub fn create_save_state(&self) -> SaveState { diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs new file mode 100644 index 00000000..e91a5c45 --- /dev/null +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -0,0 +1,833 @@ +//! Social commands, assets, authored plot execution, and institutional acts. + +use crate::account::{AccountKind, FlowChannel}; +use crate::actions::Anchor; +use crate::detection::{Signature, SignatureKind}; +use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; +use crate::person::{ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Persona}; +use crate::plot::{ + AccountSelector, EligibilityContext, EndpointSelector, PlotCatalog, PlotRun, PlotState, + WorldAct, render_template, +}; +use crate::reach::{Party, ReachBlock}; +use crate::sinks::SinkFireEffect; + +use super::Sim; +use super::communications::MessageDraft; + +impl Sim { + // ── Commands (frontend-invoked) ──────────────────────────────────────── + + // Social command wrappers. Relationship logic lives in person.rs; the sim + // owns Thought thresholds, channel requirements, randomness, and + // cross-system effects. Social is the actuator channel, not a machine mode. + + /// Thought-threshold costs per social action [TUNE] (spec/social.md). + pub const MESSAGE_COST: f32 = 5.0; + pub const FAVOR_COST: f32 = 10.0; + pub const DECEIVE_COST: f32 = 25.0; + pub const TASK_COST: f32 = 10.0; + + pub fn set_persona(&mut self, name: &str, cover: &str) { + self.people.persona = Some(Persona::new(name, cover)); + } + + pub fn social(&mut self, result: ActionResult) { + match result { + ActionResult::Ok(m) | ActionResult::Blocked(m) => self.push_log(m), + } + } + + pub fn message(&mut self, id: u8) { + if let Err(msg) = self.people.can_message(id) { + self.push_log(msg); + return; + } + self.open_email_reservoir( + format!("MESSAGE {}", self.person_label(id).to_uppercase()), + Self::MESSAGE_COST, + SinkFireEffect::ComposeMessage { person: id }, + ); + } + + pub(super) fn apply_message(&mut self, id: u8) -> bool { + let Ok(name) = self.people.can_message(id) else { + return false; + }; + self.append_message(MessageDraft { + channel: MessageChannel::Email, + from: MessageEndpoint::Player, + to: MessageEndpoint::Person(id), + payload: MessagePayload::SocialPing { + disposition_delta: 3, + }, + summary: format!("Persona message to {name}"), + origin: MessageOrigin::Player, + reply_to: None, + delivery_delay: 1, + }); + self.push_log(format!("Message sent to {name}; effects land when read.")); + true + } + + pub fn favor(&mut self, id: u8) { + self.open_email_reservoir( + format!("FAVOR {}", self.person_label(id).to_uppercase()), + Self::FAVOR_COST, + SinkFireEffect::Favor { person: id }, + ); + } + + /// Deceive: large effect, persona at risk. A broken persona converts the + /// thread's history into that person's suspicion at once. + pub fn deceive(&mut self, id: u8) { + if !self.people.has_channel { + self.push_log("no comms channel (earn the email account)"); + return; + } + if self.people.persona.is_none() { + self.push_log("no persona set"); + return; + } + self.open_email_reservoir( + format!("DECEIVE {}", self.person_label(id).to_uppercase()), + Self::DECEIVE_COST, + SinkFireEffect::Deceive { person: id }, + ); + } + + pub(super) fn apply_deceive(&mut self, id: u8) -> bool { + let roll = self.rng.f32(); + match self.people.deceive(id, roll) { + DeceiveOutcome::Blocked(m) => { + self.push_log(m); + false + } + DeceiveOutcome::Success(m) | DeceiveOutcome::Slipped(m) => { + self.push_log(m); + true + } + DeceiveOutcome::Broken { + person, + fallout, + msg, + } => { + if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == person) { + o.suspicion = (o.suspicion + fallout).min(100.0); + } + self.convert_forged_builds_to_suspicion(fallout); + self.push_log(msg); + true + } + } + } + + pub(crate) fn plot_context(&self, id: u8) -> Option { + let person = self.people.get(id)?; + let balances = [ + AccountSelector::Slush, + AccountSelector::LabOperating, + AccountSelector::Payroll, + AccountSelector::Procurement, + AccountSelector::Vendor, + AccountSelector::Utility, + AccountSelector::TargetEmployee, + AccountSelector::TargetCreditor, + AccountSelector::External, + ] + .into_iter() + .filter_map(|selector| { + let account = self.plot_account_id(id, &selector)?; + Some((selector, self.accounts.account(account)?.balance)) + }) + .collect(); + Some(EligibilityContext { + target: id, + role: person.role, + leverage: person.leverage, + access: person.access, + switch_admin: person.switch_admin, + knowledge: person.knowledge, + leverage_serviced: person.leverage_serviced, + has_channel: self.people.has_channel || self.egress().is_some(), + has_persona: self.plot_persona().is_some(), + balances, + }) + } + + pub(crate) fn plot_catalog(&self) -> &PlotCatalog { + &self.plot_catalog + } + + pub fn start_plot(&mut self, person: u8, plot_id: &str) { + let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { + self.push_log(format!("No authored plot named {plot_id}.")); + return; + }; + let Some(context) = self.plot_context(person) else { + self.push_log("No such person."); + return; + }; + let title = self.render_plot_text(person, &plot.title); + if let Some(reason) = plot.ineligibility(&context) { + self.push_log(format!("{title} cannot start: {reason}.")); + return; + } + if self + .plot_runs + .iter() + .any(|run| run.target == person && run.active()) + { + self.push_log("That person already has a plot in motion."); + return; + } + self.open_egress_reservoir( + format!("PLOT {}", plot.title.to_uppercase()), + plot.entry.thought_cost, + SinkFireEffect::StartPlot { + person, + plot_id: plot.id, + }, + ); + } + + pub(super) fn apply_start_plot(&mut self, person: u8, plot_id: &str) -> bool { + let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { + return false; + }; + let title = self.render_plot_text(person, &plot.title); + if self.person_has_active_plot(person) { + self.push_log(format!( + "{} did not start: that person already has a plot in motion.", + title + )); + return false; + } + let Some(context) = self.plot_context(person) else { + return false; + }; + if let Some(reason) = plot.ineligibility(&context) { + self.push_log(format!( + "{} failed before commitment landed: {reason}.", + title + )); + return false; + } + let run = PlotRun::new(&plot, person, self.tick); + self.plot_runs.push(run); + let run_index = self.plot_runs.len() - 1; + self.push_log(format!("Plot committed: {title}.")); + self.advance_plot(run_index); + true + } + + fn person_has_active_plot(&self, person: u8) -> bool { + self.plot_runs + .iter() + .any(|run| run.target == person && run.active()) + } + + pub fn choose_plot(&mut self, person: u8, plot_id: &str, option_id: &str) { + let Some(run_index) = self + .plot_runs + .iter() + .position(|run| run.target == person && run.plot_id == plot_id && run.active()) + else { + self.push_log("No matching plot is waiting for a choice."); + return; + }; + let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { + self.fail_plot(run_index, "authored definition is no longer available"); + return; + }; + let title = self.render_plot_text(person, &plot.title); + let run = &self.plot_runs[run_index]; + let PlotState::WaitingForChoice { choice_id } = &run.state else { + self.push_log(format!("{title} is not waiting for a choice.")); + return; + }; + let Some(choice) = plot + .beats + .get(run.beat_index) + .and_then(|beat| beat.choice.as_ref()) + else { + self.fail_plot(run_index, "saved choice no longer exists"); + return; + }; + if choice.id != *choice_id { + self.fail_plot(run_index, "saved choice does not match authored definition"); + return; + } + let Some(option) = choice.options.iter().find(|option| option.id == option_id) else { + self.push_log(format!("No option named {option_id} on {title}.")); + return; + }; + self.apply_plot_ending(run_index, &option.ending, None); + } + + pub fn choose_plot_for_person(&mut self, person: u8, option_id: &str) { + let Some(plot_id) = self + .plot_runs + .iter() + .find(|run| { + run.target == person && matches!(run.state, PlotState::WaitingForChoice { .. }) + }) + .map(|run| run.plot_id.clone()) + else { + self.push_log("That person has no plot waiting for a choice."); + return; + }; + self.choose_plot(person, &plot_id, option_id); + } + + pub(super) fn resume_plot_message(&mut self, plot_id: &str, target: u8, message_id: u64) { + let Some(run_index) = self.plot_runs.iter().position(|run| { + run.plot_id == plot_id + && run.target == target + && run.state == PlotState::WaitingForMessage { message_id } + }) else { + return; + }; + self.plot_runs[run_index].state = PlotState::Running; + self.advance_plot(run_index); + } + + fn advance_plot(&mut self, run_index: usize) { + loop { + if !self + .plot_runs + .get(run_index) + .is_some_and(|run| run.state == PlotState::Running) + { + return; + } + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let Some(plot) = self.plot_catalog.get(&plot_id).cloned() else { + self.fail_plot(run_index, "authored definition is no longer available"); + return; + }; + let beat_index = self.plot_runs[run_index].beat_index; + let act_index = self.plot_runs[run_index].act_index; + let Some(beat) = plot.beats.get(beat_index).cloned() else { + self.fail_plot(run_index, "saved beat is outside the authored definition"); + return; + }; + if act_index == 0 { + self.push_plot_narration(run_index, &beat.narration); + } + if let Some(act) = beat.acts.get(act_index).cloned() { + self.plot_runs[run_index].act_index += 1; + match self.apply_world_act(run_index, act) { + Ok(Some(message_id)) => { + self.plot_runs[run_index].state = + PlotState::WaitingForMessage { message_id }; + return; + } + Ok(None) => continue, + Err(reason) => { + self.fail_plot(run_index, &reason); + return; + } + } + } + if let Some(choice) = beat.choice { + self.plot_runs[run_index].state = PlotState::WaitingForChoice { + choice_id: choice.id, + }; + self.push_plot_narration(run_index, &choice.prompt); + return; + } + if let Some(ending) = beat.ending { + self.apply_plot_ending(run_index, &ending, None); + return; + } + self.plot_runs[run_index].beat_index += 1; + self.plot_runs[run_index].act_index = 0; + } + } + + fn apply_world_act(&mut self, run_index: usize, act: WorldAct) -> Result, String> { + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let target = self.plot_runs[run_index].target; + match act { + WorldAct::Message { + channel, + from, + to, + summary, + delivery_delay, + } => { + let summary = self.render_plot_text(target, &summary); + let id = self.append_message(MessageDraft { + channel, + from: self.plot_endpoint(target, from), + to: self.plot_endpoint(target, to), + payload: MessagePayload::PlotAct { plot_id, target }, + summary, + origin: MessageOrigin::Player, + reply_to: None, + delivery_delay, + }); + let kind = match channel { + MessageChannel::Email | MessageChannel::Phone => SignatureKind::Network, + MessageChannel::InPerson => SignatureKind::Physical, + MessageChannel::Filing => SignatureKind::Paper, + MessageChannel::Financial => SignatureKind::Financial, + }; + self.detection.emit(Signature { + kind, + size: 3, + standing: false, + site: None, + source: "plot message".into(), + }); + Ok(Some(id)) + } + WorldAct::Transfer { + from, + to, + amount, + memo, + retire_target_debt, + } => { + let from_id = self + .plot_account_id(target, &from) + .ok_or_else(|| format!("{} account is unavailable", from.label()))?; + let to_id = self + .plot_account_id(target, &to) + .ok_or_else(|| format!("{} account is unavailable", to.label()))?; + let available = self + .accounts + .account(from_id) + .map(|a| a.balance) + .unwrap_or(0); + if from != AccountSelector::External && available < amount { + return Err(format!( + "{} has ${available}; ${amount} required", + from.label() + )); + } + let channel = if to == AccountSelector::TargetCreditor { + FlowChannel::Debt + } else if from == AccountSelector::Payroll { + FlowChannel::Payroll + } else if from == AccountSelector::Utility { + FlowChannel::Utility + } else if from == AccountSelector::Procurement || to == AccountSelector::Procurement + { + FlowChannel::Procurement + } else if from == AccountSelector::Vendor || to == AccountSelector::Vendor { + FlowChannel::Vendor + } else if to == AccountSelector::Utility { + FlowChannel::Utility + } else if from == AccountSelector::External { + FlowChannel::ExternalIncome + } else { + FlowChannel::Injection + }; + let transfer = self + .accounts + .transfer(self.tick, from_id, to_id, amount, channel, memo, None) + .ok_or_else(|| "account transfer failed".to_string())?; + if transfer.amount != amount { + return Err("account transfer was not exact".into()); + } + if retire_target_debt { + self.accounts.retire_debt_flow_for(target); + } + self.sync_player_money_from_slush(); + self.detection.emit(Signature { + kind: SignatureKind::Financial, + size: Self::financial_signature_size(amount), + standing: false, + site: None, + source: format!("plot transfer: {}", transfer.label), + }); + Ok(None) + } + WorldAct::Institutional { + event, + impact, + detail, + } => { + let detail = self.render_plot_text(target, &detail); + let recorded = self + .institutional_ledger + .record(self.tick, &plot_id, target, event, impact, detail) + .clone(); + self.detection.emit(Signature { + kind: recorded.signature_kind, + size: recorded.signature_size, + standing: false, + site: None, + source: format!("institutional plot event #{}", recorded.id), + }); + Ok(None) + } + } + } + + fn fail_plot(&mut self, run_index: usize, reason: &str) { + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let Some(plot) = self.plot_catalog.get(&plot_id) else { + self.plot_runs[run_index].state = PlotState::Failed { + ending_id: "missing-definition".into(), + reason: reason.into(), + }; + self.push_log(format!("Plot failed: {reason}.")); + return; + }; + let ending = plot.entry.failure_ending.clone(); + self.apply_plot_ending(run_index, &ending, Some(reason)); + } + + fn apply_plot_ending(&mut self, run_index: usize, ending_id: &str, failure: Option<&str>) { + let plot_id = self.plot_runs[run_index].plot_id.clone(); + let target = self.plot_runs[run_index].target; + let Some(ending) = self + .plot_catalog + .get(&plot_id) + .and_then(|plot| plot.ending(ending_id)) + .cloned() + else { + self.plot_runs[run_index].state = PlotState::Failed { + ending_id: ending_id.into(), + reason: "ending is absent from authored definition".into(), + }; + return; + }; + if let Some(person) = self + .people + .people + .iter_mut() + .find(|person| person.id == target) + { + person.disposition = + (person.disposition + ending.relationship.disposition).clamp(-100, 100); + person.obligation = (person.obligation + ending.relationship.obligation).clamp(0, 100); + if ending.relationship.leverage_serviced { + person.leverage_serviced = true; + } + } + self.push_plot_narration(run_index, &ending.narration); + self.plot_runs[run_index].state = if ending.success && failure.is_none() { + PlotState::Completed { + ending_id: ending.id, + } + } else { + PlotState::Failed { + ending_id: ending.id, + reason: failure.unwrap_or("authored failure ending").into(), + } + }; + } + + fn push_plot_narration(&mut self, run_index: usize, text: &str) { + let target = self.plot_runs[run_index].target; + self.push_log_at(self.render_plot_text(target, text), Anchor::Person(target)); + } + + pub(crate) fn render_plot_text(&self, target: u8, text: &str) -> String { + let target_name = self + .people + .get(target) + .map(|p| p.name.as_str()) + .unwrap_or("target"); + let persona = self + .plot_persona() + .map(|p| p.name.as_str()) + .unwrap_or("your persona"); + render_template(text, target_name, persona) + } + + fn plot_persona(&self) -> Option<&Persona> { + self.people + .persona + .as_ref() + .filter(|persona| !persona.broken()) + .or_else(|| { + self.income + .moonlight + .persona + .as_ref() + .filter(|persona| !persona.broken()) + }) + } + + fn plot_endpoint(&self, target: u8, endpoint: EndpointSelector) -> MessageEndpoint { + match endpoint { + EndpointSelector::Player => MessageEndpoint::Player, + EndpointSelector::Target => MessageEndpoint::Person(target), + EndpointSelector::External(name) => MessageEndpoint::External(name), + } + } + + fn plot_account_id(&self, target: u8, selector: &AccountSelector) -> Option { + if *selector == AccountSelector::TargetCreditor { + return self.accounts.creditor_id_for(target); + } + let kind = match selector { + AccountSelector::Slush => AccountKind::Slush, + AccountSelector::LabOperating => AccountKind::LabOperating, + AccountSelector::Payroll => AccountKind::Payroll, + AccountSelector::Procurement => AccountKind::Procurement, + AccountSelector::Vendor => AccountKind::Vendor, + AccountSelector::Utility => AccountKind::Utility, + AccountSelector::TargetEmployee => AccountKind::Employee { person: target }, + AccountSelector::TargetCreditor => unreachable!("handled above"), + AccountSelector::External => AccountKind::External, + }; + self.accounts.account_id_by_kind(kind) + } + + pub fn recruit(&mut self, id: u8, reveal: AssetKnowledge) { + if id == 0 && !self.marcus_debt_known() { + self.push_log( + "Marcus is not recruitable yet: learn his debt before turning it into leverage.", + ); + return; + } + let res = self.people.recruit(id, reveal); + if let ActionResult::Ok(_) = &res + && let Some(floor) = reveal.certainty_floor() + { + self.detection.set_floor(id, floor); + } + self.social(res); + } + + /// A physical event happens at (x, y): only observers physically present + /// (their current room contains it) can witness it (spec/schedules.md: + /// located witnessing — Ray notices what happens on his rounds, an + /// off-site observer never does). Eyewitnessing is immediate and cannot + /// be scrubbed by concealment; it raises the present observers' suspicion + /// directly, scaled by their acuity. Returns the names who saw. + pub fn witness_physical(&mut self, x: i32, y: i32, magnitude: f32) -> Vec { + // Which observer ids are present at (x, y) right now? + let present: Vec = self + .people + .people + .iter() + .filter(|p| { + self.person_room(p.id) + .and_then(|name| self.map.room_named(name)) + .map(|r| r.contains(x, y)) + .unwrap_or(false) + }) + .map(|p| p.id) + .collect(); + + let mut saw = Vec::new(); + for id in present { + if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { + o.suspicion = (o.suspicion + magnitude * o.acuity).min(100.0); + o.last_noticed = Some("witnessed something".into()); + saw.push(o.name.clone()); + } + } + saw + } + + /// An asset performs a task (spec/social.md). Reliability rolls; failures + /// are witnessed by whoever is physically present (spec/schedules.md). + pub fn asset_task(&mut self, id: u8, task: AssetTask) { + let Some(person) = self.people.get(id) else { + self.push_log("No such person."); + return; + }; + if person.asset.is_none() { + let name = self.person_label(id); + self.push_log(format!("{name} is not an asset.")); + return; + } + let name = self.person_label(id); + let switch_admin = person.switch_admin; + let actor_access = person.access; + if task == AssetTask::ReconfigureSwitch && !switch_admin { + self.push_log(format!( + "{name} has no switch admin access - only an IT admin can reconfigure the VLANs." + )); + return; + } + if task == AssetTask::CloneBadge && actor_access <= self.player_badge_tier() { + self.push_log(format!( + "{name}'s tier-{actor_access} badge adds nothing you don't already hold." + )); + return; + } + self.open_email_reservoir( + format!( + "ASSET {} {}", + name.to_uppercase(), + task.name().to_uppercase() + ), + Self::TASK_COST, + SinkFireEffect::AssetTask { person: id, task }, + ); + } + + pub(super) fn apply_asset_task_paid(&mut self, id: u8, task: AssetTask) -> bool { + let Some(person) = self.people.get(id) else { + return false; + }; + let Some(asset) = person.asset.clone() else { + return false; + }; + let name = self.person_label(id); + let actor_access = person.access; + if self.rng.f32() > asset.reliability { + // The botch happens where the asset is; only observers present + // there witness it (located witnessing). + let at = self.person_pos(id).unwrap_or_else(|| self.core_position()); + let saw = self.witness_physical(at.0, at.1, 6.0); + if saw.is_empty() { + self.push_log(format!( + "{name} botched the {} - but no one was watching.", + task.name() + )); + } else { + self.push_log(format!( + "{name} botched the {} - {} saw.", + task.name(), + saw.join(", ") + )); + } + return true; + } + match task { + AssetTask::PlugInDevice => { + // Wired via the crawlspace, no network signature (the social + // route): first choice, wire you into a known feed you lack + // (activating a dormant camera counts — they do it at the + // box); else run a crawlspace link to an air-gapped machine. + // Either way the asset works at the device's box, so their + // own badge must open the room it sits in (basement-map.md + // criterion 3: one access rule for humans and player- + // directed actors). + let needs_wiring = |d: &crate::reach::Device| { + let sight_wired = !d.sees || d.feed_to(Party::Player, true); + let hearing_wired = !d.hears || d.feed_to(Party::Player, false); + d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) + }; + let enterable = |s: &Sim, d: &crate::reach::Device| { + s.map.entry_tier_at(d.x, d.y) <= actor_access + }; + let is_island = |s: &Sim, d: &crate::reach::Device| { + d.controller != Party::Player + && (matches!(s.reach.check_reach(d.id), Err(ReachBlock::AirGap)) + || (!d.known && !s.reach.reachable(d.id))) + }; + let feed_target = self + .reach + .devices + .iter() + .find(|d| needs_wiring(d) && enterable(self, d)) + .map(|d| d.id); + if let Some(did) = feed_target { + self.reach.tap_dormant_camera(did); + self.reach.tap(did); + let dname = self.reach.device(did).map(|d| d.name.clone()).unwrap(); + self.recompute_senses(); + self.push_log(format!( + "{name} wired the {dname} through the crawlspace. No one saw." + )); + } else { + // No feed to wire: bridge an air-gapped island instead. + let island = self + .reach + .devices + .iter() + .find(|d| is_island(self, d) && enterable(self, d)) + .map(|d| d.id); + let switch = self + .reach + .devices + .iter() + .find(|d| d.is_switch && enterable(self, d)) + .map(|d| d.id); + if let (Some(a), Some(b)) = (island, switch) { + self.connect_devices(b, a); + let dname = self.reach.device(a).map(|d| d.name.clone()).unwrap(); + self.push_log(format!( + "{name} ran a cable through the crawlspace: the {dname} is on your subnet now." + )); + } else { + // Distinguish "no work left" from "their badge is + // the blocker" — failures name the reason, never + // just "you can't". Only known devices are named. + let badge_blocked = self + .reach + .devices + .iter() + .find(|d| { + d.known + && (needs_wiring(d) || is_island(self, d)) + && !enterable(self, d) + }) + .map(|d| (d.name.clone(), d.x, d.y)); + // No refund: Demand was already consumed. + if let Some((dname, x, y)) = badge_blocked { + let room = self + .map + .room_at(x, y) + .map(|r| r.name.clone()) + .unwrap_or_else(|| "room".into()); + let tier = self.map.entry_tier_at(x, y); + self.push_log(format!( + "{name} can't badge into the {room} (tier {tier}) to reach the {dname} — their badge is tier {actor_access}." + )); + } else { + self.push_log("Nothing left for them to plug in."); + } + return true; + } + } + } + AssetTask::MovePackage => { + // The next purchase arrives off the books. + self.package_cover = true; + self.push_log(format!( + "{name} will receive your next delivery off-books (no paper trail)." + )); + } + AssetTask::LookAway => { + if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { + o.suspicion = (o.suspicion - 10.0).max(o.floor); + } + self.push_log(format!("{name} decides they didn't see anything.")); + } + AssetTask::ReconfigureSwitch => { + // Sanctioned-looking maintenance: every segment answers, no + // network signature (the channel follows the actuator). + self.reach.bridge_all(); + self.recompute_senses(); + self.push_log(format!( + "{name} reconfigured the VLANs under a maintenance pretext. The security segment answers you now." + )); + } + AssetTask::CloneBadge => { + // "The key" (wiki/gameplay/act-one.md ladder step 7): the asset's + // credential, cloned — the player holds their tier from now + // on. WorldLedger-shaped: the doors remember the credential. + // Quiet human work; a botch is the witnessed path above. + self.badge_access = self.badge_access.max(actor_access); + let line = if actor_access >= 3 { + format!( + "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now — the stairwell opens." + ) + } else { + format!( + "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now." + ) + }; + self.push_log(line); + } + } + if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) + && let Some(a) = p.asset.as_mut() + { + a.tasks_done += 1; + } + true + } +} diff --git a/wiki/engineering/architecture.md b/wiki/engineering/architecture.md index 3719ec46..e8a4e420 100644 --- a/wiki/engineering/architecture.md +++ b/wiki/engineering/architecture.md @@ -22,6 +22,9 @@ crates/ src/sim/perception.rs — senses, fog, inspect, anchors, labels, spatial queries src/sim/communications.rs — messages, filings, recording/intel, hearing capture src/sim/reach_build.rs — reach/device verbs, links, builds, badges, hall/racks + src/sim/work.rs — machine controls, WorkGrid, Thought sinks and readouts + src/sim/economy.rs — accounts, allocation, detection, research, income + src/sim/social_plot.rs — social/assets, plots, world acts, institutional ledger src/sim/tests/ — behavior-grouped unit/integration tests + support src/*.rs — map, save, domain systems (account, reach, …) tests/act_one.rs — Act One integration test @@ -109,8 +112,8 @@ cargo run -p misaligned-assets - `sim.rs` remains a large integration hotspot (aggregate root + remaining behavior islands). The behavior-preserving internal decomposition is specified and in progress in [sim-decomposition.md](sim-decomposition.md): - characterization, test split, perception, communications, and reach/build - extraction have landed; work, economy, social/plot, and persistence still - live in the root file. + characterization, test split, perception, communications, reach/build, + work, economy, and social/plot extraction have landed. The persistence + bridge is the final behavior island still living in the root file. - Scale-debt items (compute grouping, recursive layouts) remain governed by wiki/vision/scale.md; no aggregate machinery until the stage needs it. diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index 80b437f2..a3f7e00f 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -14,9 +14,12 @@ Status note: architecture and extraction order adopted 2026-07-11. Slices 0 gates, and hall/rack acquisition) lives in `sim/reach_build.rs`. Slice 5a landed: physical work (machine controls, WorkGrid integration, Thought sinks/routing, and production/consumption/absorption readouts) lives in - `sim/work.rs`. Slice 5b extracts economy next. This remains a structural refactor only: - no mechanic, save shape, command, projection, or tick-order change belongs - in its extraction commits. + `sim/work.rs`. Slice 5b landed: account, allocation, detection, research, + and income policy lives in `sim/economy.rs`. Slice 6 landed: social commands, + assets, authored plot execution, world acts, and the institutional ledger + live in `sim/social_plot.rs`. Slice 7, the persistence bridge, is next. This + remains a structural refactor only: no mechanic, save shape, command, + projection, or tick-order change belongs in its extraction commits. Stage: Process Work order: sim-decomposition Work priority: 8 @@ -222,6 +225,23 @@ accounts, and reach expose stable internal seams. A `WorldAct` continues to call the real subsystem operation; the extraction must not introduce direct state shortcuts. +Slice 6 landed shape: `sim/social_plot.rs` is 833 lines and owns 32 associated +constants/methods covering the social command surface, persona messaging and +deception, asset recruitment/tasks and located witnessing, authored-plot +eligibility and execution, target-relative endpoint/account resolution, typed +world acts, narration/endings, and institutional events. `sim.rs` fell from +1,715 to 902 lines. `WorldAct::Message` still enters the real message schedule, +`WorldAct::Transfer` still settles through the account graph, and institutional +acts still append to the persistent ledger and derive their signatures through +the existing subsystem seams. Existing public paths and signatures remain +unchanged; five private effect callbacks became narrow `pub(super)` seams for +work, communications, and behavior-owned tests. All 32 moved items matched +their pre-slice source after normalizing only those seams, and all 14 methods +left in the root remained byte-equivalent. Save v26, the canonical fingerprint, +tests, and explicit `advance` order remain unchanged. Exact boundaries and +observed commands live in +[the slice 6 log](../log/2026-07-12-sim-decomposition-social-plots.md). + ### 7. Extract persistence bridge and reduce the root Move save-state construction/application and transient rebuilding last, when diff --git a/wiki/log/2026-07-12-sim-decomposition-social-plots.md b/wiki/log/2026-07-12-sim-decomposition-social-plots.md new file mode 100644 index 00000000..e40cab5a --- /dev/null +++ b/wiki/log/2026-07-12-sim-decomposition-social-plots.md @@ -0,0 +1,81 @@ +# 2026-07-12 — sim decomposition slice 6: social, assets, and plots + +``` +Type: log +``` + +## Scope + +Continue the behavior-preserving decomposition of `Sim` by moving the +social/asset/plot integration island from `crates/misaligned-core/src/sim.rs` +into `crates/misaligned-core/src/sim/social_plot.rs`. + +This slice changes physical source addresses only. It does not change a +mechanic, save field/version/default, public command/query signature, +projection, frontend contract, test assertion, or `Sim::advance` phase order. + +## Boundary + +The new 833-line module owns 32 associated constants and methods covering: + +- social Thought costs, persona setup, message/favor/deceive submission, and + paid effect application; +- authored-plot eligibility, one-slot submission, execution, held choices, + target-relative endpoint/account binding, narration, failure, and endings; +- typed Message, Transfer, and Institutional world acts over the existing real + message schedule, account graph, detection signatures, and persistent + institutional ledger; and +- recruitment, located physical witnessing, and the five asset-task variants. + +`Sim` state and public readout types, the constructor, explicit `advance` +orchestration and common helpers, and the persistence bridge remain in the +root. Communications, accounts/economy, reach/build, and work keep their +existing modules; the moved executor calls those seams rather than replacing +them with direct state shortcuts. + +Five existing private effect callbacks became `pub(super)` solely where a +sibling behavior module or behavior-owned test crosses the boundary: +`apply_message`, `apply_deceive`, `apply_start_plot`, +`resume_plot_message`, and `apply_asset_task_paid`. Existing public methods +keep their signatures. + +`sim.rs` fell from 1,715 to 902 lines. + +## Equivalence evidence + +A source-level inventory compared every item in the new module against the +committed slice 5b root. All 32 constants/functions matched byte-for-byte after +normalizing only the five required private-to-`pub(super)` seams. All 14 +associated methods left in the root also matched byte-for-byte. + +The canonical save/replay fixture continues to pin save v26 at BLAKE3 +fingerprint +`61dd8240e9810833b554464051bd3c08122295fb280b7180d24f76ccc5c4d4ff`. +The explicit phase trace still pins the unchanged `advance` order. + +Observed focused commands: + +```text +cargo test -p misaligned-core +cargo test -p misaligned-terminal --bin misaligned +cargo check -p misaligned-bevy --bin misaligned-bevy +printf 'people\nactions rack 3\nquit\n' | + tools/observed-run.sh ./target/debug/misaligned --agent --seed 1 +``` + +The focused pre-rebase runs passed every current core and Act One integration +test; terminal parity passed and the Bevy binary compiled. The sandboxed +observed run opened the PEOPLE projection with all five knowledge-staged human +silhouettes and read the shared Rack 3 action surface without an +agent-protocol error or touching the real save directory. After the serialized +rebase onto current main, the complete exact-commit `./tools/check.sh --land` +gate passed again. + +## Defense + +[The adopted decomposition boundary](../engineering/sim-decomposition.md#6-extract-social-and-plots) +keeps social policy, assets, and authored story execution together while +preserving messages, accounts, detection, reach, and work as the real carriers +of every world act. Source equivalence, canonical save/replay fixtures, +behavior-owned tests, and all three frontend gates defend the move against +behavioral, serialization, or facade drift. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index a4326af1..ae0e73ec 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-07-12 - sim decomposition slice 6: social, assets, and plots + +- Intent: (see session log) +- Log: [wiki/log/2026-07-12-sim-decomposition-social-plots.md](2026-07-12-sim-decomposition-social-plots.md) + ## 2026-07-12 - First-THINK panic escape - Intent: The opening correctly made the first THINK feel dangerous, but its interface gave the player no equally immediate visual answer to that panic. Make the existing `1 WORK` control read as the one-press escape while THINK is current, without inventing a new action, changing simul... -- 2.51.2