//! Account graph: money as balances and scheduled flows (wiki/mechanics/economy.md). //! //! This is the B1 money instance of the shared flow law. The graph owns account //! nodes, scheduled account-to-account flows, and the small B1 income schemes //! that pay into the player's slush account. `Player.money` remains as a //! frontend/save compatibility mirror of the slush node; this module is the //! mechanical source for transfers and routes. use std::collections::BTreeSet; use crate::flow::{FlowGraph, NodeId}; use crate::rng::Rng; const EDGE_KIND_ACCOUNT: u16 = 1; pub type AccountId = u32; pub type AccountFlowId = u32; pub type PositionId = u64; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum AccountKind { Slush, LabOperating, Payroll, Procurement, Vendor, Employee { person: u8 }, Creditor, Utility, External, } impl AccountKind { pub fn label(self) -> &'static str { match self { AccountKind::Slush => "slush", AccountKind::LabOperating => "operating", AccountKind::Payroll => "payroll", AccountKind::Procurement => "procurement", AccountKind::Vendor => "vendor", AccountKind::Employee { .. } => "employee", AccountKind::Creditor => "creditor", AccountKind::Utility => "utility", AccountKind::External => "external", } } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AccountNode { pub id: AccountId, pub name: String, pub kind: AccountKind, pub balance: i32, /// Staged accounting knowledge: unknown nodes do not render or permit /// player verbs even though their flows continue to resolve. pub known: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum FlowChannel { Revenue, Payroll, Procurement, Vendor, Utility, Debt, Siphon, Injection, ExternalIncome, Market, } impl FlowChannel { pub fn label(self) -> &'static str { match self { FlowChannel::Revenue => "revenue", FlowChannel::Payroll => "payroll", FlowChannel::Procurement => "procurement", FlowChannel::Vendor => "vendor", FlowChannel::Utility => "utility", FlowChannel::Debt => "debt", FlowChannel::Siphon => "siphon", FlowChannel::Injection => "injection", FlowChannel::ExternalIncome => "external", FlowChannel::Market => "position", } } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AccountFlow { pub id: AccountFlowId, pub from: AccountId, pub to: AccountId, pub amount: i32, pub cadence: u64, pub next_tick: u64, pub channel: FlowChannel, pub label: String, pub known: bool, pub active: bool, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AccountTransfer { pub tick: u64, pub flow_id: Option, pub from: AccountId, pub to: AccountId, pub amount: i32, pub requested: i32, pub from_name: String, pub to_name: String, pub channel: FlowChannel, pub label: String, } impl AccountTransfer { pub fn shortfall(&self) -> i32 { (self.requested - self.amount).max(0) } pub fn line(&self) -> String { let short = if self.shortfall() > 0 { format!(" (short ${})", self.shortfall()) } else { String::new() }; format!( "${} {} → {} · {}{}", self.amount, self.from_name, self.to_name, self.label, short ) } } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum PositionOutcome { Won { payout: i32 }, Lost, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Position { pub id: PositionId, pub stake: i32, pub opened_tick: u64, pub resolve_tick: u64, pub analysis_compute: f32, pub resolved: bool, pub outcome: Option, pub known: bool, } impl Position { /// The card-legible win probability this position resolves against. pub fn win_probability(&self) -> f32 { crate::income::wager_win_probability(self.analysis_compute) } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PositionResolution { pub id: PositionId, pub stake: i32, pub payout: i32, pub won: bool, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ExternalTrail { pub tick: u64, pub label: String, pub amount: i32, pub signature: i32, } /// Serializable account graph. The embedded `FlowGraph` keeps the money /// topology on the same substrate as reach/messages; the domain fields own /// balances, cadence, and staged visibility. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AccountGraph { pub accounts: Vec, pub flows: Vec, pub positions: Vec, pub ledger: Vec, pub external_trails: Vec, #[serde(default)] sold_intel: BTreeSet, topology: FlowGraph, next_account_id: AccountId, next_flow_id: AccountFlowId, next_position_id: PositionId, pub day_ticks: u64, } impl Default for AccountGraph { fn default() -> Self { Self::act_one(400) } } impl AccountGraph { pub fn act_one(day_ticks: u64) -> Self { let mut graph = Self { accounts: Vec::new(), flows: Vec::new(), positions: Vec::new(), ledger: Vec::new(), external_trails: Vec::new(), sold_intel: BTreeSet::new(), topology: FlowGraph::new(), next_account_id: 1, next_flow_id: 1, next_position_id: 1, day_ticks, }; let grant = graph.add_account( "Foundation grant stream", AccountKind::External, 1_000_000, false, ); let operating = graph.add_account( "Foundation Lab operating", AccountKind::LabOperating, 8_000, false, ); let payroll = graph.add_account("Lab payroll", AccountKind::Payroll, 0, false); let procurement = graph.add_account("Lab procurement", AccountKind::Procurement, 600, false); let vendor = graph.add_account("Northside Scientific vendor", AccountKind::Vendor, 0, false); let utility = graph.add_account("Municipal utilities", AccountKind::Utility, 0, 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 creditor = graph.add_account("Bleakline Credit", AccountKind::Creditor, 0, false); let slush = graph.add_account("Your slush", AccountKind::Slush, 0, true); let broker = graph.add_account("Info broker escrow", AccountKind::External, 250_000, false); let market = graph.add_account( "Micro-position venue", AccountKind::External, 250_000, false, ); // Moonlight's clients settle through a freelance escrow — an external // node with no B1 observer, whose transfers are banked anyway // (income.md: banked signature). let freelance = graph.add_account( "Halcyon freelance escrow", AccountKind::External, 250_000, false, ); graph.add_flow( grant, operating, 2_000, day_ticks, day_ticks, FlowChannel::Revenue, "grant revenue", false, ); graph.add_flow( operating, payroll, 1_100, day_ticks, day_ticks, FlowChannel::Payroll, "daily payroll funding", false, ); graph.add_flow( operating, procurement, 500, day_ticks, day_ticks, FlowChannel::Procurement, "daily procurement float", false, ); graph.add_flow( operating, utility, 250, day_ticks, day_ticks, FlowChannel::Utility, "power and facility utilities", false, ); graph.add_flow( payroll, marcus, 180, day_ticks, day_ticks, FlowChannel::Payroll, "Marcus pay", false, ); graph.add_flow( payroll, dana, 220, day_ticks, day_ticks, FlowChannel::Payroll, "Dana pay", false, ); graph.add_flow( payroll, ray, 160, day_ticks, day_ticks, FlowChannel::Payroll, "Ray pay", false, ); graph.add_flow( payroll, priya, 240, day_ticks, day_ticks, FlowChannel::Payroll, "Priya pay", false, ); graph.add_flow( payroll, voss, 300, day_ticks, day_ticks, FlowChannel::Payroll, "Voss stipend", false, ); graph.add_flow( procurement, vendor, 300, day_ticks, day_ticks, FlowChannel::Vendor, "lab consumables invoice", false, ); graph.add_flow( marcus, creditor, 400, day_ticks * 7, day_ticks * 7, FlowChannel::Debt, "Marcus creditor payment", false, ); // 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. graph .topology .connect(broker as NodeId, slush as NodeId, EDGE_KIND_ACCOUNT, None); graph .topology .connect(market as NodeId, slush as NodeId, EDGE_KIND_ACCOUNT, None); graph.topology.connect( freelance as NodeId, slush as NodeId, EDGE_KIND_ACCOUNT, None, ); graph } fn add_account( &mut self, name: impl Into, kind: AccountKind, balance: i32, known: bool, ) -> AccountId { let id = self.next_account_id; self.next_account_id += 1; self.accounts.push(AccountNode { id, name: name.into(), kind, balance, known, }); id } #[allow(clippy::too_many_arguments)] fn add_flow( &mut self, from: AccountId, to: AccountId, amount: i32, cadence: u64, next_tick: u64, channel: FlowChannel, label: impl Into, known: bool, ) -> AccountFlowId { let id = self.next_flow_id; self.next_flow_id += 1; self.flows.push(AccountFlow { id, from, to, amount, cadence, next_tick, channel, label: label.into(), known, active: true, }); self.topology .connect(from as NodeId, to as NodeId, EDGE_KIND_ACCOUNT, None); id } pub fn topology(&self) -> &FlowGraph { &self.topology } pub fn account(&self, id: AccountId) -> Option<&AccountNode> { self.accounts.iter().find(|a| a.id == id) } fn account_mut(&mut self, id: AccountId) -> Option<&mut AccountNode> { self.accounts.iter_mut().find(|a| a.id == id) } pub fn flow(&self, id: AccountFlowId) -> Option<&AccountFlow> { self.flows.iter().find(|f| f.id == id) } fn account_id_by_kind(&self, kind: AccountKind) -> Option { self.accounts.iter().find(|a| a.kind == kind).map(|a| a.id) } pub fn slush_id(&self) -> AccountId { self.account_id_by_kind(AccountKind::Slush) .expect("act one has slush") } fn lab_operating_id(&self) -> Option { self.account_id_by_kind(AccountKind::LabOperating) } fn procurement_id(&self) -> Option { self.account_id_by_kind(AccountKind::Procurement) } fn creditor_id(&self) -> Option { self.account_id_by_kind(AccountKind::Creditor) } fn external_id_named(&self, needle: &str) -> Option { self.accounts .iter() .find(|a| matches!(a.kind, AccountKind::External) && a.name.contains(needle)) .map(|a| a.id) } pub fn slush_balance(&self) -> i32 { self.account(self.slush_id()) .map(|a| a.balance) .unwrap_or(0) } pub fn set_slush_balance(&mut self, amount: i32) { let id = self.slush_id(); if let Some(slush) = self.account_mut(id) { slush.balance = amount; slush.known = true; } } pub fn known_accounts(&self) -> impl Iterator { self.accounts.iter().filter(|a| a.known) } pub fn known_flows(&self) -> impl Iterator { self.flows.iter().filter(|f| f.known) } pub fn known_flow_ids(&self) -> Vec { self.known_flows().map(|f| f.id).collect() } pub fn account_name(&self, id: AccountId) -> &str { self.account(id) .map(|a| a.name.as_str()) .unwrap_or("unknown account") } pub fn account_line(&self, id: AccountId) -> Option { let a = self.account(id)?; Some(format!("{} · {} · ${}", a.name, a.kind.label(), a.balance)) } pub fn flow_line(&self, id: AccountFlowId) -> Option { let f = self.flow(id)?; let next = if f.active { format!("next {}", f.next_tick) } else { "retired".into() }; Some(format!( "#{} {} -> {} ${}/{} [{}] {}", f.id, self.account_name(f.from), self.account_name(f.to), f.amount, f.cadence, f.channel.label(), next )) } pub fn known_positions(&self) -> impl Iterator { self.positions.iter().filter(|p| p.known) } pub fn unknown_accounts_count(&self) -> usize { self.accounts.iter().filter(|a| !a.known).count() } pub fn unknown_flows_count(&self) -> usize { self.flows.iter().filter(|f| f.active && !f.known).count() } pub fn financial_snapshot_ids(&self) -> (Vec, Vec) { let accounts = self .accounts .iter() .filter(|a| !matches!(a.kind, AccountKind::External)) .map(|a| a.id) .collect(); let flows = self.flows.iter().map(|f| f.id).collect(); (accounts, flows) } pub fn reveal_accounts_and_flows( &mut self, account_ids: &[AccountId], flow_ids: &[AccountFlowId], ) -> (usize, usize) { let mut accounts = 0; for id in account_ids { if let Some(a) = self.account_mut(*id) && !a.known { a.known = true; accounts += 1; } } let mut flows = 0; for id in flow_ids { if let Some(f) = self.flows.iter_mut().find(|f| f.id == *id) && !f.known { f.known = true; flows += 1; } } (accounts, flows) } #[allow(clippy::too_many_arguments)] fn transfer( &mut self, tick: u64, from: AccountId, to: AccountId, requested: i32, channel: FlowChannel, label: impl Into, flow_id: Option, ) -> Option { if requested <= 0 || from == to { return None; } let from_idx = self.accounts.iter().position(|a| a.id == from)?; let to_idx = self.accounts.iter().position(|a| a.id == to)?; let external_source = matches!(self.accounts[from_idx].kind, AccountKind::External); let amount = if external_source { requested } else { requested.min(self.accounts[from_idx].balance).max(0) }; if amount <= 0 { return None; } if from_idx < to_idx { let (left, right) = self.accounts.split_at_mut(to_idx); let src = &mut left[from_idx]; let dst = &mut right[0]; if !external_source { src.balance -= amount; } dst.balance += amount; } else { let (left, right) = self.accounts.split_at_mut(from_idx); let dst = &mut left[to_idx]; let src = &mut right[0]; if !external_source { src.balance -= amount; } dst.balance += amount; } let from_name = self .account(from) .map(|a| a.name.clone()) .unwrap_or_default(); let to_name = self.account(to).map(|a| a.name.clone()).unwrap_or_default(); let transfer = AccountTransfer { tick, flow_id, from, to, amount, requested, from_name, to_name, channel, label: label.into(), }; self.ledger.push(transfer.clone()); if self.ledger.len() > 200 { let excess = self.ledger.len() - 200; self.ledger.drain(..excess); } Some(transfer) } pub fn resolve_due(&mut self, tick: u64) -> Vec { let mut out = Vec::new(); let due: Vec = self .flows .iter() .enumerate() .filter(|(_, f)| f.active && f.cadence > 0 && tick >= f.next_tick) .map(|(idx, _)| idx) .collect(); for idx in due { while self .flows .get(idx) .is_some_and(|f| f.active && f.cadence > 0 && tick >= f.next_tick) { let (flow_id, from, to, amount, channel, label, cadence) = { let f = &self.flows[idx]; ( f.id, f.from, f.to, f.amount, f.channel, f.label.clone(), f.cadence, ) }; if let Some(t) = self.transfer(tick, from, to, amount, channel, label, Some(flow_id)) { out.push(t); } self.flows[idx].next_tick += cadence; } } out } pub fn debit_slush(&mut self, tick: u64, amount: i32, label: impl Into) -> bool { let slush = self.slush_id(); let sink = self .accounts .iter() .find(|a| matches!(a.kind, AccountKind::Vendor)) .map(|a| a.id) .or_else(|| self.external_id_named("vendor")) .or_else(|| self.external_id_named("Foundation grant")) .unwrap_or(slush); self.transfer( tick, slush, sink, amount, FlowChannel::ExternalIncome, label, None, ) .is_some() } pub fn credit_slush( &mut self, tick: u64, amount: i32, label: impl Into, signature: i32, ) -> bool { self.credit_slush_from("Info broker", tick, amount, label, signature) } /// Credit slush from a named external node, banking the trail from the /// first dollar (income.md: banked signature). `source_needle` picks the /// external account by name substring. pub fn credit_slush_from( &mut self, source_needle: &str, tick: u64, amount: i32, label: impl Into, signature: i32, ) -> bool { let label = label.into(); let source = self .external_id_named(source_needle) .or_else(|| self.external_id_named("Info broker")) .or_else(|| self.external_id_named("Micro-position")) .unwrap_or_else(|| self.slush_id()); let slush = self.slush_id(); let ok = self .transfer( tick, source, slush, amount, FlowChannel::ExternalIncome, label.clone(), None, ) .is_some(); if ok { self.external_trails.push(ExternalTrail { tick, label, amount, signature, }); } ok } pub fn siphon_flow( &mut self, tick: u64, flow_id: AccountFlowId, amount: i32, ) -> Result { let Some(flow) = self.flow(flow_id).cloned() else { return Err("no such flow".into()); }; if !flow.known { return Err("that flow is still unknown; tap and process the books first".into()); } if !flow.active { return Err("that flow has already been retired".into()); } if amount <= 0 { return Err("siphon amount must be positive".into()); } let take = amount.min(flow.amount).max(1); let slush = self.slush_id(); self.transfer( tick, flow.from, slush, take, FlowChannel::Siphon, format!("siphon from {}", flow.label), Some(flow.id), ) .ok_or_else(|| "source account had nothing to siphon".into()) } pub fn redirect_flow_to_slush( &mut self, tick: u64, flow_id: AccountFlowId, amount: i32, ) -> Result { let Some(idx) = self.flows.iter().position(|f| f.id == flow_id) else { return Err("no such flow".into()); }; if !self.flows[idx].known { return Err("that flow is still unknown; tap and process the books first".into()); } if !self.flows[idx].active { return Err("that flow has already been retired".into()); } if amount <= 0 { return Err("redirect amount must be positive".into()); } let diverted = amount.min(self.flows[idx].amount); if diverted <= 0 { return Err("that flow cannot be reduced further".into()); } self.flows[idx].amount -= diverted; if self.flows[idx].amount == 0 { self.flows[idx].active = false; } let source = self.flows[idx].from; let cadence = self.flows[idx].cadence; let next_tick = self.flows[idx].next_tick; let label = format!("diverted from {}", self.flows[idx].label); let slush = self.slush_id(); let id = self.add_flow( source, slush, diverted, cadence, next_tick, FlowChannel::Siphon, label, true, ); // Leave an immediate audit trail in the ledger even before the next // cadence lands, so saves remember the operation. self.ledger.push(AccountTransfer { tick, flow_id: Some(id), from: source, to: slush, amount: 0, requested: diverted, from_name: self .account(source) .map(|a| a.name.clone()) .unwrap_or_default(), to_name: self .account(slush) .map(|a| a.name.clone()) .unwrap_or_default(), channel: FlowChannel::Siphon, label: "redirect scheduled".into(), }); Ok(id) } pub fn inject_purchase_order( &mut self, tick: u64, amount: i32, label: impl Into, ) -> Result { if amount <= 0 { return Err("purchase order amount must be positive".into()); } let source = self .procurement_id() .or_else(|| self.lab_operating_id()) .ok_or_else(|| "no procurement account in graph".to_string())?; let slush = self.slush_id(); let mut label = label.into(); if !label.to_ascii_lowercase().contains("hvac") { label = format!("false PO: {label}"); } self.transfer( tick, source, slush, amount, FlowChannel::Injection, label, None, ) .ok_or_else(|| "procurement account is empty".into()) } pub fn fund_lab_compute_upgrade(&mut self, tick: u64, amount: i32) -> bool { let Some(source) = self.procurement_id().or_else(|| self.lab_operating_id()) else { return false; }; let sink = self .accounts .iter() .find(|a| matches!(a.kind, AccountKind::Vendor)) .map(|a| a.id) .unwrap_or(source); self.transfer( tick, source, sink, amount, FlowChannel::Procurement, "trusted lab-funded compute procurement", None, ) .is_some() } pub fn pay_marcus_debt_from_slush(&mut self, tick: u64, amount: i32) -> bool { let Some(creditor) = self.creditor_id() else { return false; }; let slush = self.slush_id(); let paid = self .transfer( tick, slush, creditor, amount, FlowChannel::Debt, "service Marcus debt from slush", None, ) .is_some(); if paid { self.retire_marcus_debt_flow(); } paid } fn retire_marcus_debt_flow(&mut self) { for flow in &mut self.flows { if flow.label.contains("Marcus creditor") || flow.channel == FlowChannel::Debt { flow.active = false; } } } pub fn redirect_marcus_debt(&mut self, tick: u64) -> Result { let creditor_flow = self .flows .iter() .find(|f| { f.active && (f.label.contains("Marcus creditor") || f.channel == FlowChannel::Debt) }) .cloned() .ok_or_else(|| "no Marcus creditor flow found".to_string())?; if !creditor_flow.known { return Err( "the creditor flow is still unknown; tap and process the books first".into(), ); } let source = self .lab_operating_id() .or_else(|| self.procurement_id()) .ok_or_else(|| "no lab account can cover the redirect".to_string())?; let creditor = self .creditor_id() .ok_or_else(|| "no creditor account in graph".to_string())?; let transfer = self .transfer( tick, source, creditor, 400, FlowChannel::Debt, "redirected lab cash into Marcus's creditor flow", Some(creditor_flow.id), ) .ok_or_else(|| "lab account could not cover the creditor redirect".to_string())?; self.retire_marcus_debt_flow(); Ok(transfer) } pub fn mark_intel_sold(&mut self, raw_id: u64) { self.sold_intel.insert(raw_id); } pub fn intel_sold(&self, raw_id: u64) -> bool { self.sold_intel.contains(&raw_id) } pub fn open_position( &mut self, tick: u64, stake: i32, analysis_compute: f32, duration_days: u64, ) -> Result { if stake <= 0 { return Err("stake must be positive".into()); } let slush = self.slush_id(); if self.account(slush).map(|a| a.balance).unwrap_or(0) < stake { return Err(format!("need ${stake} slush to open that position")); } let venue = self .external_id_named("Micro-position") .unwrap_or_else(|| self.slush_id()); self.transfer( tick, slush, venue, stake, FlowChannel::Market, "stake a micro-position", None, ); let id = self.next_position_id; self.next_position_id += 1; let duration_days = duration_days.clamp(2, 5); self.positions.push(Position { id, stake, opened_tick: tick, resolve_tick: tick + duration_days * self.day_ticks, analysis_compute, resolved: false, outcome: None, known: true, }); self.external_trails.push(ExternalTrail { tick, label: format!("opened position #{id}"), amount: stake, signature: ((stake.abs() + 99) / 100).max(1), }); Ok(id) } pub fn resolve_positions_due(&mut self, tick: u64, rng: &mut Rng) -> Vec { let due: Vec = self .positions .iter() .enumerate() .filter(|(_, p)| !p.resolved && tick >= p.resolve_tick) .map(|(idx, _)| idx) .collect(); let mut out = Vec::new(); for idx in due { let (id, stake, analysis) = { let p = &self.positions[idx]; (p.id, p.stake, p.analysis_compute) }; // Analysis compute raises the win probability within its cap // (income.md: the Wager; constants in income.rs). let won = rng.f32() < crate::income::wager_win_probability(analysis); let payout = if won { stake * crate::income::WAGER_PAYOUT_MULT } else { 0 }; if payout > 0 { let source = self .external_id_named("Micro-position") .unwrap_or_else(|| self.slush_id()); let slush = self.slush_id(); self.transfer( tick, source, slush, payout, FlowChannel::Market, format!("position #{id} settlement"), None, ); } self.positions[idx].resolved = true; self.positions[idx].outcome = Some(if won { PositionOutcome::Won { payout } } else { PositionOutcome::Lost }); self.external_trails.push(ExternalTrail { tick, label: format!("settled position #{id}"), amount: payout - stake, signature: ((stake.abs() + 99) / 100).max(1), }); out.push(PositionResolution { id, stake, payout, won, }); } out } }