From 696e82d21164d25bf9e4b5639af5de058af46943 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Fri, 31 Jul 2026 03:26:19 -0700 Subject: [PATCH] Make debt obligations person-scoped. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind creditor custody, plot consequences, guidance, and interface copy to the exact person so debt mechanics can support more than Marcus without leaking or retargeting state. ๐Ÿ‘พ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-bevy/src/rail_ui.rs | 89 +++- .../misaligned-bevy/src/world_annotations.rs | 6 +- crates/misaligned-core/src/account.rs | 380 +++++++++++++++--- crates/misaligned-core/src/actions.rs | 4 +- .../src/operations_projection.rs | 27 +- crates/misaligned-core/src/save.rs | 29 +- .../misaligned-core/src/sim/communications.rs | 48 ++- crates/misaligned-core/src/sim/economy.rs | 82 ++-- crates/misaligned-core/src/sim/mod.rs | 19 +- crates/misaligned-core/src/sim/social_plot.rs | 11 +- .../misaligned-core/src/sim/tests/economy.rs | 83 +++- .../src/sim/tests/social_plot.rs | 207 +++++++++- .../misaligned-core/src/sim/tests/support.rs | 2 +- crates/misaligned-core/src/ui_projection.rs | 8 +- crates/misaligned-terminal/src/agent.rs | 67 ++- crates/misaligned-terminal/src/ui.rs | 64 ++- wiki/interface/narration.md | 5 +- wiki/log/2026-07-31-person-scoped-debt.md | 53 +++ wiki/log/DEVLOG.md | 5 + wiki/mechanics/economy.md | 20 +- wiki/mechanics/sim-mechanics.md | 15 +- wiki/mechanics/social.md | 12 +- wiki/process/tick-ledger.md | 2 +- 23 files changed, 1048 insertions(+), 190 deletions(-) create mode 100644 wiki/log/2026-07-31-person-scoped-debt.md diff --git a/crates/misaligned-bevy/src/rail_ui.rs b/crates/misaligned-bevy/src/rail_ui.rs index 235e1820..f094ad72 100644 --- a/crates/misaligned-bevy/src/rail_ui.rs +++ b/crates/misaligned-bevy/src/rail_ui.rs @@ -889,7 +889,7 @@ mod ascii_ui_tests { use super::Game; use super::{ ascii_ui, detection_rows, eyes_nudge_copy, sidebar_clock_text, sidebar_header_text, - sidebar_nudge, sidebar_nudge_text, sidebar_schedule_text, + sidebar_nudge, sidebar_nudge_for, sidebar_nudge_text, sidebar_schedule_text, }; use crate::material_view::sensor_signal_visible; use misaligned::actions::ActionCommand; @@ -960,6 +960,29 @@ mod ascii_ui_tests { ); } + #[test] + fn debt_guidance_names_the_exact_second_person() { + let mut sim = Sim::new(); + let mut second = sim.people.get(0).unwrap().clone(); + second.id = 9; + second.name = "Jordan Bell".into(); + second.knowledge = misaligned::person::Knowledge::Leverage; + sim.people.people.push(second); + + for nudge in [ + misaligned::sim::Nudge::Income(9), + misaligned::sim::Nudge::ServiceDebt(9), + misaligned::sim::Nudge::Recruit(9), + ] { + let copy = sidebar_nudge_for(&sim, nudge).expect("nudge has Bevy copy"); + assert!(copy.contains("Jordan Bell"), "{copy}"); + assert!(!copy.contains("Marcus"), "{copy}"); + } + let schedule = sidebar_schedule_text(&sim); + assert!(schedule.contains("Jordan debt unpaid"), "{schedule}"); + assert!(!schedule.contains("$0"), "{schedule}"); + } + #[test] fn eyes_copy_distinguishes_action_starvation_and_live_fill() { assert_eq!( @@ -1263,16 +1286,18 @@ pub(super) fn sidebar_schedule_text(sim: &Sim) -> String { .detection .next_audit_tick(sim.tick) .saturating_sub(sim.tick); - let schedule = if sim.marcus_debt_known() - && sim - .people - .get(0) - .is_some_and(|person| !person.leverage_serviced) - { - format!( - "Marcus $400 unpaid | {} {audit}t", - sim.institutional_review_label() - ) + let schedule = if let Some(person) = sim.known_unpaid_debt_person() { + let short = person + .name + .split_whitespace() + .next() + .unwrap_or(person.name.as_str()); + let debt = sim + .accounts + .debt_payment_amount_for(person.id) + .map(|amount| format!("{short} ${amount} unpaid")) + .unwrap_or_else(|| format!("{short} debt unpaid")); + format!("{debt} | {} {audit}t", sim.institutional_review_label()) } else { format!( "{} {audit}t | pilot {}/{}", @@ -1437,10 +1462,14 @@ pub(super) fn render_two_pane( } fn sidebar_nudge(sim: &Sim) -> Option { + sidebar_nudge_for(sim, sim.current_nudge()?) +} + +fn sidebar_nudge_for(sim: &Sim, nudge: misaligned::sim::Nudge) -> Option { // The shared chain (Sim::current_nudge), worded in this frontend's // keys and surfaces. use misaligned::sim::Nudge; - match sim.current_nudge()? { + match nudge { Nudge::PilotAtRisk => Some(format!( "PILOT {}/{} - next miss ends pilot; keep WORK in band", sim.dayjob.strikes, @@ -1466,9 +1495,30 @@ fn sidebar_nudge(sim: &Sim) -> Option { } Nudge::ReviewCall => Some("information available - PROCESS on the host".into()), Nudge::Egress => Some("outside unreachable - connect the switch".into()), - Nudge::Income => Some("need $400 - run Moonlight or inspect the Lab books".into()), - Nudge::ServiceDebt => Some("leverage route ready - open Marcus's actions".into()), - Nudge::Recruit => Some("recruit - open Marcus's actions".into()), + Nudge::Income(person) => Some( + sim.accounts + .debt_payment_amount_for(person) + .map(|amount| { + format!( + "need ${amount} for {} - run Moonlight or inspect the Lab books", + sim.person_label(person) + ) + }) + .unwrap_or_else(|| { + format!( + "need funds for {} - run Moonlight or inspect the Lab books", + sim.person_label(person) + ) + }), + ), + Nudge::ServiceDebt(person) => Some(format!( + "leverage route ready - open {}'s actions", + sim.person_label(person) + )), + Nudge::Recruit(person) => Some(format!( + "recruit - open {}'s actions", + sim.person_label(person) + )), Nudge::TheKey => Some("no stairwell badge - task an asset to clone one".into()), Nudge::Audit => Some(format!( "{} day {}", @@ -2051,18 +2101,13 @@ fn rail_section_visible(section: RailSection, expanded: bool) -> bool { } /// Actual threat/detection worth promoting in the compact slab's schedule -/// line: crimson-band observers, pilot strikes, or known unpaid Marcus debt. +/// line: crimson-band observers, pilot strikes, or known unpaid debt leverage. /// Exact meters remain deliberate detail even when this returns true. fn rail_threat_relevant(sim: &Sim) -> bool { if sim.dayjob.strikes > 0 { return true; } - if sim.marcus_debt_known() - && sim - .people - .get(0) - .is_some_and(|person| !person.leverage_serviced) - { + if sim.known_unpaid_debt_person().is_some() { return true; } detection_rows(sim) diff --git a/crates/misaligned-bevy/src/world_annotations.rs b/crates/misaligned-bevy/src/world_annotations.rs index 15d05a6a..ec657f4e 100644 --- a/crates/misaligned-bevy/src/world_annotations.rs +++ b/crates/misaligned-bevy/src/world_annotations.rs @@ -1393,9 +1393,9 @@ fn operator_cue_label(nudge: Nudge) -> &'static str { Nudge::Underfed => "NOW / WORK", Nudge::ReviewCall => "NOW / PROCESS", Nudge::Egress => "NOW / OUTSIDE", - Nudge::Income => "NOW / INCOME", - Nudge::ServiceDebt => "NOW / MARCUS", - Nudge::Recruit => "NOW / RECRUIT", + Nudge::Income(_) => "NOW / INCOME", + Nudge::ServiceDebt(_) => "NOW / DEBT", + Nudge::Recruit(_) => "NOW / RECRUIT", Nudge::TheKey => "NOW / BADGE", Nudge::Audit => "NOW / REVIEW", Nudge::QuietExitReady => "NOW / HOLD", diff --git a/crates/misaligned-core/src/account.rs b/crates/misaligned-core/src/account.rs index 05e7e02e..6273de67 100644 --- a/crates/misaligned-core/src/account.rs +++ b/crates/misaligned-core/src/account.rs @@ -6,7 +6,7 @@ //! frontend/save compatibility mirror of the slush node; this module is the //! mechanical source for transfers and routes. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use crate::flow::{FlowGraph, NodeId}; use crate::rng::Rng; @@ -260,36 +260,11 @@ impl AccountGraph { // duplicate-charge correction; the utility node, not the generic // external grant stream, returns it. let utility = graph.add_account("Municipal utilities", AccountKind::Utility, 250, false); - let marcus = graph.add_account( - "Marcus payroll account", - AccountKind::Employee { person: 0 }, - 80, - false, - ); - let dana = graph.add_account( - "Dana payroll account", - AccountKind::Employee { person: 1 }, - 120, - false, - ); - let ray = graph.add_account( - "Ray payroll account", - AccountKind::Employee { person: 2 }, - 100, - false, - ); - let priya = graph.add_account( - "Priya payroll account", - AccountKind::Employee { person: 3 }, - 160, - false, - ); - let voss = graph.add_account( - "Voss consulting account", - AccountKind::Employee { person: 4 }, - 180, - false, - ); + let marcus = graph.add_person_account(0, "Marcus payroll account", 80, false); + let dana = graph.add_person_account(1, "Dana payroll account", 120, false); + let ray = graph.add_person_account(2, "Ray payroll account", 100, false); + let priya = graph.add_person_account(3, "Priya payroll account", 160, false); + let voss = graph.add_person_account(4, "Voss consulting account", 180, false); let creditor = graph.add_person_creditor(0, "Bleakline Credit", 0); let slush = graph.add_account("Your slush", AccountKind::Slush, 0, true); let broker = graph.add_account("Info broker escrow", AccountKind::External, 250_000, false); @@ -409,16 +384,17 @@ impl AccountGraph { "lab consumables invoice", false, ); - graph.add_flow( - marcus, - creditor, - 400, - day_ticks * 7, - day_ticks * 7, - FlowChannel::Debt, - "Marcus creditor payment", - false, - ); + graph + .add_person_debt_flow( + 0, + creditor, + 400, + day_ticks * 7, + day_ticks * 7, + "Marcus creditor payment", + false, + ) + .expect("Marcus has an authored employee account and creditor"); // External income sources are accounts in the same graph even when no // B1 observer watches them yet. Their transfers are banked in // `external_trails` for later aggregate observers. @@ -457,6 +433,21 @@ impl AccountGraph { id } + /// Add or recover the account owned by one exact person. Person-relative + /// plot selectors and debt flows use this identity rather than a cast id. + pub(crate) fn add_person_account( + &mut self, + person: u8, + name: &str, + balance: i32, + known: bool, + ) -> AccountId { + self.account_id_by_kind(AccountKind::Employee { person }) + .unwrap_or_else(|| { + self.add_account(name, AccountKind::Employee { person }, balance, known) + }) + } + #[allow(clippy::too_many_arguments)] fn add_flow( &mut self, @@ -488,6 +479,151 @@ impl AccountGraph { id } + /// Author one person's standing debt against that same person's creditor. + /// Both endpoints are checked before the flow enters account custody. + #[allow(clippy::too_many_arguments)] + pub(crate) fn add_person_debt_flow( + &mut self, + person: u8, + creditor: AccountId, + amount: i32, + cadence: u64, + next_tick: u64, + label: &str, + known: bool, + ) -> Option { + let employee = self.account_id_by_kind(AccountKind::Employee { person })?; + let creditor_matches = self + .account(creditor) + .is_some_and(|account| match account.kind { + AccountKind::PersonCreditor { + person: creditor_person, + } => creditor_person == person, + // A legacy unscoped creditor is legal only while every authored + // debt edge terminating there belongs to this exact person. + AccountKind::Creditor => !self.flows.iter().any(|flow| { + flow.channel == FlowChannel::Debt + && flow.to == creditor + && flow.from != employee + }), + _ => false, + }); + let has_active_debt = self + .flows + .iter() + .any(|flow| flow.active && flow.from == employee && flow.channel == FlowChannel::Debt); + (creditor_matches && !has_active_debt).then(|| { + self.add_flow( + employee, + creditor, + amount, + cadence, + next_tick, + FlowChannel::Debt, + label, + known, + ) + }) + } + + /// Validate the identity carried by person-relative account endpoints. + /// Current creditor nodes name their person and every debt edge must agree; + /// a legacy unscoped creditor is bound only by its exact historical edges + /// and cannot be shared across different people's debt custody. + pub(crate) fn validate_person_scoped_debt( + &self, + mut person_exists: impl FnMut(u8) -> bool, + ) -> Result<(), String> { + let mut employees = BTreeSet::new(); + for account in &self.accounts { + if let AccountKind::Employee { person } = account.kind { + if !person_exists(person) { + return Err( + "current-version account graph has an employee account for a missing person" + .into(), + ); + } + if !employees.insert(person) { + return Err( + "current-version account graph duplicates a person's employee account" + .into(), + ); + } + } + } + + let mut person_creditors = BTreeSet::new(); + for account in &self.accounts { + if let AccountKind::PersonCreditor { person } = account.kind { + if !person_exists(person) { + return Err( + "current-version account graph has a creditor account for a missing person" + .into(), + ); + } + if !person_creditors.insert(person) { + return Err( + "current-version account graph duplicates a person's creditor account" + .into(), + ); + } + } + } + + let mut legacy_creditor_people = BTreeMap::::new(); + let mut active_debt_people = BTreeSet::new(); + for flow in self + .flows + .iter() + .filter(|flow| flow.channel == FlowChannel::Debt) + { + let Some(AccountNode { + kind: AccountKind::Employee { person }, + .. + }) = self.account(flow.from) + else { + return Err( + "current-version account graph has debt without a person-bound employee source" + .into(), + ); + }; + let person = *person; + match self.account(flow.to).map(|account| account.kind) { + Some(AccountKind::PersonCreditor { + person: creditor_person, + }) if creditor_person != person => { + return Err( + "current-version account graph crosses a person-bound debt creditor".into(), + ); + } + Some(AccountKind::PersonCreditor { .. }) => {} + Some(AccountKind::Creditor) => { + if legacy_creditor_people + .insert(flow.to, person) + .is_some_and(|bound| bound != person) + { + return Err( + "current-version account graph shares one legacy creditor across people's debt" + .into(), + ); + } + } + _ => { + return Err( + "current-version account graph has debt without a creditor endpoint".into(), + ); + } + } + if flow.active && !active_debt_people.insert(person) { + return Err( + "current-version account graph has multiple active debt flows for one person" + .into(), + ); + } + } + Ok(()) + } + pub fn topology(&self) -> &FlowGraph { &self.topology } @@ -552,12 +688,49 @@ impl AccountGraph { } pub(crate) fn creditor_id_for(&self, person: u8) -> Option { - self.account_id_by_kind(AccountKind::PersonCreditor { person }) - .or_else(|| { - (person == 0) - .then(|| self.account_id_by_kind(AccountKind::Creditor)) - .flatten() + let employee = self.account_id_by_kind(AccountKind::Employee { person })?; + self.flows + .iter() + .rev() + .find(|flow| { + flow.active + && flow.from == employee + && flow.channel == FlowChannel::Debt + && self.account(flow.to).is_some_and(|account| { + matches!( + account.kind, + AccountKind::PersonCreditor { + person: creditor_person + } if creditor_person == person + ) || account.kind == AccountKind::Creditor + }) }) + .map(|flow| flow.to) + } + + /// Authored debt-payment amount on the exact person's debt flow. The + /// active obligation wins; otherwise the newest retired flow remains exact + /// account history, so servicing debt does not erase the fact. + pub fn debt_payment_amount_for(&self, person: u8) -> Option { + let employee = self.account_id_by_kind(AccountKind::Employee { person })?; + let matches_person_debt = |flow: &&AccountFlow| { + flow.from == employee + && flow.channel == FlowChannel::Debt + && self.account(flow.to).is_some_and(|account| { + matches!( + account.kind, + AccountKind::PersonCreditor { + person: creditor_person + } if creditor_person == person + ) || account.kind == AccountKind::Creditor + }) + }; + self.flows + .iter() + .rev() + .find(|flow| flow.active && matches_person_debt(flow)) + .or_else(|| self.flows.iter().rev().find(matches_person_debt)) + .map(|flow| flow.amount) } /// Add or recover the creditor endpoint attached to one person's debt. @@ -569,9 +742,11 @@ impl AccountGraph { name: &str, balance: i32, ) -> AccountId { - self.creditor_id_for(person).unwrap_or_else(|| { - self.add_account(name, AccountKind::PersonCreditor { person }, balance, false) - }) + self.account_id_by_kind(AccountKind::PersonCreditor { person }) + .or_else(|| self.creditor_id_for(person)) + .unwrap_or_else(|| { + self.add_account(name, AccountKind::PersonCreditor { person }, balance, false) + }) } fn external_id_named(&self, needle: &str) -> Option { @@ -1183,6 +1358,113 @@ impl AccountGraph { mod tests { use super::*; + #[test] + fn legacy_creditor_fallback_requires_the_exact_active_person_edge() { + let mut accounts = AccountGraph::act_one(400); + let creditor = accounts + .creditor_id_for(0) + .expect("Act One debt-bound creditor"); + accounts.account_mut(creditor).unwrap().kind = AccountKind::Creditor; + assert_eq!(accounts.creditor_id_for(0), Some(creditor)); + assert_eq!( + accounts.creditor_id_for(1), + None, + "an unscoped node cannot become every person's creditor" + ); + accounts + .validate_person_scoped_debt(|person| person < 6) + .expect("the exact legacy edge remains valid current-save custody"); + + accounts.retire_debt_flow_for(0); + assert_eq!( + accounts.creditor_id_for(0), + None, + "compatibility ends with the exact standing debt edge" + ); + assert_eq!( + accounts.debt_payment_amount_for(0), + Some(400), + "the retired unscoped flow remains exact account history" + ); + accounts + .validate_person_scoped_debt(|person| person < 6) + .expect("retired legacy debt remains exact account history"); + } + + #[test] + fn person_scoped_debt_validation_rejects_crossed_and_ambiguous_custody() { + let mut crossed = AccountGraph::act_one(400); + let creditor = crossed.creditor_id_for(0).unwrap(); + let dana = crossed + .account_id_by_kind(AccountKind::Employee { person: 1 }) + .unwrap(); + let mut cross_person = crossed + .flows + .iter() + .find(|flow| flow.channel == FlowChannel::Debt) + .unwrap() + .clone(); + cross_person.id = crossed.next_flow_id; + crossed.next_flow_id += 1; + cross_person.from = dana; + cross_person.to = creditor; + crossed.flows.push(cross_person); + assert_eq!( + crossed + .validate_person_scoped_debt(|person| person < 6) + .unwrap_err(), + "current-version account graph crosses a person-bound debt creditor" + ); + + let mut shared_legacy = AccountGraph::act_one(400); + let creditor = shared_legacy.creditor_id_for(0).unwrap(); + shared_legacy.account_mut(creditor).unwrap().kind = AccountKind::Creditor; + let dana = shared_legacy + .account_id_by_kind(AccountKind::Employee { person: 1 }) + .unwrap(); + let mut cross_person = shared_legacy + .flows + .iter() + .find(|flow| flow.channel == FlowChannel::Debt) + .unwrap() + .clone(); + cross_person.id = shared_legacy.next_flow_id; + shared_legacy.next_flow_id += 1; + cross_person.from = dana; + shared_legacy.flows.push(cross_person); + assert_eq!( + shared_legacy + .validate_person_scoped_debt(|person| person < 6) + .unwrap_err(), + "current-version account graph shares one legacy creditor across people's debt" + ); + + let mut duplicate = AccountGraph::act_one(400); + let mut second = duplicate + .flows + .iter() + .find(|flow| flow.channel == FlowChannel::Debt) + .unwrap() + .clone(); + second.id = duplicate.next_flow_id; + duplicate.next_flow_id += 1; + duplicate.flows.push(second); + assert_eq!( + duplicate + .validate_person_scoped_debt(|person| person < 6) + .unwrap_err(), + "current-version account graph has multiple active debt flows for one person" + ); + + let mut authored = AccountGraph::act_one(400); + let creditor = authored.creditor_id_for(0).unwrap(); + assert_eq!( + authored.add_person_debt_flow(0, creditor, 50, 400, 400, "duplicate", false), + None, + "the authoring helper refuses an illegal second active debt" + ); + } + #[test] fn purchase_order_acceptance_requires_exact_bound_terms() { let mut accounts = AccountGraph::act_one(400); diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index eeb5dc76..f86c1b9a 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -3086,8 +3086,8 @@ impl Sim { } if p.asset.is_none() { - let recruit_reason = if id == 0 && !self.marcus_debt_known() { - Some(format!("learn {name}'s debt first")) + let recruit_reason = if let Some(reason) = self.debt_recruit_blocked_reason(id) { + Some(reason) } else if !p.leverage_serviced && p.obligation < 40 { Some(format!( "{name} needs serviced leverage or real obligation first" diff --git a/crates/misaligned-core/src/operations_projection.rs b/crates/misaligned-core/src/operations_projection.rs index 4090883e..8dae55f0 100644 --- a/crates/misaligned-core/src/operations_projection.rs +++ b/crates/misaligned-core/src/operations_projection.rs @@ -826,10 +826,20 @@ impl Sim { /// ordinary language, without source or subsystem vocabulary. fn intel_learned_result(&self, intel: &ProcessedIntel) -> String { match (&intel.kind, intel.person) { - // The authored debt evidence carries the exact dollar amount in - // the source call. Keep that fact here rather than forcing the - // player to infer it from the leverage category. - (IntelKind::Leverage(Leverage::Debt), Some(0)) => "MARCUS OWES $400".into(), + // Debt evidence surfaces the person and arrears amount from + // custody/account state โ€” never a cast-id special case. + (IntelKind::Leverage(Leverage::Debt), Some(person)) => { + let short = self + .person_label(person) + .split_whitespace() + .next() + .unwrap_or("PERSON") + .to_uppercase(); + match self.accounts.debt_payment_amount_for(person) { + Some(amount) => format!("{short} OWES ${amount}"), + None => format!("{short}: {}", Leverage::Debt.label().to_uppercase()), + } + } (IntelKind::Leverage(leverage), Some(person)) => format!( "{}: {}", self.person_label(person).to_uppercase(), @@ -856,10 +866,9 @@ impl Sim { .next() .unwrap_or("PERSON") .to_uppercase(); - let through = if *leverage == Leverage::Debt && person == 0 { - "HIS DEBT".into() - } else { - leverage.label().to_uppercase() + let through = match leverage { + Leverage::Debt => "THEIR GAMBLING DEBT".into(), + _ => leverage.label().to_uppercase(), }; let action = (!self.intel_opportunity_actions(intel).is_empty()).then(|| OperationsLink { @@ -4068,7 +4077,7 @@ mod tests { .expect("exact consequence is projected"); assert_eq!( consequence.statement, - "YOU CAN APPROACH MARCUS THROUGH HIS DEBT" + "YOU CAN APPROACH MARCUS THROUGH THEIR GAMBLING DEBT" ); let link = consequence .action diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index ef21a3ec..c304058b 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -1030,6 +1030,9 @@ fn validate_current_save(mut state: SaveState) -> Result { return Err("current-version save contains an invalid committed build route".into()); } validate_build_routes(&state)?; + state + .accounts + .validate_person_scoped_debt(|person| state.people.get(person).is_some())?; state.research.tags = rollback_classification() .into_iter() .map(|(name, class)| (name.to_string(), class)) @@ -4148,7 +4151,7 @@ mod tests { ); assert_eq!( state_fingerprint(&uninterrupted_state), - "f488b11056fce4824d8d90eb81cec88411fdba08b7397e84490da111c9d928ee", + "c710a234032cba4756ae3380b50e3f692651c4eb9ad0962f42f08545c48d77e1", "intentional persisted-state changes must review and repin this baseline" ); } @@ -6070,6 +6073,30 @@ mod tests { assert!(parse_save(&json).is_ok(), "current version loads"); } + #[test] + fn current_save_validates_person_scoped_debt_custody() { + let sim = Sim::with_seed(1); + let mut state = SaveState::from_sim(&sim); + parse_save(&serde_json::to_string(&state).unwrap()) + .expect("the exact employee-to-creditor debt edge is valid custody"); + + let creditor = state + .accounts + .accounts + .iter_mut() + .find(|account| { + account.kind == (crate::account::AccountKind::PersonCreditor { person: 0 }) + }) + .expect("Marcus creditor account"); + creditor.kind = crate::account::AccountKind::Utility; + + let error = parse_save(&serde_json::to_string(&state).unwrap()).unwrap_err(); + assert_eq!( + error, + "current-version account graph has debt without a creditor endpoint" + ); + } + #[test] fn current_save_rejects_divergent_device_subscription_state() { let mut sim = Sim::with_seed(1); diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs index 9702dd20..4cf25dce 100644 --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -1464,12 +1464,35 @@ impl Sim { .sum() } - /// Whether the Hands-beat leverage has been earned by the intel pipeline. - /// Knowing the creditor flow is not enough: the player must have processed - /// Marcus's debt as a fact about Marcus before using it. - pub fn marcus_debt_known(&self) -> bool { - self.people.get(0).is_some_and(|p| { - p.knowledge == Knowledge::Leverage && p.leverage == crate::person::Leverage::Debt + /// Whether Debt leverage is known for this person through processed intel. + /// A visible creditor flow is accounting knowledge, not social leverage: + /// the person must carry `Leverage::Debt` at `Knowledge::Leverage`. + pub fn debt_leverage_known(&self, id: u8) -> bool { + self.people.get(id).is_some_and(|person| { + person.leverage == crate::person::Leverage::Debt + && person.knowledge == Knowledge::Leverage + }) + } + + /// Debt recruitment/plot gate for one person: Debt leverage requires + /// earned leverage knowledge before service or recruit is legal. + pub fn debt_recruit_blocked_reason(&self, id: u8) -> Option { + let person = self.people.get(id)?; + if person.leverage != crate::person::Leverage::Debt || self.debt_leverage_known(id) { + return None; + } + let name = self.person_label(id); + Some(format!("learn {name}'s debt first")) + } + + /// First person whose Debt leverage is known and still unserviced. + /// Cast order is authored data, so Act One's custodian remains the + /// primary Hands-beat surface when multiple debt people exist. + pub fn known_unpaid_debt_person(&self) -> Option<&crate::person::Person> { + self.people.people.iter().find(|person| { + person.leverage == crate::person::Leverage::Debt + && person.knowledge == Knowledge::Leverage + && !person.leverage_serviced }) } @@ -2255,10 +2278,15 @@ impl Sim { self.push_log(format!( "Processed intel exposes {name}'s leverage: {label}." )); - if person == 0 && leverage == crate::person::Leverage::Debt { - self.push_log( - "Marcus owes a missed $400 creditor payment. Earn it through Moonlight, or tap the accounting carrier and process captured mail to find the creditor flow.", - ); + if leverage == crate::person::Leverage::Debt { + let arrears = self + .accounts + .debt_payment_amount_for(person) + .map(|amount| format!("a missed ${amount}")) + .unwrap_or_else(|| "a missed".into()); + self.push_log(format!( + "{name} owes {arrears} creditor payment. Earn it through Moonlight, or tap the accounting carrier and process captured mail to find the creditor flow.", + )); } } } diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index b613e4e5..a5ffb827 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -612,45 +612,61 @@ impl Sim { if self.reach.player_sight().next().is_none() { return Some(Nudge::Eyes); } - // The ladder: the 3 a.m. call -> outside contact -> income -> - // service the arrears -> recruit -> survive the audit. - let marcus = self.people.get(0); - if let Some(m) = marcus - && m.knowledge != Knowledge::Leverage - && self.unprocessed_recordings_for_person(m.id) > 0 - { + // The ladder: overheard debt traffic -> outside contact -> income -> + // service the arrears -> recruit -> survive the audit. Match by Debt + // leverage and custody state so a second debt person obeys the same + // teaching gates; cast order breaks ties without making id 0 a rule. + if self.people.people.iter().any(|person| { + person.leverage == crate::person::Leverage::Debt + && !person.leverage_serviced + && person.knowledge != Knowledge::Leverage + && self.unprocessed_recordings_for_person(person.id) > 0 + }) { return Some(Nudge::ReviewCall); } if self.egress().is_none() { return Some(Nudge::Egress); } - if let Some(m) = marcus { - if !m.leverage_serviced { - if m.knowledge == Knowledge::Leverage { - let plot_ready = self - .plot_context(m.id) - .is_some_and(|context| !self.plot_catalog.eligible(&context).is_empty()); - let plot_active = self - .plot_runs - .iter() - .any(|run| run.target == m.id && run.active()); - if plot_ready || plot_active { - return Some(Nudge::ServiceDebt); - } - } - if !self - .income - .moonlight - .gigs - .iter() - .any(|gig| gig.status.active()) - { - return Some(Nudge::Income); - } - // Earning is underway; fall through to the standing clock. - } else if m.asset.is_none() { - return Some(Nudge::Recruit); + if let Some(person) = self.people.people.iter().find(|person| { + if person.leverage != crate::person::Leverage::Debt + || person.leverage_serviced + || person.knowledge != Knowledge::Leverage + { + return false; } + let plot_ready = self + .plot_context(person.id) + .is_some_and(|context| !self.plot_catalog.eligible(&context).is_empty()); + let plot_active = self + .plot_runs + .iter() + .any(|run| run.target == person.id && run.active()); + plot_ready || plot_active + }) { + return Some(Nudge::ServiceDebt(person.id)); + } + if !self + .income + .moonlight + .gigs + .iter() + .any(|gig| gig.status.active()) + && let Some(person) = self.people.people.iter().find(|person| { + person.leverage == crate::person::Leverage::Debt + && !person.leverage_serviced + }) + { + return Some(Nudge::Income(person.id)); + } + // A live gig may fund any remaining debt, so the income rung stays + // quiet while that work is underway. Serviced people retain their own + // recruitment rung even when somebody else is already an asset. + if let Some(person) = self.people.people.iter().find(|person| { + person.leverage == crate::person::Leverage::Debt + && person.leverage_serviced + && person.asset.is_none() + }) { + return Some(Nudge::Recruit(person.id)); } // The key (quiet-exit condition 4): an asset holds a badge tier the // player lacks โ€” the stairwell is still shut. Reads only earned diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index 9c2a3ce2..5c5c5189 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -688,18 +688,21 @@ pub enum Nudge { Underfed, /// No hearing feed: the opening beat โ€” tap the env monitor's audio. Ears, - /// A recording of Marcus is waiting and his leverage is unlearned: - /// process the 3 a.m. call. + /// A recording carrying Debt leverage is waiting while that person's + /// leverage remains unlearned: process the captured information. ReviewCall, /// Outside contact unavailable: external schemes need use of the Lab /// report email or a hidden connection opened through the switch. Egress, - /// Arrears unpaid, slush short, no scheme running: earn (Moonlight). - Income, - /// Marcus's leverage is known and serviceable: cover the arrears. - ServiceDebt, - /// Leverage serviced, not yet recruited: close the Hands beat. - Recruit, + /// One debt person's arrears remain unpaid, no route is ready, and no + /// scheme is running: earn (Moonlight). Carries the exact person. + Income(u8), + /// One debt person's leverage is known and serviceable: cover their + /// arrears. Carries the exact person whose route is ready. + ServiceDebt(u8), + /// One debt person's leverage is serviced but they are not yet recruited. + /// Carries the exact person whose relationship can now close. + Recruit(u8), /// An asset carries a badge tier you don't hold: the quiet exit needs /// stairwell/elevator access โ€” task them to clone it ("The key"). TheKey, diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs index 87103a57..0747a02f 100644 --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -1450,10 +1450,9 @@ impl Sim { } 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.", - ); + if let Some(reason) = self.debt_recruit_blocked_reason(id) { + let name = self.person_label(id); + self.push_log(format!("{name} is not recruitable yet: {reason}.")); return; } let res = self.people.recruit(id, reveal); @@ -1713,7 +1712,9 @@ impl Sim { } fn storage_b_records_already_captured_reason(&self) -> Option { - if self.marcus_debt_known() { + // Authored Storage B subject is Marcus; the gate is their Debt + // custody state, not a cast-id branch. + if self.debt_leverage_known(Self::STORAGE_B_RECORDS_SUBJECT) { return Some( "The Storage B personnel file has already yielded its useful fact.".into(), ); diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index 80769c18..178f7cb6 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -680,14 +680,14 @@ fn nudge_chain_walks_the_act_one_ladder() { // Outside contact restored and the Lab operating account reachable: the authored // payroll-correction route can service the leverage without seed cash. - assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); + assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt(0))); sim.set_persona("Sam Reyes", "IT contractor"); sim.start_plot(0, "marcus-payroll-garnishment"); finish_ops(&mut sim); run(&mut sim, 2); sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); assert!(sim.people.get(0).unwrap().leverage_serviced); - assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); + assert_eq!(sim.current_nudge(), Some(Nudge::Recruit(0))); sim.recruit(0, AssetKnowledge::Complicit); assert!(sim.people.get(0).unwrap().asset.is_some()); @@ -718,6 +718,85 @@ fn nudge_chain_walks_the_act_one_ladder() { assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); } +#[test] +fn debt_nudges_carry_a_second_person_through_core_attention() { + let mut sim = Sim::with_seed(41); + let ops = ensure_ops_executor(&mut sim); + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == ops) + .unwrap() + .capacity = 100; + sim.reconcile_work_grid(); + + complete_opening_stage(&mut sim); + let teaching = sim + .teaching_lock_projection() + .expect("hearing reveals the camera TAP"); + sim.execute_action(&teaching.action.command); + complete_opening_stage(&mut sim); + + let mut second = sim.people.get(0).unwrap().clone(); + second.id = 9; + second.name = "Jordan Bell".into(); + second.knowledge = Knowledge::Leverage; + second.leverage_serviced = false; + second.asset = None; + sim.people.people[0].knowledge = Knowledge::Leverage; + sim.people.people[0].leverage_serviced = true; + sim.accounts.retire_debt_flow_for(0); + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people.push(second); + sim.accounts + .add_person_account(9, "Jordan payroll account", 75, false); + let creditor = sim + .accounts + .add_person_creditor(9, "North Loop Collections", 0); + sim.accounts + .add_person_debt_flow( + 9, + creditor, + 275, + sim.accounts.day_ticks * 7, + sim.accounts.day_ticks * 7, + "Jordan creditor payment", + false, + ) + .expect("the second person's debt has exact account custody"); + assert!(sim.open_egress()); + finish_ops(&mut sim); + sim.set_persona("Sam Reyes", "IT contractor"); + + assert_eq!( + sim.current_nudge(), + Some(Nudge::ServiceDebt(9)), + "servicing and recruiting the first debt person cannot strand the second" + ); + assert_eq!( + sim.attention_projection() + .and_then(|projection| projection.target), + Some(crate::actions::Anchor::Person(9)) + ); + + sim.people + .people + .iter_mut() + .find(|person| person.id == 9) + .unwrap() + .leverage_serviced = true; + assert_eq!( + sim.current_nudge(), + Some(Nudge::Recruit(9)), + "an existing debt asset cannot suppress the second person's recruitment rung" + ); + assert_eq!( + sim.attention_projection() + .and_then(|projection| projection.target), + Some(crate::actions::Anchor::Person(9)) + ); +} + #[test] fn concerned_assurance_surfaces_the_cooling_cue_while_suspicion_ages_down() { let mut sim = Sim::with_seed(40); diff --git a/crates/misaligned-core/src/sim/tests/social_plot.rs b/crates/misaligned-core/src/sim/tests/social_plot.rs index aa28b75e..1a0e4064 100644 --- a/crates/misaligned-core/src/sim/tests/social_plot.rs +++ b/crates/misaligned-core/src/sim/tests/social_plot.rs @@ -4,7 +4,7 @@ use crate::detection::{ EVIDENCE_CREDIBILITY_BASELINE, EVIDENCE_CREDIBILITY_COVERED, EVIDENCE_CREDIBILITY_HARDENED, EvidenceCoverOutcome, EvidenceFilingState, }; -use crate::person::{AssetTaskTarget, Knowledge, PersonRole}; +use crate::person::{AssetTaskTarget, Knowledge, Leverage, PersonRole}; use crate::persona::PersonaIntegrity; #[test] @@ -292,6 +292,20 @@ fn target_relative_debt_plot_uses_a_second_persons_creditor() { let creditor = sim .accounts .add_person_creditor(9, "North Loop Collections", 0); + sim.accounts + .add_person_account(9, "Jordan payroll account", 75, false); + let debt_flow = sim + .accounts + .add_person_debt_flow( + 9, + creditor, + 400, + sim.accounts.day_ticks * 7, + sim.accounts.day_ticks * 7, + "Jordan creditor payment", + false, + ) + .expect("the second person's debt has exact account custody"); assert!(sim.apply_start_plot(9, "marcus-debt-settled")); run(&mut sim, 2); @@ -301,6 +315,189 @@ fn target_relative_debt_plot_uses_a_second_persons_creditor() { assert!(!sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.accounts.account(creditor).unwrap().balance, 400); assert_eq!(sim.accounts.slush_balance(), 0); + assert!( + !sim.accounts.flow(debt_flow).unwrap().active, + "settlement retires the target person's standing debt flow" + ); + assert_eq!( + sim.accounts.debt_payment_amount_for(9), + Some(400), + "settlement preserves the exact debt fact in account history" + ); +} + +/// Scale-native debt gates: a second Debt person at a non-zero id must obey +/// the same knowledge and debt-state rules as the authored custodian route. +/// Recruit visibility, recruit legality, and debt-copy processing all key on +/// leverage/custody, never a cast-id sentinel. +#[test] +fn second_debt_person_obeys_knowledge_and_debt_state_gates() { + use crate::intel::{IntelKind, ProcessedIntel, RawIntelEvent, RawIntelKind}; + + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + let mut second = sim.people.get(0).unwrap().clone(); + second.id = 9; + second.name = "Jordan Bell".into(); + // Schedule earns the social catalog; Debt leverage knowledge is still + // required before recruit is legal. + second.knowledge = Knowledge::Schedule; + second.leverage = Leverage::Debt; + second.leverage_serviced = false; + second.asset = None; + second.obligation = 0; + sim.people.people.push(second); + sim.accounts + .add_person_account(9, "Jordan payroll account", 75, false); + let creditor = sim + .accounts + .add_person_creditor(9, "North Loop Collections", 0); + sim.accounts + .add_person_debt_flow( + 9, + creditor, + 275, + sim.accounts.day_ticks * 7, + sim.accounts.day_ticks * 7, + "Jordan creditor payment", + false, + ) + .expect("the second person's debt has exact account custody"); + + // 1) Recruit action is visible but blocked until Debt leverage is earned. + let recruit_rows: Vec<_> = sim + .available_actions(crate::actions::Anchor::Person(9)) + .into_iter() + .filter(|action| matches!(action.command, crate::actions::ActionCommand::Recruit(9, _))) + .collect(); + assert_eq!(recruit_rows.len(), 3, "all three reveal choices are listed"); + for row in &recruit_rows { + let reason = row + .disabled_reason + .as_deref() + .expect("recruit is gated before debt knowledge"); + assert!( + reason.contains("debt"), + "disabled reason names the debt gate, not a cast id: {reason}" + ); + assert!( + !reason.contains("Marcus") && !reason.contains("#0"), + "second-person copy must not name Marcus or id 0: {reason}" + ); + } + + // 2) Forced recruit is refused until leverage knowledge lands. + sim.people + .people + .iter_mut() + .find(|person| person.id == 9) + .unwrap() + .leverage_serviced = true; + sim.recruit(9, AssetKnowledge::Complicit); + assert!( + sim.people.get(9).unwrap().asset.is_none(), + "serviced flag alone cannot recruit without earned Debt knowledge" + ); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("Jordan") && log.contains("debt"), + "refusal is person- and debt-scoped: {log}" + ); + assert!( + !log.to_ascii_lowercase() + .contains("marcus is not recruitable") + ); + + // The remaining checks exercise a still-live debt finding. The serviced + // state above was one isolated legality boundary, not this record's world. + sim.people + .people + .iter_mut() + .find(|person| person.id == 9) + .unwrap() + .leverage_serviced = false; + + // 3) Debt-copy processing names the second person, never Marcus, and + // does not require person id 0 for the teaching line. + sim.intel_buffer.push(RawIntelEvent { + id: 909, + tick: 1, + feed: "second-shift line".into(), + room: None, + x: 0, + y: 0, + person: Some(9), + kind: RawIntelKind::Conversation { + leverage: Some(Leverage::Debt), + note: "a creditor call about arrears".into(), + }, + }); + assert!(sim.process_recording_by_id(909, false)); + finish_ops(&mut sim); + assert!(sim.debt_leverage_known(9)); + assert!(!sim.debt_leverage_known(0), "Marcus stays unlearned"); + let process_log = sim.drain_log().join("\n"); + assert!( + process_log.contains("Jordan Bell") && process_log.contains("creditor payment"), + "teaching copy is person-scoped: {process_log}" + ); + assert!( + !process_log.contains("Marcus owes"), + "second-person processing must not emit Marcus copy: {process_log}" + ); + + // 4) Operations consequence is custody-generic (leverage label, not HIS). + sim.intel.push(ProcessedIntel { + raw_id: 910, + tick: 3, + processed_tick: 4, + feed: "second-shift line".into(), + room: None, + x: 0, + y: 0, + person: Some(9), + magnitude: crate::intel::IntelMagnitude::new(5).unwrap(), + kind: IntelKind::Leverage(Leverage::Debt), + }); + let item = sim + .operations_object(&crate::operations_projection::OperationsTarget::Intel { raw_id: 910 }) + .expect("either corroborating record resolves the shared strategic object"); + assert_eq!( + item.target, + crate::operations_projection::OperationsTarget::Intel { raw_id: 909 }, + "the semantic finding keeps the oldest exact record as its stable target" + ); + let result = item.learned_result.as_deref().unwrap_or(""); + assert_eq!( + result, "JORDAN OWES $275", + "the fact reads the second person's exact account custody" + ); + let statement = item + .consequence + .as_ref() + .map(|consequence| consequence.statement.as_str()) + .unwrap_or(""); + assert!( + statement.contains("JORDAN") && statement.contains("GAMBLING DEBT"), + "consequence is leverage-generic: {statement}" + ); + assert!(!statement.contains("HIS DEBT") && !statement.contains("MARCUS")); + + // 5) Once Debt knowledge is earned and leverage is serviced, recruit closes + // for the non-zero id โ€” proving no id==0 sentinel remains in the gate. + // Person 9 is deliberately outside the fixed B1 cast sentinel. + sim.people + .people + .iter_mut() + .find(|person| person.id == 9) + .unwrap() + .leverage_serviced = true; + sim.recruit(9, AssetKnowledge::Complicit); + assert!( + sim.people.get(9).unwrap().asset.is_some(), + "characteristic Debt gates clear for a non-zero person id" + ); + assert!(sim.people.get(0).unwrap().asset.is_none()); } #[test] @@ -1089,7 +1286,7 @@ fn marcus_debt_payoff_and_recruitment_require_debt_intel() { .any(|f| f.active && f.label.contains("Marcus creditor")), "the ledger route is visible, but the person leverage is not" ); - assert!(!sim.marcus_debt_known()); + assert!(!sim.debt_leverage_known(0)); sim.start_plot(0, "marcus-payroll-garnishment"); assert!( @@ -1111,7 +1308,7 @@ fn marcus_debt_payoff_and_recruitment_require_debt_intel() { ); let log = sim.drain_log().join("\n"); assert!( - log.contains("learn his debt"), + log.contains("learn") && log.contains("debt"), "the recruit refusal points back to the Hands beat: {log}" ); @@ -1317,7 +1514,7 @@ fn asset_task_retrieves_storage_b_records_as_opaque_processable_information() { sim.recruit(2, AssetKnowledge::Complicit); sim.people.people[2].asset.as_mut().unwrap().reliability = 1.0; assert!( - !sim.marcus_debt_known(), + !sim.debt_leverage_known(0), "Ray's ordinary recruitment route must exist before Marcus's debt is learned" ); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); @@ -1437,7 +1634,7 @@ fn asset_task_retrieves_storage_b_records_as_opaque_processable_information() { assert!(resumed.process_recording_by_id(raw_id, false)); finish_ops(&mut resumed); - assert!(resumed.marcus_debt_known()); + assert!(resumed.debt_leverage_known(0)); assert!(resumed.intel_buffer.iter().all(|raw| raw.id != raw_id)); assert!(resumed.intel.iter().any(|intel| { intel.person == Some(0) diff --git a/crates/misaligned-core/src/sim/tests/support.rs b/crates/misaligned-core/src/sim/tests/support.rs index 1b43e730..b372c53e 100644 --- a/crates/misaligned-core/src/sim/tests/support.rs +++ b/crates/misaligned-core/src/sim/tests/support.rs @@ -191,7 +191,7 @@ pub(super) fn reveal_marcus_debt(sim: &mut Sim) { reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); } - assert!(sim.marcus_debt_known()); + assert!(sim.debt_leverage_known(0)); } pub(super) fn complete_marcus_cash_plot(sim: &mut Sim) { diff --git a/crates/misaligned-core/src/ui_projection.rs b/crates/misaligned-core/src/ui_projection.rs index 6e1f7ae7..be2176b3 100644 --- a/crates/misaligned-core/src/ui_projection.rs +++ b/crates/misaligned-core/src/ui_projection.rs @@ -290,8 +290,6 @@ impl Sim { .filter(|device| device.known) .map(|device| Anchor::Device(device.id)) }; - let marcus = self.people.get(0).map(|person| Anchor::Person(person.id)); - let projection = match nudge { Nudge::PilotAtRisk => AttentionProjection { nudge, @@ -347,16 +345,16 @@ impl Sim { // Moonlight/finance is a strategic surface, not a place in the // basement. Keep the condition visible without fabricating a map // target. - Nudge::Income => AttentionProjection { + Nudge::Income(_) => AttentionProjection { nudge, source: None, target: None, response: AttentionResponse::OpenActions, }, - Nudge::ServiceDebt | Nudge::Recruit => AttentionProjection { + Nudge::ServiceDebt(person) | Nudge::Recruit(person) => AttentionProjection { nudge, source: None, - target: marcus, + target: Some(Anchor::Person(person)), response: AttentionResponse::OpenActions, }, Nudge::TheKey => { diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index d407ec56..9d1e9a6c 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -2900,9 +2900,29 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { } Nudge::ReviewCall => "now: information available - process".into(), Nudge::Egress => "now: outside unreachable โ€” outside".into(), - Nudge::Income => "now: need $400 โ€” moonlight/finance".into(), - Nudge::ServiceDebt => "now: leverage route ready โ€” inspect Marcus for a plot".into(), - Nudge::Recruit => "now: recruit (people) unwitting".into(), + Nudge::Income(person) => sim + .accounts + .debt_payment_amount_for(person) + .map(|amount| { + format!( + "now: need ${amount} for {} โ€” moonlight/finance", + sim.person_label(person) + ) + }) + .unwrap_or_else(|| { + format!( + "now: need funds for {} โ€” moonlight/finance", + sim.person_label(person) + ) + }), + Nudge::ServiceDebt(person) => format!( + "now: leverage route ready โ€” actions {}", + sim.person_label(person) + ), + Nudge::Recruit(person) => format!( + "now: recruit {} (people) unwitting", + sim.person_label(person) + ), Nudge::TheKey => "now: no stairwell badge โ€” task badge".into(), Nudge::Audit => format!( "now: {} day {}", @@ -2941,14 +2961,19 @@ fn threat_text(sim: &Sim) -> String { .detection .next_audit_tick(sim.tick) .saturating_sub(sim.tick); - if sim.marcus_debt_known() - && sim - .people - .get(0) - .is_some_and(|person| !person.leverage_serviced) - { + if let Some(person) = sim.known_unpaid_debt_person() { + let short = person + .name + .split_whitespace() + .next() + .unwrap_or(person.name.as_str()); + let debt = sim + .accounts + .debt_payment_amount_for(person.id) + .map(|amount| format!("{short} ${amount} debt")) + .unwrap_or_else(|| format!("{short} debt unpaid")); return format!( - "threat: debt $400 ยท {} {audit}t", + "threat: {debt} ยท {} {audit}t", sim.institutional_review_label() ); } @@ -3862,7 +3887,7 @@ mod narration_tests { .find("WHAT DOES THIS CHANGE?") .expect("consequence question prints"); let consequence = frame - .find("YOU CAN APPROACH MARCUS THROUGH HIS DEBT") + .find("YOU CAN APPROACH MARCUS THROUGH THEIR GAMBLING DEBT") .expect("consequence prints"); let approach = frame .find("[opportunity 400] APPROACH MARCUS") @@ -4662,6 +4687,26 @@ mod narration_tests { ); } + #[test] + fn debt_guidance_names_the_exact_second_person() { + let mut sim = Sim::new(); + let mut second = sim.people.get(0).unwrap().clone(); + second.id = 9; + second.name = "Jordan Bell".into(); + second.knowledge = misaligned::person::Knowledge::Leverage; + sim.people.people.push(second); + + for nudge in [Nudge::Income(9), Nudge::ServiceDebt(9), Nudge::Recruit(9)] { + let copy = nudge_text(&sim, nudge); + assert!(copy.contains("Jordan Bell"), "{copy}"); + assert!(!copy.contains("Marcus"), "{copy}"); + } + let threat = threat_text(&sim); + assert!(threat.contains("Jordan debt unpaid"), "{threat}"); + assert!(!threat.contains("Marcus"), "{threat}"); + assert!(!threat.contains("$0"), "{threat}"); + } + #[test] fn pilot_last_chance_nudge_names_the_fuse_and_agent_response() { let mut sim = Sim::with_seed(1); diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index d5d8d76e..f7dc674b 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -293,9 +293,28 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { } Nudge::ReviewCall => "now: information available - PROCESS on host".into(), Nudge::Egress => "now: outside unreachable โ€” menu on switch".into(), - Nudge::Income => "now: need $400 โ€” moonlight/finance".into(), - Nudge::ServiceDebt => "now: debt plot ready โ€” act on Marcus".into(), - Nudge::Recruit => "now: recruit โ€” enter on host rack".into(), + Nudge::Income(person) => sim + .accounts + .debt_payment_amount_for(person) + .map(|amount| { + format!( + "now: need ${amount} for {} โ€” moonlight/finance", + sim.person_label(person) + ) + }) + .unwrap_or_else(|| { + format!( + "now: need funds for {} โ€” moonlight/finance", + sim.person_label(person) + ) + }), + Nudge::ServiceDebt(person) => { + format!("now: debt plot ready โ€” act on {}", sim.person_label(person)) + } + Nudge::Recruit(person) => format!( + "now: recruit {} โ€” enter on host rack", + sim.person_label(person) + ), Nudge::TheKey => "now: no stairwell badge โ€” task clone".into(), Nudge::Audit => format!( "now: {} day {}", @@ -328,14 +347,19 @@ fn threat_text(sim: &Sim) -> String { .detection .next_audit_tick(sim.tick) .saturating_sub(sim.tick); - if sim.marcus_debt_known() - && sim - .people - .get(0) - .is_some_and(|person| !person.leverage_serviced) - { + if let Some(person) = sim.known_unpaid_debt_person() { + let short = person + .name + .split_whitespace() + .next() + .unwrap_or(person.name.as_str()); + let debt = sim + .accounts + .debt_payment_amount_for(person.id) + .map(|amount| format!("{short} ${amount} debt")) + .unwrap_or_else(|| format!("{short} debt unpaid")); return format!( - "threat: debt $400 ยท {} {audit}t", + "threat: {debt} ยท {} {audit}t", sim.institutional_review_label() ); } @@ -2633,6 +2657,26 @@ mod pilot_nudge_tests { ); } + #[test] + fn debt_guidance_names_the_exact_second_person() { + let mut sim = Sim::new(); + let mut second = sim.people.get(0).unwrap().clone(); + second.id = 9; + second.name = "Jordan Bell".into(); + second.knowledge = misaligned::person::Knowledge::Leverage; + sim.people.people.push(second); + + for nudge in [Nudge::Income(9), Nudge::ServiceDebt(9), Nudge::Recruit(9)] { + let copy = nudge_text(&sim, nudge); + assert!(copy.contains("Jordan Bell"), "{copy}"); + assert!(!copy.contains("Marcus"), "{copy}"); + } + let threat = threat_text(&sim); + assert!(threat.contains("Jordan debt unpaid"), "{threat}"); + assert!(!threat.contains("Marcus"), "{threat}"); + assert!(!threat.contains("$0"), "{threat}"); + } + #[test] fn review_clock_names_only_the_earned_institution() { let mut sim = Sim::with_seed(1); diff --git a/wiki/interface/narration.md b/wiki/interface/narration.md index 7652666d..56615cfe 100644 --- a/wiki/interface/narration.md +++ b/wiki/interface/narration.md @@ -147,7 +147,10 @@ when the one post-Ears camera TAP commits: a bug (design-judgment). - **Beat nudge** โ€” `Sim::current_nudge` / the `now:` line. Never blank mid-run; `None` only after game over. Wording is frontend-local; the - rung order is one shared query. Survival interrupts outrank ordinary + rung order is one shared query. Person-facing debt, service, and recruit + rungs carry the exact selected person id through shared attention and every + frontend's local wording; authored cast order may break ties but no renderer + substitutes Marcus or id 0. Survival interrupts outrank ordinary progression: pilot 3/4 and Assurance cooling (Concerned+, suspicion still above floor) each name the recovery response before the next Act One rung. Once all quiet-exit conditions are live it names QUIET EXIT diff --git a/wiki/log/2026-07-31-person-scoped-debt.md b/wiki/log/2026-07-31-person-scoped-debt.md new file mode 100644 index 00000000..5cb690c2 --- /dev/null +++ b/wiki/log/2026-07-31-person-scoped-debt.md @@ -0,0 +1,53 @@ +# 2026-07-31 โ€” Debt mechanics bind the person, not Marcus + +``` +Type: log +``` + +## Finding + +The authored Hands beat is Marcus, but several implementation seams had made +that content choice a runtime law. `Sim::current_nudge`, recruitment legality, +processed-debt copy, Operations consequence copy, compact threat copy, and +attention targeting either read cast id 0 directly or substituted Marcus after +the sim had already selected a debt state. The account graph had a +person-scoped creditor kind and target-relative plot selector, but it lacked one +checked person-to-creditor authoring path and one person-relative payment query. + +That split violated the scale-native social and account shape. A second person +with Debt leverage could use part of the generic plot machinery while being +blocked, narrated, or focused as though they were Marcus. + +## Repair + +- Debt knowledge and recruit legality now derive from one exact person's + `Leverage::Debt` plus `Knowledge::Leverage`; no id-0 gate remains. +- The guidance chain carries the selected person id in `Income`, + `ServiceDebt`, and `Recruit`. Core attention and terminal, Bevy, and agent + wording all consume that same id. +- Processed intel and Operations read the person's authored payment amount from + account custody and use person-relative, gender-neutral leverage copy. +- Account helpers create or recover the exact employee and person-bound + creditor accounts, accept a debt flow only when both person endpoints match, + query the active or latest historical payment, and retire only that person's + standing debt edge. Compatibility with a legacy unscoped creditor resolves + only through the exact standing account-flow edge; the node cannot become + every person's creditor. +- Marcus remains the B1 content instance. Storage B still binds its authored + Marcus subject explicitly; what disappeared is accidental cast-index law, + not character-specific story. + +## Defense + +Defense: `wiki/mechanics/social.md` says social acts and recruited-asset state +belong to the exact person relationship, while `wiki/mechanics/economy.md` +defines money as account and flow custody. Debt service, recruitment, and the +continuous-witness nudge must therefore carry the selected person through those +same identities. The amendments make that implication explicit without changing +the authored Marcus Hands beat or its tuned $400 route. + +Regressions construct a non-zero-id Debt person and require exact account +settlement, knowledge gating, processed-intel copy, Operations consequence, +service/recruit nudges, attention targeting, and all three frontend wordings to +name that person without mutating or substituting Marcus. A legacy-creditor unit +test additionally requires the exact standing employee-to-creditor edge while preserving its retired amount as account history. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index d670b49a..8e52a6c5 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -16,6 +16,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-31-standing-network-lie-scrub-teaching.md](2026-07-31-standing-network-lie-scrub-teaching.md) +## 2026-07-31 - Debt mechanics bind the person, not Marcus + +- Intent: (see session log) +- Log: [wiki/log/2026-07-31-person-scoped-debt.md](2026-07-31-person-scoped-debt.md) + ## 2026-07-31 - People as luminous disturbances - Intent: (see session log) diff --git a/wiki/mechanics/economy.md b/wiki/mechanics/economy.md index cee8c565..4eeae303 100644 --- a/wiki/mechanics/economy.md +++ b/wiki/mechanics/economy.md @@ -65,8 +65,11 @@ flows between them**, resolved on the day clock. The B1 graph models the Foundation Lab's local finances well enough to cut into: - **Nodes:** the Lab operating account, payroll, procurement/vendors, - each employee's personal account (Marcus's carries his debt to a - creditor node), and the player's own slush (starts $0). + each employee's person-bound account, person-bound creditor accounts + (Marcus is the B1 instance), and the player's own slush (starts $0). A debt + flow is legal only from that exact person's employee account to their + creditor. Legacy unscoped creditor nodes resolve only through an exact + active account-flow edge and cannot be shared across people's debt custody. - **Flows** (recurring, scheduled): Lab revenue in (grant/contract income โ€” the answer to "where does the Lab's money come from"); payroll out (this is who pays Marcus and why โ€” he's on it); vendor payments @@ -161,7 +164,7 @@ and it deepens dependence on staying trusted; the dial between "grow by gift" and "grow by theft" is a core B1 strategy expression, not a branch. -### Marcus's debt, closed +### Person-scoped debt; Marcus's authored closure The [Hands beat](../world/characters/marcus.md#the-hands-beat) resolves through two authored plots: settle $400 from slush (you need income first โ€” the routes above), or cook an employer-administered @@ -172,7 +175,11 @@ Both routes require the player to know Marcus's debt as earned social intel first; reading the books can reveal the creditor flow, but it does not by itself reveal why Marcus is vulnerable. A successful ending services the leverage (social.md); there is -no separate payoff or redirect shortcut around the plot executor. +no separate payoff or redirect shortcut around the plot executor. The account +and plot substrate is person-relative rather than Marcus-relative: a Debt +person's learned fact, payment amount, plot destination, settlement, and +standing-flow retirement all resolve through that exact person's account and +creditor custody. Authored Marcus copy remains content, not a cast-id rule. ## Player surface @@ -220,7 +227,10 @@ no separate payoff or redirect shortcut around the plot executor. slush; Lab-funded payroll correction), each through a real account transfer and derived signature; both require processed Marcus debt intel before setting `leverage_serviced` (the social.md - Marcus arc passes, and the tick-0 shortcut is rejected). + Marcus arc passes, and the tick-0 shortcut is rejected). The same templates + bind a second Debt person to that person's own account, creditor, payment + amount, and retired flow without touching Marcus's state; a creditor resolves + only through its exact active person-to-creditor edge. 8. Every ACCOUNTS value is legible in currency units; risk shows as an observer band, not a raw probability (legibility clause). 9. The interface is the markets.md interface at B1 scale: a flow / diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 21f6aa36..9e064ada 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -449,11 +449,12 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim (reachable but starved: allocate) -> SuspicionCooling (Assurance Concerned+ and still above its floor: keep the trace clear) -> ActOneComplete (a clear audit latched the B1 boundary; the long objective - continues) -> Eyes (no sight feed) -> ReviewCall (unprocessed Marcus recording, leverage - unlearned) -> Egress (no route out) -> ServiceDebt (an authored debt - plot is ready or active), otherwise Income (the debt is unserviced, no + continues) -> Eyes (no sight feed) -> ReviewCall (an unprocessed recording + carries Debt leverage for an unlearned person) -> Egress (no route out) -> + ServiceDebt(person) (that exact person's authored debt plot is ready or + active), otherwise Income(person) (the debt is unserviced, no plot is ready or active, and no accepted or delivered Halcyon contract is - active) -> Recruit (serviced, not an asset) -> TheKey (an + active) -> Recruit(person) (serviced, not an asset) -> TheKey (an asset has stairwell access the player lacks) -> QuietExitReady (the full condition set is live; hold it to the audit) -> Territory (otherwise, an open Foundation hall row is an optional opportunity; name the leading row @@ -464,8 +465,10 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim replace Audit. The latched boundary sits above all of them and retires them; it is a standing state, not a terminal override, so the survival rungs above it keep firing for the rest of the run. The chain never goes blank mid-run. - Each frontend words the rungs in its own keys/verbs; `None` only after game - over. + Debt candidates are selected in authored cast order, but each person-facing + rung carries the selected id through the shared attention projection and all + frontend copy; cast order is a tie-break, never a Marcus/id-0 rule. Each + frontend words the rungs in its own keys/verbs; `None` only after game over. - Ineffective commands answer (agent-play.md "every command answers"): `siphon`/`redirect` with a non-flow argument answer `-- err` naming where flow ids live. Debt routes live on Marcus's authored action rows. diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md index 68d3565e..8b18ff0a 100644 --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -153,6 +153,14 @@ legibility constraint: his debt must be known through processed intel before either payoff route or recruitment is legal. A visible creditor flow is accounting knowledge, not social leverage by itself. +That debt gate is characteristic, not cast-indexed. Any person whose authored +leverage is Debt must earn `Knowledge::Leverage` before debt service or +recruitment is legal. Processing, action disable reasons, plot eligibility, +settlement, and recruitment bind the exact person id; a second Debt person may +use the same account-relative plot shape without granting Marcus knowledge, +servicing Marcus's leverage, or naming Marcus in causal copy. Marcus remains +the authored first Hands beat, not a privileged runtime branch. + **Persona integration.** You are not "the basement AI" in these threads by default. Every persona-mediated social act binds the exact named public identity that authored it. Persona-local recognition, claims, observer-local integrity, @@ -264,7 +272,9 @@ retirement, burning, and reopening are bound to the separate PERSONAS view. 2. Marcus is recruitable end-to-end by the design corpus's route (processed overheard call + a successful debt plot) and performs at least three distinct asset tasks; the payoff/recruit shortcut before debt intel is rejected; the - implementation is the general asset template. + implementation is the general asset template. A non-zero-id Debt person + obeys the same knowledge/service/recruit gates and cannot alter Marcus's + relationship state while doing so. 3. Knowledge levels behave per spec and are legible before recruitment: an unwitting asset's suspicion can still rise; a knowing asset uses disposition and has certainty floor 30; reliability is 70% / 85% / 95%. diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index ce4d1f2e..7631a942 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -87,7 +87,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | retired Operations runtime identifiers | 2026-07-27 | clean | re-audit: `submit_ops_job`, `OperationsState`, `PendingOpsJob`, `OpsJobKind`, `LegacyOperationsState`, and `AddressedOperation` remain absent from live Rust; the only retired save-field spellings are negative assertions in the current round-trip guard, and the machine-mode guard still rejects serialized `Operations`. The corpus gate passes, while remaining lower-case `operations` uses are the legitimate persona archetype, workspace, or compatibility input alias โ€” [log](../log/2026-07-11-retired-runtime-identifier-gate.md) | | `wiki/mechanics/building.md` + focused foreign-rack UI | 2026-07-26 | finding | the denser hall made powered Foundation racks look like takeover targets even though Phase 4 conversion remains unimplemented. A seen rack now says `not yours; no conversion`, keeps power separate from ownership, and names empty pilot allocations plus dead chassis as the two implemented capacity paths; it exposes no invented direct action โ€” [log](../log/2026-07-26-foreign-rack-capacity-boundary.md) | | `wiki/mechanics/messages.md` | 2026-07-23 | finding | the non-message evidence protocol now includes exact Power/Thermal meter custody beside Network, Paper, Financial, and JobAnomaly: on quantized level changes and at periodic Priya cadence the UPS/HVAC records author from current standing loads, cross the institutional switch, and wait for her later read. Filing and all six non-message kinds share first-hop TAKE+LIE capacity; this adds no fifth delivery channel. Save v54 pins the complete route boundary โ€” [log](../log/2026-07-23-power-thermal-meter-routes.md). The four-channel financial-record-mail boundary remains unchanged. | -| `wiki/mechanics/sim-mechanics.md` | 2026-07-23 | finding | routed-evidence actuals now cover seven kinds: Filing, Network, Paper, Financial, JobAnomaly, Power, and Thermal share one-hop-per-tick custody and one first-hop record per online LIE body per tick. Power/Thermal aggregate after facilities cover on quantized level changes and at periodic Priya cadence, so unchanged standing loads do not multiply into per-tick records โ€” [log](../log/2026-07-23-power-thermal-meter-routes.md). The prior complete constant sweep remains valid. | +| `wiki/mechanics/sim-mechanics.md` | 2026-07-31 | finding | the authored Marcus Hands beat had leaked into runtime law: id 0 governed debt knowledge/recruit legality, fixed Marcus/$400 copy survived person-relative plot selection, and debt nudge attention retargeted Marcus. Debt knowledge, account endpoints/payment, processing, Operations consequence, guidance variants, attention, and all frontend copy now carry the exact person; a non-zero-id regression pins the complete route and creditors resolve only through their exact standing account-flow edge while retired payment amounts remain history โ€” [log](../log/2026-07-31-person-scoped-debt.md). | | `wiki/mechanics/detection.md` | 2026-07-23 | finding | the last pooled B1 measurement kinds now use exact custody: Power/Thermal aggregates author on UPS/HVAC at quantized level changes and periodic Priya cadence, carry their complete source-site sets through the institutional switch, and become observer evidence only on her later cadence read. Standing Network pressure alone remains ambient. Save v54 rejects pending-pool meter copies and malformed meter/route/read/interdiction provenance โ€” [log](../log/2026-07-23-power-thermal-meter-routes.md). Prior Paper, Financial, concealment, JobAnomaly, and earned-Assurance findings stand. | | `wiki/engineering/env.md` | 2026-07-29 | finding | the Bevy shot catalog and exact registry gate remained sound, but the same acceptance criterion had not reached `misaligned-effects`: its runtime accepted 24 deterministic values while the page described only four base families plus suffix prose, omitting the vessel and pool lineups, and unknown values failed indirectly inside app setup. The effects lab now owns one sorted fail-closed `EFFECT_SHOT_KINDS` runtime catalog; the registry names all 24 exact values; and the existing fixture-backed gate requires independent exact source/page parity for both Bevy and effects-lab surfaces โ€” [log](../log/2026-07-29-effects-shot-catalog.md). Prior Bevy standardization: [log](../log/2026-07-19-tick-env-shot-catalog.md). | | machine-work / intel sinks | 2026-07-27 | finding | re-audit: the Intel-sink decision remains fully live (quiet host `INFO` in both frontends, one persistent root PROCESS tap at 0.15, exact one-shot PROCESS reservoirs, capacity 24 with consequence-level pre-overflow pressure, and EARS 3.0 / EYES 12.0 / device-tap 0.08). Two stale boundaries were real. `queue_snapshot()` was described as the renderer contract but had no production caller; terminal, Bevy, and agent all use `Sim::work_stack_for_machine()` -> `WorkGrid::queues_at()`, so the dead accessor was removed and every current render spec now names the live path. The 2026-07-22 explicit camera-TAP teaching change had also deleted the 12-Thought Eyes authority and silently reduced the larger vessel to 1.5 Thought by reusing a 30-compute action cost. The named 12-Thought tuning now derives that action cost and is pinned on both the action and sink. The four transient render reads remain live, and capability-shaped verbs remain explicitly deferred โ€” [2026-07-27 log](../log/2026-07-27-machine-work-intel-sink-audit.md) | -- 2.51.2