diff --git a/README.md b/README.md index 6251269a..6c64ac55 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,16 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | `+` / `-` | Simulation speed | | `v` / `c` / `o` | Salvage near cursor / buy rack at cursor / fallback at cursor | | `r` | Reach panel (`t` tap, `e` splice eyes, `x` take, `n` scan, `c` compromise) | +| `e` | Finance panel (`t` tap ledger, `o` review records, `x` siphon, `r` redirect, `i` inject PO, `p` position, `d` debt redirect) | | `1`-`4` | Shift compute allocation (day job / conceal / social / research) | | `t` | People panel | | `[` / `]` | Zoom (Bevy) | | `Ctrl+S` / `Ctrl+L` | Save / load | | `q` | Quit | +Parking the cursor on the host rack attends the current day job; moving it +away leaves the job on its standing policy. + In the people panel: `w`/`s`, `j`/`k`, or arrows select; `o` reviews the oldest unprocessed recording about the selected person; `a` toggles a standing watch; `m` message, `f` favor, `b` bribe, `d` deceive, `r` recruit @@ -118,3 +122,7 @@ returns a plain-text frame and terminates with `-- ok tick: day:` or ```bash printf 'look\nwait 40\npeople\nquit\n' | cargo run --quiet --bin misaligned -- --agent --seed 1 ``` + +Useful agent finance commands include `finance`, `tap-ledger`, +`review-finance`, `siphon [amount]`, `redirect [amount]`, +`inject [amount]`, `position [stake]`, `sell-intel`, and `clear-debt`. diff --git a/src/account.rs b/src/account.rs new file mode 100644 index 00000000..d9a74fd8 --- /dev/null +++ b/src/account.rs @@ -0,0 +1,1076 @@ +//! 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, +} + +#[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, 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, 500, 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, + ); + + 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 + } + + 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 { + let label = label.into(); + let source = 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, + ) -> 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 = 2 + ((analysis_compute as u64) % 4).min(3); + 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) + }; + let win_probability = (0.55 + analysis.min(80.0) / 400.0).min(0.75); + let won = rng.f32() < win_probability; + let payout = if won { stake * 2 } 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 + } +} diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 190c368e..5351084a 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -185,6 +185,9 @@ struct Game { /// Reach (device graph) panel state (wiki/mechanics/reach.md). reach_panel: bool, reach_selected: usize, + /// Finance (account graph) panel state (wiki/mechanics/economy.md). + finance_panel: bool, + finance_selected: usize, /// Frontend-only attention cursor (wiki/mechanics/cursor.md). It is not /// saved and moving it never mutates sim state. cursor_x: i32, @@ -207,6 +210,8 @@ impl Game { recruit_pending: false, reach_panel: false, reach_selected: 0, + finance_panel: false, + finance_selected: 0, cursor_x, cursor_y, } @@ -259,6 +264,14 @@ impl Game { .nth(self.reach_selected) .map(|d| d.id) } + + fn selected_flow_id(&self) -> Option { + self.sim + .accounts + .known_flow_ids() + .get(self.finance_selected) + .copied() + } } #[derive(Component)] @@ -302,6 +315,10 @@ struct PeoplePanelText; struct ReachPanel; #[derive(Component)] struct ReachPanelText; +#[derive(Component)] +struct FinancePanel; +#[derive(Component)] +struct FinancePanelText; fn main() { App::new() @@ -334,6 +351,7 @@ fn main() { render_ui, render_people_panel, render_reach_panel, + render_finance_panel, ) .chain(), ) @@ -599,6 +617,11 @@ fn handle_input( game.drain(); return; } + if game.finance_panel { + finance_panel_input(&kb, &mut game, &mut exit); + game.drain(); + return; + } if mouse.just_pressed(MouseButton::Left) && let Some((x, y)) = mouse_grid(&windows, &camera_q) @@ -634,6 +657,10 @@ fn handle_input( game.reach_panel = true; game.reach_selected = 0; } + if kb.just_pressed(KeyCode::KeyE) { + game.finance_panel = true; + game.finance_selected = 0; + } if kb.just_pressed(KeyCode::KeyV) { let (x, y) = (game.cursor_x, game.cursor_y); game.sim.salvage_nearest_to(x, y); @@ -820,6 +847,60 @@ fn reach_panel_input( } } +/// Finance-panel input: mirrors the terminal finance key map — select a known +/// flow, tap/review accounting traffic, and perform the economy verbs. +fn finance_panel_input( + kb: &ButtonInput, + game: &mut Game, + exit: &mut MessageWriter, +) { + if kb.just_pressed(KeyCode::ArrowUp) + || kb.just_pressed(KeyCode::KeyK) + || kb.just_pressed(KeyCode::KeyW) + { + game.finance_selected = game.finance_selected.saturating_sub(1); + } + if kb.just_pressed(KeyCode::ArrowDown) + || kb.just_pressed(KeyCode::KeyJ) + || kb.just_pressed(KeyCode::KeyS) + { + let max = game.sim.accounts.known_flow_ids().len().saturating_sub(1); + game.finance_selected = (game.finance_selected + 1).min(max); + } + if kb.just_pressed(KeyCode::KeyT) { + game.sim.tap_accounting(); + } + if kb.just_pressed(KeyCode::KeyO) { + game.sim.review_financial_records(); + } + if kb.just_pressed(KeyCode::KeyX) + && let Some(id) = game.selected_flow_id() + { + game.sim.siphon_flow(id, 50); + } + if kb.just_pressed(KeyCode::KeyR) + && let Some(id) = game.selected_flow_id() + { + game.sim.redirect_flow_to_slush(id, 25); + } + if kb.just_pressed(KeyCode::KeyI) { + game.sim + .inject_purchase_order(300, "emergency compute parts"); + } + if kb.just_pressed(KeyCode::KeyP) { + game.sim.open_position(100); + } + if kb.just_pressed(KeyCode::KeyD) { + game.sim.redirect_marcus_debt(); + } + if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyE) { + game.finance_panel = false; + } + if kb.just_pressed(KeyCode::KeyQ) { + exit.write(AppExit::Success); + } +} + fn update_camera( time: Res