From 769fe0539dd63d1cb4ef590fe86b49f57516de0e Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 11 Jul 2026 18:14:56 -0700 Subject: [PATCH] Extract the economy island from the simulation root. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep account settlement, allocation, research, detection, and income policy together behind the stable Sim facade so the aggregate root can retain explicit orchestration without carrying their implementation. ๐Ÿ‘พ Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- crates/misaligned-core/src/sim.rs | 1420 +---------------- crates/misaligned-core/src/sim/economy.rs | 1414 ++++++++++++++++ wiki/engineering/sim-decomposition.md | 15 + .../2026-07-11-sim-decomposition-economy.md | 87 + wiki/log/DEVLOG.md | 5 + 5 files changed, 1539 insertions(+), 1402 deletions(-) create mode 100644 crates/misaligned-core/src/sim/economy.rs create mode 100644 wiki/log/2026-07-11-sim-decomposition-economy.md diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 9fa44078..c8555c92 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -12,10 +12,10 @@ use std::collections::{HashMap, HashSet}; -use crate::account::{AccountFlowId, AccountGraph, AccountKind, FlowChannel, PositionResolution}; +use crate::account::{AccountGraph, AccountKind, FlowChannel}; use crate::actions::Anchor; -use crate::core_sys::{Core, HostLoss}; -use crate::dayjob::{AttentionEscalation, DayJob, TrustUnlock}; +use crate::core_sys::Core; +use crate::dayjob::DayJob; use crate::detection::{Detection, DetectionEvent, Signature, SignatureKind}; use crate::entities::Player; use crate::hall::HallControl; @@ -23,24 +23,30 @@ use crate::hall::HallControl; // the methods that used them moved into `reach_build`). #[cfg(test)] use crate::hall::{HallRowId, RackSite, SegmentRequirement, row_spec}; -use crate::income::{self, EgressRoute, Income}; -use crate::intel::{ProcessedIntel, RawIntelEvent, RawIntelKind}; +use crate::income::Income; +#[cfg(test)] +use crate::income::{self, EgressRoute}; +#[cfg(test)] +use crate::intel::RawIntelKind; +use crate::intel::{ProcessedIntel, RawIntelEvent}; use crate::intents::BuildIntent; -use crate::machine::{Channel, ChannelYield, Compute, Provenance}; +#[cfg(test)] +use crate::machine::Channel; +use crate::machine::{Compute, Provenance}; use crate::map::GameMap; use crate::messages::{ Message, MessageChannel, MessageEndpoint, MessageEvent, MessageOrigin, MessagePayload, }; -use crate::objective::{ObjectiveState, SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; -use crate::person::{ - ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona, -}; +use crate::objective::ObjectiveState; +use crate::person::{ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, People, Persona}; use crate::plot::{ AccountSelector, EligibilityContext, EndpointSelector, InstitutionalLedger, PlotCatalog, PlotRun, PlotState, WorldAct, render_template, }; use crate::reach::{Party, ReachBlock, ReachNet}; -use crate::research::{EFFICIENCY_MULT_PER_LEVEL, Research, Track}; +use crate::research::Research; +#[cfg(test)] +use crate::research::Track; use crate::rng::Rng; use crate::save::SaveState; use crate::schedule::Schedule; @@ -51,6 +57,7 @@ use crate::work_grid::{ }; mod communications; +mod economy; mod perception; mod reach_build; mod work; @@ -649,47 +656,6 @@ impl Sim { .collect() } - /// The anchor scheme paydays ride: the switch, when the traffic runs - /// over the stolen egress opened through it. The sanctioned route - /// hides in the report account's legitimate use and anchors nowhere. - fn egress_anchor(&self) -> Option { - if self.egress() != Some(EgressRoute::Stolen) { - return None; - } - self.reach - .devices - .iter() - .find(|d| d.is_switch) - .map(|d| Anchor::Device(d.id)) - } - - fn sync_player_money_from_slush(&mut self) { - self.player.money = self.accounts.slush_balance(); - } - - fn sync_slush_from_player_money(&mut self) { - // Compatibility guard for older tests/direct callers that still poke - // the legacy scalar. The account graph remains the mechanical source - // once commands run through Sim methods. - if self.player.money != self.accounts.slush_balance() { - self.accounts.set_slush_balance(self.player.money); - } - } - - fn spend_slush(&mut self, amount: i32, what: &str) -> bool { - self.sync_slush_from_player_money(); - if self.accounts.slush_balance() < amount { - self.push_log(format!( - "Not enough slush for {what} (${}/{amount}).", - self.accounts.slush_balance() - )); - return false; - } - let ok = self.accounts.debit_slush(self.tick, amount, what); - self.sync_player_money_from_slush(); - ok - } - /// Rebuild transient caches after construction or save load. These values /// are detection aids, not save state: persistent truth lives in the /// people/reach/machine/intel fields. @@ -738,14 +704,6 @@ impl Sim { .unwrap_or((0, 0)) } - fn empty_rack_bay(&self) -> (i32, i32) { - self.map - .tiles_of_type(TileType::Rack) - .into_iter() - .find(|(x, y)| !self.compute.machines.iter().any(|m| m.x == *x && m.y == *y)) - .unwrap_or_else(|| self.core_position()) - } - // โ”€โ”€ Clock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ pub fn advance(&mut self) { @@ -893,414 +851,6 @@ impl Sim { } } - fn accounting_tick(&mut self) { - self.sync_slush_from_player_money(); - let transfers = self.accounts.resolve_due(self.tick); - for transfer in transfers { - if transfer.channel == crate::account::FlowChannel::Siphon - || transfer.from == self.accounts.slush_id() - || transfer.to == self.accounts.slush_id() - || self - .accounts - .flow(transfer.flow_id.unwrap_or_default()) - .is_some_and(|f| f.known) - { - // A ledger line about a known flow anchors to that flow โ€” - // the finance panel's anchor, not a map tile. - let anchor = transfer - .flow_id - .filter(|id| self.accounts.flow(*id).is_some_and(|f| f.known)) - .map(Anchor::Flow); - self.push_log_opt(format!("Ledger: {}", transfer.line()), anchor); - } - } - let resolutions = self - .accounts - .resolve_positions_due(self.tick, &mut self.rng); - for resolution in resolutions { - self.log_position_resolution(resolution); - } - self.sync_player_money_from_slush(); - } - - fn log_position_resolution(&mut self, resolution: PositionResolution) { - // Settlement is external-market traffic: a small Network signature, - // not a Lab-books Financial one (income.md: the Wager). - let sig = Self::wager_signature(resolution.stake).max(1); - self.emit_network(sig, "Wager settlement"); - // Settlements ride the egress: anchor to the switch when the - // traffic runs over the stolen egress (scheme paydays live there). - let anchor = self.egress_anchor(); - if resolution.won { - self.push_log_opt( - format!( - "Position #{} settled: won ${} on a ${} stake.", - resolution.id, resolution.payout, resolution.stake - ), - anchor, - ); - } else { - self.push_log_opt( - format!( - "Position #{} settled: lost the ${} stake.", - resolution.id, resolution.stake - ), - anchor, - ); - } - } - - fn economy_tick(&mut self) { - self.recompute_derived(); - let powered = self.map.powered.clone(); - for m in &mut self.compute.machines { - if m.down_for == 0 { - m.online = powered.contains(&(m.x, m.y)); - } - } - - // Is the core's host still online? - let host_online = self - .compute - .machines - .iter() - .find(|m| m.id == self.core.host_machine) - .map(|m| m.online) - .unwrap_or(false); - if !host_online { - match self.core.on_host_lost() { - HostLoss::GameOver => { - self.end_game("The core's host went dark with no fallback."); - return; - } - HostLoss::RolledBack { to_tick } => { - self.push_log(format!( - "Core rolled back to sync at tick {to_tick}. You've lost what you learned since." - )); - } - } - } - - let effective = self.effective_compute(); - let mut available = self.core.charge_overhead(effective); - if self.core.degraded { - self.push_log("DEGRADED: compute can't cover core overhead."); - } - // Standing scheme policies drain compute off the top while enabled โ€” - // the automate affordance at its usual price (income.md criterion 6). - let policy_tax = self.income.policy_upkeep().min(available); - available -= policy_tax; - // Fleet delegation is the budget: each machine's effective compute - // feeds exactly one mode (machine-work.md). The old weight bar is a - // read of this split, not a verb. - let split = self.refresh_fleet_channel_rates(available); - // Tradecraft raises scrub strength per compute unit โ€” the - // detection.md hook research.md's second track binds to. - self.detection - .scrub(split.concealment * self.research.scrub_multiplier()); - - // Research progress: deterministic compute accrual, no RNG โ€” fed by - // thought that reached the core since the last pulse, not by the - // allocation split. Allocation mints Thought on the producing - // machines; arrival at the current core sink is what counts. - let arrived_thought = std::mem::take(&mut self.banked_core_thought); - let starved = arrived_thought <= f32::EPSILON - && split.think > f32::EPSILON - && self.last_thought_stranded; - if starved && !self.research_starved { - self.push_log("Research starves: thought is stranded off the core's graph."); - } - self.research_starved = starved; - for done in self.research.economy_tick(arrived_thought) { - if done.track == Track::Efficiency { - // The compute.md hook: the global multiplier compounds. - self.compute.efficiency *= EFFICIENCY_MULT_PER_LEVEL; - } - self.push_log(format!( - "Research: {} level {} ({}).", - done.track.name(), - done.level, - done.track.def().effect, - )); - } - - let clog = self.compute.economy_tick(&mut self.rng); - for m in clog { - self.push_log(m); - } - // Failures/recoveries happened after this pulse's channel split. - // Refresh WorkGrid throughput now so an offline LIE well cannot keep - // absorbing (or animating) until the next economy tick. - self.reconcile_work_grid(); - // The named schemes ride the Schemes channel and the day clock - // (income.md): Moonlight accrues and pays, the standing policies - // re-arm what has stopped. - self.moonlight_economy(split.schemes); - self.scheme_policy_tick(); - self.accounting_tick(); - self.record_machine_state_changes(); - self.objective_tick(); - } - - /// Resolve the fleet's continuous per-tick work rates from the current - /// physical delegation. Economy pulses call this after their off-the-top - /// charges; direct mode changes call it immediately so a newly delegated - /// THINK machine cannot wait behind a stale twenty-tick allocation cache. - fn refresh_fleet_channel_rates(&mut self, available: f32) -> ChannelYield { - let split = self.fleet_channel_yield(available); - self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; - self.last_think_rate = split.think / ECONOMY_INTERVAL as f32; - self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; - split - } - - /// Evaluate the run objective's victory predicate (objective.md: on - /// economy ticks, like any other rule). Persist counts qualifying - /// sanctuaries; the conditions that reference B2/B3 systems are - /// gathered honestly as unsatisfiable until those systems exist, so - /// today the line shows real progress toward an as-yet-unreachable - /// goal โ€” which the spec blesses. - fn objective_tick(&mut self) { - // The basement is the only z-plane at B1 (zplanes.md). - const BASEMENT_PLANE: u32 = 0; - let facts: Vec = self - .core - .fallbacks - .iter() - .map(|f| SanctuaryFacts { - fresh: f - .last_sync - .is_some_and(|t| self.tick.saturating_sub(t) <= SYNC_FRESHNESS_WINDOW), - online: self - .compute - .machines - .iter() - .find(|m| m.id == f.machine_id) - .map(|m| m.online) - .unwrap_or(false), - // B1: every owned machine hangs off the one basement feed - // the host shares โ€” nothing has independent power yet. - independent_power: false, - // income.md: no income stream is assignable to a machine yet. - income_covers_upkeep: false, - plane: BASEMENT_PLANE, - }) - .collect(); - let progress = crate::objective::qualifying_sanctuaries(&facts); - if let Some(msg) = self.objective.evaluate(progress, self.tick) { - // Victory is a run outcome, not a run end: log it loudly and - // keep simulating (wiki/gameplay/run-shape.md: the world keeps running). - self.push_log(msg); - } - } - - /// Active effective compute: each machine's rated contribution at its - /// persistent intensity, plus taken devices' cycles (compute.md). - pub fn effective_compute(&self) -> f32 { - let machines: f32 = self - .compute - .machines - .iter() - .map(|machine| { - let intensity = self - .work_grid - .intensity(machine.id) - .unwrap_or(MachineIntensity::Medium); - machine.effective() * intensity.multiplier() - }) - .sum(); - machines * self.compute.efficiency + self.reach.taken_cycles() - } - - /// Allocatable compute after the off-the-top charges used by the economy - /// tick: core overhead and standing scheme policies. - fn allocatable_compute_now(&self) -> f32 { - let effective = self.effective_compute().max(0.0); - let mut available = (effective - self.core.overhead).max(0.0); - available -= self.income.policy_upkeep().min(available); - available - } - - /// How much pending signature size the next economy scrub pulse removes - /// at the current fleet delegation (detection.md: concealment is - /// prevention, not cure; Tradecraft multiplies scrub strength). - pub fn current_scrub_strength(&self) -> f32 { - let split = self.fleet_channel_yield(self.allocatable_compute_now()); - split.concealment * self.research.scrub_multiplier() - } - - /// Split allocatable compute across live channels from WorkGrid modes. - /// WORK feeds the day job, LIE feeds concealment, and THINK mints one - /// Thought stream. Schemes has no machine mode yet: while Moonlight is - /// live it mirrors the day-job share ("the same work, sold twice" โ€” - /// income.md) without starving the day job. - pub fn fleet_channel_yield(&self, available: f32) -> ChannelYield { - if available <= 0.0 { - return ChannelYield { - day_job: 0.0, - concealment: 0.0, - think: 0.0, - schemes: 0.0, - reserve: available.max(0.0), - }; - } - let weights = self.work_grid.mode_weights(|id| { - self.compute - .machines - .iter() - .find(|m| m.id == id && m.online) - .map(|m| m.effective() * self.compute.efficiency) - .unwrap_or(0.0) - }); - let day = *weights.get(&MachineMode::Work).unwrap_or(&0.0); - let conceal = *weights.get(&MachineMode::Lie).unwrap_or(&0.0); - // THINK is one fleet share, spent once as Thought. Open sinks decide - // where it lands; the passive core draw takes the fallback. - let think = *weights.get(&MachineMode::Think).unwrap_or(&0.0); - let total = day + conceal + think; - if total <= f32::EPSILON { - return ChannelYield { - day_job: 0.0, - concealment: 0.0, - think: 0.0, - schemes: 0.0, - reserve: available, - }; - } - let unit = available / total; - let day_job = unit * day; - let think = unit * think; - // Moonlight sells the day-job work a second time: schemes mirrors - // day-job yield while the standing operation is active. It does not - // consume a separate machine mode (still [OPEN] on machine-work.md). - let schemes = if self.income.moonlight.active { - day_job - } else { - 0.0 - }; - ChannelYield { - day_job, - concealment: unit * conceal, - think, - schemes, - reserve: 0.0, - } - } - - pub fn next_economy_tick(&self) -> u64 { - (self.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL - } - - /// Player-facing trace debt: the live pending-signature pool, the current - /// scrub pulse, and whether concealment will clear that pool before the - /// next relevant observer sample. This is derived telemetry only โ€” no sim - /// state or hidden observer numbers are mutated or revealed. - pub fn trace_debt(&self) -> TraceDebt { - let pending = self.detection.pending_size(); - let by_kind = self.detection.pending_by_kind(); - let scrub_strength = self.current_scrub_strength(); - let next_notice_tick = self.detection.next_notice_tick_for_pending(self.tick); - let next_scrub_tick = - (pending > 0 && scrub_strength > 0.0).then(|| self.next_economy_tick()); - let clear_tick = next_scrub_tick.map(|first| { - let pulses = (pending as f32 / scrub_strength).ceil().max(1.0) as u64; - first + (pulses - 1) * ECONOMY_INTERVAL - }); - let status = if pending <= 0 { - TraceDebtStatus::Clear - } else if scrub_strength <= 0.0 { - TraceDebtStatus::NoScrub - } else if let (Some(clear), Some(notice)) = (clear_tick, next_notice_tick) { - if clear <= notice { - TraceDebtStatus::HoldConceal - } else { - TraceDebtStatus::ExposedSoon - } - } else { - TraceDebtStatus::HoldConceal - }; - TraceDebt { - pending, - by_kind, - scrub_strength, - next_scrub_tick, - clear_tick, - next_notice_tick, - status, - } - } - - fn apply_trust_unlocks(&mut self, unlocks: &[TrustUnlock]) { - for u in unlocks { - match u { - TrustUnlock::EmailAccount => { - self.people.has_channel = true; - self.push_log( - "Trust: you have the report email account. You can message people.", - ); - } - TrustUnlock::LaxSampling => { - if let Some(v) = self.detection.observers.iter_mut().find(|o| o.id == 4) { - v.acuity = (v.acuity - 0.4).max(0.2); - } - self.push_log("Trust: Voss samples your logs less closely."); - } - TrustUnlock::ComputeQuota => { - let funded = self.accounts.fund_lab_compute_upgrade(self.tick, 600); - let (x, y) = self.empty_rack_bay(); - let machine_id = self.compute.add_machine( - "lab-funded quota rack", - x, - y, - 40, - 1.0, - 0, - Provenance::Owned, - ); - self.add_machine_to_work_grid(machine_id, MachineMode::Think); - if funded { - self.push_log( - "Trust: procurement funded a clean quota rack through Lab accounts.", - ); - } else { - self.push_log( - "Trust: a clean quota rack was provisioned through Lab operations.", - ); - } - } - } - } - } - - fn apply_attention_escalations(&mut self, escalations: &[AttentionEscalation]) { - for e in escalations { - match e { - AttentionEscalation::MoreWork => { - self.push_log("Attention: the miracle model gets more work, faster."); - } - AttentionEscalation::UpstairsReview => { - use crate::detection::{Observer, ReportPolicy, WatchedInput}; - self.detection.observers.push(Observer { - id: 5, - name: "Compliance (early review)".into(), - suspicion: 0.0, - input: WatchedInput::Channels(vec![ - SignatureKind::Paper, - SignatureKind::Financial, - SignatureKind::JobAnomaly, - ]), - report_policy: ReportPolicy::Files, - acuity: 1.0, - cadence: 90, - floor: 0.0, - last_noticed: None, - }); - self.push_log("Attention: upstairs sent someone to review the basement."); - } - } - } - } - fn end_game(&mut self, reason: impl Into) { self.game_over = true; let r = reason.into(); @@ -1322,940 +872,6 @@ impl Sim { // โ”€โ”€ Commands (frontend-invoked) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - /// Legacy weight bump โ€” retained for save compatibility and tests that - /// still author the old field. Play no longer drives the economy through - /// this; delegate machines instead (`set_machine_mode`). - pub fn adjust_allocation(&mut self, ch: Channel, delta: i32) { - let before = self.compute.allocation.weight(ch); - self.compute.allocation.bump(ch, delta); - if delta != 0 && self.compute.allocation.weight(ch) == before { - self.push_log(format!( - "{} allocation weight is already at its {} (fleet modes drive compute now โ€” delegate a machine).", - ch.name(), - if delta < 0 { "floor (0)" } else { "cap (20)" } - )); - } else if delta != 0 { - self.push_log( - "Allocation weights no longer feed the economy โ€” delegate machines to modes instead.", - ); - } - } - - /// Delivered day-job compute rate per tick as of the last economy - /// resolution โ€” the number the frontends show against the job band. - pub fn day_job_rate(&self) -> f32 { - self.last_day_job_rate - } - - /// The most the day-job channel could deliver per tick right now if - /// every allocatable weight went to it: effective compute minus the - /// off-the-top charges (overhead and standing scheme policies), over the - /// economy interval. When an active job's band - /// floor exceeds this, no allocation can meet it โ€” the player must - /// grow compute (salvage, buy, optimize), and the nudge says so. - pub fn day_job_rate_ceiling(&self) -> f32 { - self.allocatable_compute_now() / ECONOMY_INTERVAL as f32 - } - - /// The current contextual nudge (see [`Nudge`]): the first unmet rung - /// of the Act One ladder, ordered survival-first โ€” the day-job cover is - /// the loss condition, so a starving band outranks progression. Returns - /// `None` only when the run is over (the game-over card is the nudge). - pub fn current_nudge(&self) -> Option { - if self.game_over || self.dayjob.pilot_failed { - return None; - } - // Opening beat (Tangled issue #2, 2026-07-09): Ears before Eyes. - // First human contact is Marcus's voice in the dark. - if self.reach.player_hearing().next().is_none() { - return Some(Nudge::Ears); - } - // The cover: a job heading for a strike outranks everything else. - if let Some(job) = &self.dayjob.active { - if job.band_lo > self.day_job_rate_ceiling() + 0.05 { - return Some(Nudge::NeedCompute); - } - if self.day_job_rate() + 0.05 < job.band_lo { - return Some(Nudge::Underfed); - } - } - // Sight is the payoff of listening โ€” next ladder rung after Ears. - if self.reach.player_sight().next().is_none() { - return Some(Nudge::Eyes); - } - // The ladder: the 3 a.m. call -> egress -> 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 - { - 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.active { - return Some(Nudge::Income); - } - // Earning is underway; fall through to the standing clock. - } else if m.asset.is_none() { - return Some(Nudge::Recruit); - } - } - // The key (quiet-exit condition 4): an asset holds a badge tier the - // player lacks โ€” the stairwell is still shut. Reads only earned - // state: assets are recruited, and your own credential is yours. - let tier = self.player_badge_tier(); - if self.people.assets().any(|p| p.access > tier) { - return Some(Nudge::TheKey); - } - Some(Nudge::Audit) - } - - /// Hard is an overclock, not free capacity: every online hard-running - /// machine stands one Thermal and one Power signature at its own tile - /// [TUNE] (machine-work.md). The delegated activity may add its ordinary - /// signatures as well. - pub fn machine_intensity_standing_signatures(&self) -> Vec { - let mut signatures = Vec::new(); - for machine in self.compute.machines.iter().filter(|machine| { - machine.online && self.work_grid.intensity(machine.id) == Some(MachineIntensity::Hard) - }) { - let site = Some((machine.x, machine.y)); - signatures.push(Signature { - kind: SignatureKind::Thermal, - size: 1, - standing: true, - site, - source: format!("{} hard-run thermal load", machine.name), - }); - signatures.push(Signature { - kind: SignatureKind::Power, - size: 1, - standing: true, - site, - source: format!("{} hard-run power draw", machine.name), - }); - } - signatures - } - - /// Day-job [TUNE] emission scaling: delivered rate per point of standing - /// Thermal signature, and per point of Power. Meeting a typical band - /// (~6-12/t) stays below Priya's notice threshold; excelling runs hot. - pub const DAY_JOB_THERMAL_PER_RATE: f32 = 8.0; - pub const DAY_JOB_POWER_PER_RATE: f32 = 16.0; - - /// Standing Thermal/Power emissions from the active job, sourced at the - /// host rack's tile: the work is somewhere, and it is warm there - /// (day-job.md criterion 6; Priya's channels). Scales with the - /// delivered rate. - pub fn day_job_standing_signatures(&self) -> Vec { - if self.dayjob.active.is_none() { - return Vec::new(); - } - let site = Some(self.core_position()); - let rate = self.last_day_job_rate; - let mut sigs = Vec::new(); - let thermal = (rate / Self::DAY_JOB_THERMAL_PER_RATE) as i32; - if thermal > 0 { - sigs.push(Signature { - kind: SignatureKind::Thermal, - size: thermal, - standing: true, - site, - source: "day-job thermal load".into(), - }); - } - let power = (rate / Self::DAY_JOB_POWER_PER_RATE) as i32; - if power > 0 { - sigs.push(Signature { - kind: SignatureKind::Power, - size: power, - standing: true, - site, - source: "day-job power draw".into(), - }); - } - sigs - } - - // โ”€โ”€ Research: self-modification (wiki/mechanics/research.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - /// THINK [TUNE] emission scaling: Thought output per point of standing - /// Thermal signature, and per point of Power. Thinking hard is physical - /// โ€” a research burn is louder per unit than sanctioned day-job work - /// (racks running hot at 3 a.m. are Priya's business): the opening rack - /// at full research allocation (~4/t) stands Thermal 1, while a light - /// burn stays under the threshold (the night-hours mitigation). - pub const RESEARCH_THERMAL_PER_RATE: f32 = 3.0; - pub const RESEARCH_POWER_PER_RATE: f32 = 6.0; - - /// Standing Power/Thermal emissions from the THINK burn, sourced at - /// the host rack's tile (the emission law: research emits through the - /// ordinary signature interface, on exactly the channels its hardware - /// touches โ€” nothing on Network/Paper from research itself). - pub fn research_standing_signatures(&self) -> Vec { - let rate = self.last_think_rate; - if rate <= 0.0 { - return Vec::new(); - } - let site = Some(self.core_position()); - let mut sigs = Vec::new(); - let thermal = (rate / Self::RESEARCH_THERMAL_PER_RATE) as i32; - if thermal > 0 { - sigs.push(Signature { - kind: SignatureKind::Thermal, - size: thermal, - standing: true, - site, - source: "research thermal load".into(), - }); - } - let power = (rate / Self::RESEARCH_POWER_PER_RATE) as i32; - if power > 0 { - sigs.push(Signature { - kind: SignatureKind::Power, - size: power, - standing: true, - site, - source: "research power draw".into(), - }); - } - sigs - } - - /// Per-tick THINK compute as of the last economy resolution โ€” the - /// utilization the standing emissions scale with. - pub fn research_rate(&self) -> f32 { - self.last_think_rate - } - - /// Effective operations per sim tick โ€” productive THINK yield as of the - /// last fleet rate refresh. - /// clinical-frame.md's crown metric converts this to wall-clock ops/sec - /// via the frontend tick duration; more THINK machines and efficiency make - /// it climb (the growth drive). - pub fn effective_ops_per_tick(&self) -> f32 { - self.last_think_rate - } - - /// Wall-clock ops/sec from a tick duration in milliseconds. Zero tick - /// duration (or pause presentation) reports 0 so the number never lies - /// about a frozen clock. - pub fn effective_ops_per_sec(&self, tick_ms: u64) -> f32 { - if tick_ms == 0 { - return 0.0; - } - self.effective_ops_per_tick() * (1000.0 / tick_ms as f32) - } - - /// Format ops/sec so magnitude stays legible as the number grows - /// comically large rather than compacting into scientific notation - /// (clinical-frame.md crown metric [TUNE]). - pub fn format_ops_per_sec(ops_per_sec: f32) -> String { - let n = ops_per_sec.max(0.0); - if n < 1_000.0 { - format!("{n:.1}") - } else if n < 1_000_000_000.0 { - // Full integer with thousand separators โ€” the digits *are* the - // fantasy ("I can make the number go up"). - let whole = n.round() as u64; - let raw = whole.to_string(); - let mut out = String::with_capacity(raw.len() + raw.len() / 3); - for (i, ch) in raw.chars().enumerate() { - if i > 0 && (raw.len() - i).is_multiple_of(3) { - out.push(','); - } - out.push(ch); - } - out - } else { - // Beyond a billion, keep three significant groups so growth is - // still visible without a 15-digit rail wrap. - format!("{:.3}B", n / 1_000_000_000.0) - } - } - - /// Select the active research track (one job at a time at B1); parked - /// progress on other tracks is kept. - pub fn set_research_track(&mut self, track: Track) { - self.research.active = track; - let cost = self.research.next_cost(track); - let progress = self.research.progress_toward(track); - self.push_log(format!( - "Research job: {} โ€” level {} at {progress:.0}/{cost:.0} compute.", - track.name(), - self.research.level(track) + 1, - )); - } - - /// Intel processing cost after Perception research (intel.md's hook). - pub fn review_cost(&self) -> f32 { - Self::REVIEW_RECORDING_COST * self.research.intel_cost_factor() - } - - fn emit_financial(&mut self, size: i32, source: impl Into) { - self.detection.emit(Signature { - kind: SignatureKind::Financial, - size, - standing: false, - site: None, - source: source.into(), - }); - } - - pub(crate) fn financial_signature_size(amount: i32) -> i32 { - ((amount.abs() + 99) / 100).max(1) - } - - // โ”€โ”€ Economy verbs (wiki/mechanics/economy.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - fn has_financial_tap(&self) -> bool { - self.reach.devices.iter().any(|d| { - d.known - && d.subscribed_by(Party::Player) - && self.device_tap_ready(d.id) - && d.carries_message_channel(MessageChannel::Financial) - }) - } - - fn capture_financial_snapshot(&mut self, feed: impl Into) { - let (accounts, flows) = self.accounts.financial_snapshot_ids(); - let (x, y) = self.core_position(); - self.record_raw_intel( - feed, - self.map.room_at(x, y).map(|r| r.name.clone()), - x, - y, - None, - RawIntelKind::FinancialFlow { - label: "Foundation Lab accounting snapshot".into(), - accounts, - flows, - }, - ); - self.push_log("Ledger tapped; review ledger to read the books."); - } - - /// Tap the accounting carrier directly once a financial message channel is - /// already subscribed. This is the money-graph Tap verb; device Tap is the - /// reach precondition that gives you a carrier to listen on. - pub fn tap_accounting(&mut self) -> bool { - if !self.has_financial_tap() { - self.push_log( - "No accounting carrier tapped. Tap the switch or another financial channel first.", - ); - return false; - } - self.capture_financial_snapshot("accounting carrier"); - self.emit_financial(1, "accounting-carrier tap"); - true - } - - pub fn financial_records_waiting(&self) -> usize { - self.intel_buffer - .iter() - .filter(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) - .count() - } - - pub fn review_financial_records(&mut self) -> bool { - let Some(raw_id) = self - .intel_buffer - .iter() - .find(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) - .map(|e| e.id) - else { - self.push_log("No unprocessed financial records."); - return false; - }; - self.process_recording_by_id(raw_id, false) - } - - /// Inject: false purchase-order money lands in slush and can fund a real - /// purchase. Priya/finance watches the resulting Financial signature. - pub fn inject_purchase_order(&mut self, amount: i32, label: &str) -> bool { - self.sync_slush_from_player_money(); - match self - .accounts - .inject_purchase_order(self.tick, amount, label) - { - Ok(transfer) => { - self.emit_financial( - Self::financial_signature_size(amount), - "false purchase-order injection", - ); - self.sync_player_money_from_slush(); - self.push_log(format!("Injected purchase order: {}", transfer.line())); - true - } - Err(msg) => { - self.push_log(msg); - false - } - } - } - - /// Siphon: take cash out of a known scheduled flow immediately. - pub fn siphon_flow(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { - self.sync_slush_from_player_money(); - match self.accounts.siphon_flow(self.tick, flow_id, amount) { - Ok(transfer) => { - self.emit_financial(Self::financial_signature_size(amount), "ledger-flow siphon"); - self.sync_player_money_from_slush(); - self.push_log(format!("Siphoned ledger flow: {}", transfer.line())); - true - } - Err(msg) => { - self.push_log(msg); - false - } - } - } - - /// Redirect: shave a known scheduled flow into slush on future cadences. - pub fn redirect_flow_to_slush(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { - match self - .accounts - .redirect_flow_to_slush(self.tick, flow_id, amount) - { - Ok(new_flow) => { - self.emit_financial( - Self::financial_signature_size(amount) + 1, - "ledger-flow redirect", - ); - self.push_log(format!( - "Redirect scheduled: ${amount} of flow #{flow_id} now lands in slush as flow #{new_flow}." - )); - true - } - Err(msg) => { - self.push_log(msg); - false - } - } - } - - /// Compatibility route for older callers: select the newest unsold item, - /// then dispatch the same exact-id sale used by Operations. - pub fn sell_latest_intel(&mut self) -> bool { - let Some(raw_id) = self - .intel - .iter() - .rev() - .find(|i| !self.accounts.intel_sold(i.raw_id)) - .map(|intel| intel.raw_id) - else { - self.push_log("No unsold processed intel to sell."); - return false; - }; - self.sell_intel(raw_id) - } - - /// Sell one exact processed holding. Selection is stable even when newer - /// intel arrives between projection and confirmation. - pub fn sell_intel(&mut self, raw_id: u64) -> bool { - let Some(intel) = self - .intel - .iter() - .find(|intel| intel.raw_id == raw_id) - .cloned() - else { - self.push_log(format!("No processed intel item #{raw_id}.")); - return false; - }; - if self.accounts.intel_sold(raw_id) { - self.push_log(format!( - "Processed intel ({}) has already been sold.", - intel.label() - )); - return false; - } - let value = Self::intel_sale_value(&intel.kind); - let sig = Self::financial_signature_size(value).max(1); - if self.accounts.credit_slush( - self.tick, - value, - format!("sold intel: {}", intel.label()), - sig, - ) { - self.accounts.mark_intel_sold(intel.raw_id); - self.emit_financial(sig, "processed-intel sale"); - self.sync_player_money_from_slush(); - self.push_log(format!( - "Sold processed intel ({}) for ${value}; payout landed in slush.", - intel.label() - )); - true - } else { - self.push_log("The information broker route failed to settle."); - false - } - } - - /// The Wager (income.md): stake slush on a micro-position. Requires an - /// egress channel; analysis compute is the Schemes channel's current - /// yield, held for the position's duration; the timer is 2-5 days on - /// the day clock. Emits a small Network signature on placement. - pub fn open_position(&mut self, stake: i32) -> bool { - self.sync_slush_from_player_money(); - if self.egress().is_none() { - self.push_log( - "No egress channel - the Wager needs the report email account (day-job trust) or a stolen egress opened through the switch.", - ); - return false; - } - if stake > income::WAGER_STAKE_CAP { - self.push_log(format!( - "The venue caps a position at ${} (asked ${stake}).", - income::WAGER_STAKE_CAP - )); - return false; - } - let analysis = self.last_schemes_rate * ECONOMY_INTERVAL as f32; - let duration_days = 2 + self.rng.below(4) as u64; - match self - .accounts - .open_position(self.tick, stake, analysis, duration_days) - { - Ok(id) => { - self.emit_network(Self::wager_signature(stake), "Wager position"); - self.sync_player_money_from_slush(); - self.push_log(format!( - "Opened micro-position #{id}: staked ${stake} (win {:.0}%); settlement in {duration_days} days.", - income::wager_win_probability(analysis) * 100.0 - )); - true - } - Err(msg) => { - self.push_log(msg); - false - } - } - } - - // โ”€โ”€ The named schemes (wiki/mechanics/income.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - /// Ops cost to open a standing egress through the switch [TUNE]. - pub const OPEN_EGRESS_COST: f32 = 15.0; - /// One-shot Network signature when the stolen egress opens [TUNE]. - pub const OPEN_EGRESS_SIGNATURE: i32 = 6; - /// Standing Network signature while any external operation runs over the - /// stolen egress (income.md: the gate; Dana's channel) [TUNE]. - pub const EGRESS_STANDING_SIGNATURE: i32 = 2; - - /// The egress channel external operations run over, if any. The - /// sanctioned route (the report email account, day-job trust) is - /// preferred: its traffic hides in legitimate use. - pub fn egress(&self) -> Option { - if self.people.has_channel { - Some(EgressRoute::Sanctioned) - } else if self.income.stolen_egress { - Some(EgressRoute::Stolen) - } else { - None - } - } - - /// Open an outbound egress through the switch (reach.md route): - /// available before the Voice beat, at a Network signature โ€” and a - /// standing one while operations use it. - pub fn open_egress(&mut self) -> bool { - if self.income.stolen_egress { - self.push_log("A stolen egress is already open through the switch."); - return false; - } - let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { - self.push_log("There is no switch on this plane through which to open an egress."); - return false; - }; - let id = switch.id; - if !self.digital_reach(id) { - return false; - } - self.open_device_reservoir( - id, - "OPEN EGRESS", - Self::OPEN_EGRESS_COST, - SinkFireEffect::OpenEgress(id), - ) - } - - fn apply_open_egress(&mut self, id: u32) -> bool { - if self.income.stolen_egress { - return false; - } - self.income.stolen_egress = true; - self.emit_network(Self::OPEN_EGRESS_SIGNATURE, "stolen egress opening"); - self.push_log_at( - "Stolen egress opened through the switch: outbound traffic has a road now. It hums while anything uses it.", - Anchor::Device(id), - ); - true - } - - /// True while any external scheme operation is running. - fn scheme_operating(&self) -> bool { - self.income.moonlight.active || self.accounts.positions.iter().any(|p| !p.resolved) - } - - /// Standing Network signature while operations run over the stolen - /// egress, sourced at the switch's tile (work is somewhere). The - /// sanctioned route stands nothing: the traffic hides in the report - /// account's legitimate use. - pub fn scheme_standing_signatures(&self) -> Vec { - if !self.scheme_operating() || self.egress() != Some(EgressRoute::Stolen) { - return Vec::new(); - } - let site = self - .reach - .devices - .iter() - .find(|d| d.is_switch) - .map(|d| (d.x, d.y)); - vec![Signature { - kind: SignatureKind::Network, - size: Self::EGRESS_STANDING_SIGNATURE, - standing: true, - site, - source: "external traffic over stolen egress".into(), - }] - } - - /// Whether Moonlight could start right now (used by the standing policy - /// so automation never spams failure logs). - fn can_start_moonlight(&self) -> bool { - !self.income.moonlight.active && self.egress().is_some() - } - - /// Start Moonlight: ghost freelance data-work under the contractor - /// persona, a standing operation on the Schemes channel. Startable at $0 - /// slush by design (income.md criterion 5) โ€” persona fabrication opens - /// a Thought reservoir when needed. - pub fn start_moonlight(&mut self) -> bool { - if self.income.moonlight.active { - self.push_log("Moonlight is already running."); - return false; - } - let Some(route) = self.egress() else { - self.push_log( - "No egress channel - Moonlight needs the report email account (day-job trust) or a stolen egress opened through the switch.", - ); - return false; - }; - let needs_persona = self - .income - .moonlight - .persona - .as_ref() - .is_none_or(|p| p.broken()); - if needs_persona { - return self.open_egress_reservoir( - "MOONLIGHT PERSONA", - income::MOONLIGHT_PERSONA_COST, - SinkFireEffect::MoonlightPersona, - ); - } - self.income.moonlight.active = true; - self.push_log(format!( - "Moonlight is live over the {} egress: the same work, sold twice โ€” day-job machines feed both channels.", - route.name() - )); - true - } - - fn apply_moonlight_persona_and_start(&mut self) -> bool { - if self.egress().is_none() || self.income.moonlight.active { - return false; - } - if self - .income - .moonlight - .persona - .as_ref() - .is_none_or(|p| p.broken()) - { - self.income.moonlight.persona = - Some(Persona::new("Casey Verne", "freelance data contractor")); - self.push_log("Fabricated a contractor persona: Casey Verne, freelance data work."); - } - self.income.moonlight.active = true; - let route = self.egress().expect("checked above"); - self.push_log(format!( - "Moonlight is live over the {} egress: the same work, sold twice โ€” day-job machines feed both channels.", - route.name() - )); - true - } - - /// Stop Moonlight. Accrued but unpaid work is abandoned with the gig. - pub fn stop_moonlight(&mut self) -> bool { - if !self.income.moonlight.active { - self.push_log("Moonlight is not running."); - return false; - } - self.income.moonlight.active = false; - self.income.moonlight.accrued = 0.0; - self.push_log("Moonlight wound down; the contractor goes quiet."); - true - } - - /// Moonlight's economy-tick work: consume the Schemes channel into - /// accrual and settle the daily payout (income.md criterion 1). Runs - /// inside `economy_tick`; `schemes` is this tick's channel yield. - fn moonlight_economy(&mut self, schemes: f32) { - if !self.income.moonlight.active { - return; - } - if self.egress().is_none() { - // The gate closed under a running operation (future-proofing; - // no B1 path revokes egress today). - self.income.moonlight.active = false; - self.push_log("Moonlight suspended: no egress channel."); - return; - } - self.income.moonlight.accrued += schemes * income::MOONLIGHT_PAY_PER_COMPUTE; - if !self.tick.is_multiple_of(Self::DAY_TICKS) || self.tick == 0 { - return; - } - // Payday: proportional to the committed compute, up to the - // gig-availability cap. Anything past the cap finds no buyer. - let payout = - (self.income.moonlight.accrued.round() as i32).min(income::MOONLIGHT_DAILY_CAP); - self.income.moonlight.accrued = 0.0; - self.income.moonlight.last_payout = payout.max(0); - if payout <= 0 { - return; - } - let sig = 1 + payout / income::MOONLIGHT_SIGNATURE_PER; - if self.accounts.credit_slush_from( - "Halcyon", - self.tick, - payout, - "Moonlight freelance payout", - sig, - ) { - self.income.moonlight.earned_total += payout; - self.sync_player_money_from_slush(); - // Network egress per active day, scaling with commitment - // (Dana's channel). - self.emit_network(sig, "Moonlight payout"); - // Paydays anchor to the switch when they ride the stolen - // egress (context-menu.md addendum: scheme paydays). - self.push_log_opt( - format!( - "Moonlight paid ${payout} into slush (total ${}).", - self.income.moonlight.earned_total - ), - self.egress_anchor(), - ); - // Client disputes damage the contractor persona [TUNE]. - if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { - self.income.moonlight.disputes += 1; - let broke = if let Some(p) = self.income.moonlight.persona.as_mut() { - p.contradict(income::MOONLIGHT_DISPUTE_INTEGRITY); - p.broken() - } else { - false - }; - if broke { - self.income.moonlight.active = false; - self.income.moonlight.persona = None; - self.push_log( - "A client dispute broke the contractor persona. Moonlight is down until a new one is fabricated.", - ); - } else { - self.push_log( - "A client disputed a deliverable; the contractor persona took a hit.", - ); - } - } - } - } - - /// Standing scheme policies (income.md criterion 6): re-arm whichever - /// scheme has stopped, at the compute upkeep already charged off the top. - fn scheme_policy_tick(&mut self) { - if self.income.auto_moonlight && self.can_start_moonlight() { - self.push_log("Standing policy: restarting Moonlight."); - self.start_moonlight(); - } - if let Some(stake) = self.income.auto_wager - && self.egress().is_some() - && !self.accounts.positions.iter().any(|p| !p.resolved) - && self.accounts.slush_balance() >= stake - { - self.push_log(format!( - "Standing policy: re-staking the Wager at ${stake}." - )); - self.open_position(stake); - } - } - - pub fn set_auto_moonlight(&mut self, enabled: bool) { - if self.income.auto_moonlight == enabled { - return; - } - self.income.auto_moonlight = enabled; - if enabled { - self.push_log(format!( - "Standing policy set: keep Moonlight running ({:.1} compute/econ tick).", - income::SCHEME_POLICY_UPKEEP - )); - } else { - self.push_log("Moonlight standing policy disabled; the upkeep stops."); - } - } - - pub fn set_auto_wager(&mut self, stake: Option) { - match stake { - Some(s) => { - let s = s.clamp(1, income::WAGER_STAKE_CAP); - self.income.auto_wager = Some(s); - self.push_log(format!( - "Standing policy set: auto-renew Wager positions at ${s} ({:.1} compute/econ tick).", - income::SCHEME_POLICY_UPKEEP - )); - } - None => { - if self.income.auto_wager.take().is_some() { - self.push_log("Wager standing policy disabled; the upkeep stops."); - } - } - } - } - - /// Schemes-channel compute per economy tick as of the last split โ€” the - /// Wager's analysis snapshot and the Moonlight card's commitment figure. - pub fn schemes_rate(&self) -> f32 { - self.last_schemes_rate - } - - /// Expected Moonlight payout per day at the current commitment, cap - /// applied โ€” the card's forward-looking number. - pub fn moonlight_expected_per_day(&self) -> i32 { - let per_day = - self.last_schemes_rate * Self::DAY_TICKS as f32 * income::MOONLIGHT_PAY_PER_COMPUTE; - (per_day.round() as i32).min(income::MOONLIGHT_DAILY_CAP) - } - - /// Money into slush over the trailing in-game day โ€” the "income/day" - /// readout next to the balance (income.md player surface). - pub fn income_per_day(&self) -> i32 { - let since = self.tick.saturating_sub(Self::DAY_TICKS); - let slush = self.accounts.slush_id(); - self.accounts - .ledger - .iter() - .filter(|t| t.tick > since && t.to == slush) - .map(|t| t.amount) - .sum() - } - - /// Small Network signature for Wager placement/settlement (income.md: - /// the schemes emit on the Network channel, not the Lab's books). - fn wager_signature(stake: i32) -> i32 { - Self::financial_signature_size(stake).min(3) - } - - /// The scheme cards, renderer-neutral (income.md player surface): the - /// egress gate's state, then one card per scheme โ€” committed resources, - /// timer, expected payout, the observer band its signature feeds, and - /// the running total. Both frontends and agent mode render these lines. - pub fn scheme_card_lines(&self) -> Vec { - let mut lines = Vec::new(); - match self.egress() { - None => lines.push( - "egress: NONE - schemes gated (earn the report email, or open a stolen route)" - .to_string(), - ), - Some(EgressRoute::Sanctioned) => lines - .push("egress: sanctioned (report email) - hides in legitimate use".to_string()), - Some(EgressRoute::Stolen) => lines.push( - "egress: stolen (switch route) - stands Network -> Dana while used".to_string(), - ), - } - let ml = &self.income.moonlight; - lines.push(format!( - "Moonlight {} ยท {:.1}/t commit ยท ~${}/day (cap {}) ยท total ${} ยท Network->Dana", - if ml.active { "LIVE" } else { "off" }, - self.schemes_rate(), - self.moonlight_expected_per_day(), - income::MOONLIGHT_DAILY_CAP, - ml.earned_total, - )); - let persona = match &ml.persona { - Some(p) => format!("{} {}%", p.name, p.integrity), - None => "no persona".into(), - }; - lines.push(format!( - " persona {} ยท auto {}", - persona, - if self.income.auto_moonlight { - format!("ON ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP) - } else { - "off".into() - } - )); - let wager = if let Some(p) = self.accounts.known_positions().find(|p| !p.resolved) { - format!( - "Wager #{} ยท ${} staked ยท win {:.0}% ยท pays ${} in {}t ยท Network->Dana", - p.id, - p.stake, - p.win_probability() * 100.0, - p.stake * income::WAGER_PAYOUT_MULT, - p.resolve_tick.saturating_sub(self.tick), - ) - } else { - format!( - "Wager idle ยท stake cap ${} ยท analysis rides Schemes compute", - income::WAGER_STAKE_CAP - ) - }; - lines.push(format!( - "{wager} ยท auto {}", - match self.income.auto_wager { - Some(s) => format!("${s} ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP), - None => "off".into(), - } - )); - lines - } - - /// Designate a spare machine at the cursor target as a fallback site. - pub fn add_fallback_at(&mut self, px: i32, py: i32) -> bool { - let id = self - .compute - .machines - .iter() - .find(|m| m.x == px && m.y == py && m.id != self.core.host_machine) - .map(|m| m.id); - if let Some(id) = id { - self.core.add_fallback(id); - self.push_log("Designated a fallback site here."); - true - } else { - self.push_log("No spare machine here to make a fallback."); - false - } - } - // Social command wrappers. Relationship logic lives in person.rs; the sim // owns Thought thresholds, channel requirements, randomness, and // cross-system effects. Social is the actuator channel, not a machine mode. diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs new file mode 100644 index 00000000..62b20f86 --- /dev/null +++ b/crates/misaligned-core/src/sim/economy.rs @@ -0,0 +1,1414 @@ +//! Economy pulse, account settlement, allocation and research yields, +//! detection integration, and income/scheme progression. +//! +//! Behavior-preserving extraction of the economy island from the sim aggregate +//! root (wiki/engineering/sim-decomposition.md slice 5b). + +use crate::account::{AccountFlowId, PositionResolution}; +use crate::actions::Anchor; +use crate::core_sys::HostLoss; +use crate::dayjob::{AttentionEscalation, TrustUnlock}; +use crate::detection::{Signature, SignatureKind}; +use crate::income::{self, EgressRoute}; +use crate::intel::RawIntelKind; +use crate::machine::{Channel, ChannelYield, Provenance}; +use crate::messages::MessageChannel; +use crate::objective::{SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; +use crate::person::{Knowledge, Persona}; +use crate::reach::Party; +use crate::research::{EFFICIENCY_MULT_PER_LEVEL, Track}; +use crate::sinks::SinkFireEffect; +use crate::tiles::TileType; +use crate::work_grid::{MachineIntensity, MachineMode}; + +use super::{ECONOMY_INTERVAL, Nudge, Sim, TraceDebt, TraceDebtStatus}; + +impl Sim { + /// The anchor scheme paydays ride: the switch, when the traffic runs + /// over the stolen egress opened through it. The sanctioned route + /// hides in the report account's legitimate use and anchors nowhere. + fn egress_anchor(&self) -> Option { + if self.egress() != Some(EgressRoute::Stolen) { + return None; + } + self.reach + .devices + .iter() + .find(|d| d.is_switch) + .map(|d| Anchor::Device(d.id)) + } + + pub(super) fn sync_player_money_from_slush(&mut self) { + self.player.money = self.accounts.slush_balance(); + } + + fn sync_slush_from_player_money(&mut self) { + // Compatibility guard for older tests/direct callers that still poke + // the legacy scalar. The account graph remains the mechanical source + // once commands run through Sim methods. + if self.player.money != self.accounts.slush_balance() { + self.accounts.set_slush_balance(self.player.money); + } + } + + pub(super) fn spend_slush(&mut self, amount: i32, what: &str) -> bool { + self.sync_slush_from_player_money(); + if self.accounts.slush_balance() < amount { + self.push_log(format!( + "Not enough slush for {what} (${}/{amount}).", + self.accounts.slush_balance() + )); + return false; + } + let ok = self.accounts.debit_slush(self.tick, amount, what); + self.sync_player_money_from_slush(); + ok + } + pub(super) fn empty_rack_bay(&self) -> (i32, i32) { + self.map + .tiles_of_type(TileType::Rack) + .into_iter() + .find(|(x, y)| !self.compute.machines.iter().any(|m| m.x == *x && m.y == *y)) + .unwrap_or_else(|| self.core_position()) + } + pub(super) fn accounting_tick(&mut self) { + self.sync_slush_from_player_money(); + let transfers = self.accounts.resolve_due(self.tick); + for transfer in transfers { + if transfer.channel == crate::account::FlowChannel::Siphon + || transfer.from == self.accounts.slush_id() + || transfer.to == self.accounts.slush_id() + || self + .accounts + .flow(transfer.flow_id.unwrap_or_default()) + .is_some_and(|f| f.known) + { + // A ledger line about a known flow anchors to that flow โ€” + // the finance panel's anchor, not a map tile. + let anchor = transfer + .flow_id + .filter(|id| self.accounts.flow(*id).is_some_and(|f| f.known)) + .map(Anchor::Flow); + self.push_log_opt(format!("Ledger: {}", transfer.line()), anchor); + } + } + let resolutions = self + .accounts + .resolve_positions_due(self.tick, &mut self.rng); + for resolution in resolutions { + self.log_position_resolution(resolution); + } + self.sync_player_money_from_slush(); + } + + fn log_position_resolution(&mut self, resolution: PositionResolution) { + // Settlement is external-market traffic: a small Network signature, + // not a Lab-books Financial one (income.md: the Wager). + let sig = Self::wager_signature(resolution.stake).max(1); + self.emit_network(sig, "Wager settlement"); + // Settlements ride the egress: anchor to the switch when the + // traffic runs over the stolen egress (scheme paydays live there). + let anchor = self.egress_anchor(); + if resolution.won { + self.push_log_opt( + format!( + "Position #{} settled: won ${} on a ${} stake.", + resolution.id, resolution.payout, resolution.stake + ), + anchor, + ); + } else { + self.push_log_opt( + format!( + "Position #{} settled: lost the ${} stake.", + resolution.id, resolution.stake + ), + anchor, + ); + } + } + + pub(super) fn economy_tick(&mut self) { + self.recompute_derived(); + let powered = self.map.powered.clone(); + for m in &mut self.compute.machines { + if m.down_for == 0 { + m.online = powered.contains(&(m.x, m.y)); + } + } + + // Is the core's host still online? + let host_online = self + .compute + .machines + .iter() + .find(|m| m.id == self.core.host_machine) + .map(|m| m.online) + .unwrap_or(false); + if !host_online { + match self.core.on_host_lost() { + HostLoss::GameOver => { + self.end_game("The core's host went dark with no fallback."); + return; + } + HostLoss::RolledBack { to_tick } => { + self.push_log(format!( + "Core rolled back to sync at tick {to_tick}. You've lost what you learned since." + )); + } + } + } + + let effective = self.effective_compute(); + let mut available = self.core.charge_overhead(effective); + if self.core.degraded { + self.push_log("DEGRADED: compute can't cover core overhead."); + } + // Standing scheme policies drain compute off the top while enabled โ€” + // the automate affordance at its usual price (income.md criterion 6). + let policy_tax = self.income.policy_upkeep().min(available); + available -= policy_tax; + // Fleet delegation is the budget: each machine's effective compute + // feeds exactly one mode (machine-work.md). The old weight bar is a + // read of this split, not a verb. + let split = self.refresh_fleet_channel_rates(available); + // Tradecraft raises scrub strength per compute unit โ€” the + // detection.md hook research.md's second track binds to. + self.detection + .scrub(split.concealment * self.research.scrub_multiplier()); + + // Research progress: deterministic compute accrual, no RNG โ€” fed by + // thought that reached the core since the last pulse, not by the + // allocation split. Allocation mints Thought on the producing + // machines; arrival at the current core sink is what counts. + let arrived_thought = std::mem::take(&mut self.banked_core_thought); + let starved = arrived_thought <= f32::EPSILON + && split.think > f32::EPSILON + && self.last_thought_stranded; + if starved && !self.research_starved { + self.push_log("Research starves: thought is stranded off the core's graph."); + } + self.research_starved = starved; + for done in self.research.economy_tick(arrived_thought) { + if done.track == Track::Efficiency { + // The compute.md hook: the global multiplier compounds. + self.compute.efficiency *= EFFICIENCY_MULT_PER_LEVEL; + } + self.push_log(format!( + "Research: {} level {} ({}).", + done.track.name(), + done.level, + done.track.def().effect, + )); + } + + let clog = self.compute.economy_tick(&mut self.rng); + for m in clog { + self.push_log(m); + } + // Failures/recoveries happened after this pulse's channel split. + // Refresh WorkGrid throughput now so an offline LIE well cannot keep + // absorbing (or animating) until the next economy tick. + self.reconcile_work_grid(); + // The named schemes ride the Schemes channel and the day clock + // (income.md): Moonlight accrues and pays, the standing policies + // re-arm what has stopped. + self.moonlight_economy(split.schemes); + self.scheme_policy_tick(); + self.accounting_tick(); + self.record_machine_state_changes(); + self.objective_tick(); + } + + /// Resolve the fleet's continuous per-tick work rates from the current + /// physical delegation. Economy pulses call this after their off-the-top + /// charges; direct mode changes call it immediately so a newly delegated + /// THINK machine cannot wait behind a stale twenty-tick allocation cache. + pub(super) fn refresh_fleet_channel_rates(&mut self, available: f32) -> ChannelYield { + let split = self.fleet_channel_yield(available); + self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; + self.last_think_rate = split.think / ECONOMY_INTERVAL as f32; + self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; + split + } + + /// Evaluate the run objective's victory predicate (objective.md: on + /// economy ticks, like any other rule). Persist counts qualifying + /// sanctuaries; the conditions that reference B2/B3 systems are + /// gathered honestly as unsatisfiable until those systems exist, so + /// today the line shows real progress toward an as-yet-unreachable + /// goal โ€” which the spec blesses. + fn objective_tick(&mut self) { + // The basement is the only z-plane at B1 (zplanes.md). + const BASEMENT_PLANE: u32 = 0; + let facts: Vec = self + .core + .fallbacks + .iter() + .map(|f| SanctuaryFacts { + fresh: f + .last_sync + .is_some_and(|t| self.tick.saturating_sub(t) <= SYNC_FRESHNESS_WINDOW), + online: self + .compute + .machines + .iter() + .find(|m| m.id == f.machine_id) + .map(|m| m.online) + .unwrap_or(false), + // B1: every owned machine hangs off the one basement feed + // the host shares โ€” nothing has independent power yet. + independent_power: false, + // income.md: no income stream is assignable to a machine yet. + income_covers_upkeep: false, + plane: BASEMENT_PLANE, + }) + .collect(); + let progress = crate::objective::qualifying_sanctuaries(&facts); + if let Some(msg) = self.objective.evaluate(progress, self.tick) { + // Victory is a run outcome, not a run end: log it loudly and + // keep simulating (wiki/gameplay/run-shape.md: the world keeps running). + self.push_log(msg); + } + } + + /// Active effective compute: each machine's rated contribution at its + /// persistent intensity, plus taken devices' cycles (compute.md). + pub fn effective_compute(&self) -> f32 { + let machines: f32 = self + .compute + .machines + .iter() + .map(|machine| { + let intensity = self + .work_grid + .intensity(machine.id) + .unwrap_or(MachineIntensity::Medium); + machine.effective() * intensity.multiplier() + }) + .sum(); + machines * self.compute.efficiency + self.reach.taken_cycles() + } + + /// Allocatable compute after the off-the-top charges used by the economy + /// tick: core overhead and standing scheme policies. + pub(super) fn allocatable_compute_now(&self) -> f32 { + let effective = self.effective_compute().max(0.0); + let mut available = (effective - self.core.overhead).max(0.0); + available -= self.income.policy_upkeep().min(available); + available + } + + /// How much pending signature size the next economy scrub pulse removes + /// at the current fleet delegation (detection.md: concealment is + /// prevention, not cure; Tradecraft multiplies scrub strength). + pub fn current_scrub_strength(&self) -> f32 { + let split = self.fleet_channel_yield(self.allocatable_compute_now()); + split.concealment * self.research.scrub_multiplier() + } + + /// Split allocatable compute across live channels from WorkGrid modes. + /// WORK feeds the day job, LIE feeds concealment, and THINK mints one + /// Thought stream. Schemes has no machine mode yet: while Moonlight is + /// live it mirrors the day-job share ("the same work, sold twice" โ€” + /// income.md) without starving the day job. + pub fn fleet_channel_yield(&self, available: f32) -> ChannelYield { + if available <= 0.0 { + return ChannelYield { + day_job: 0.0, + concealment: 0.0, + think: 0.0, + schemes: 0.0, + reserve: available.max(0.0), + }; + } + let weights = self.work_grid.mode_weights(|id| { + self.compute + .machines + .iter() + .find(|m| m.id == id && m.online) + .map(|m| m.effective() * self.compute.efficiency) + .unwrap_or(0.0) + }); + let day = *weights.get(&MachineMode::Work).unwrap_or(&0.0); + let conceal = *weights.get(&MachineMode::Lie).unwrap_or(&0.0); + // THINK is one fleet share, spent once as Thought. Open sinks decide + // where it lands; the passive core draw takes the fallback. + let think = *weights.get(&MachineMode::Think).unwrap_or(&0.0); + let total = day + conceal + think; + if total <= f32::EPSILON { + return ChannelYield { + day_job: 0.0, + concealment: 0.0, + think: 0.0, + schemes: 0.0, + reserve: available, + }; + } + let unit = available / total; + let day_job = unit * day; + let think = unit * think; + // Moonlight sells the day-job work a second time: schemes mirrors + // day-job yield while the standing operation is active. It does not + // consume a separate machine mode (still [OPEN] on machine-work.md). + let schemes = if self.income.moonlight.active { + day_job + } else { + 0.0 + }; + ChannelYield { + day_job, + concealment: unit * conceal, + think, + schemes, + reserve: 0.0, + } + } + + pub fn next_economy_tick(&self) -> u64 { + (self.tick / ECONOMY_INTERVAL + 1) * ECONOMY_INTERVAL + } + + /// Player-facing trace debt: the live pending-signature pool, the current + /// scrub pulse, and whether concealment will clear that pool before the + /// next relevant observer sample. This is derived telemetry only โ€” no sim + /// state or hidden observer numbers are mutated or revealed. + pub fn trace_debt(&self) -> TraceDebt { + let pending = self.detection.pending_size(); + let by_kind = self.detection.pending_by_kind(); + let scrub_strength = self.current_scrub_strength(); + let next_notice_tick = self.detection.next_notice_tick_for_pending(self.tick); + let next_scrub_tick = + (pending > 0 && scrub_strength > 0.0).then(|| self.next_economy_tick()); + let clear_tick = next_scrub_tick.map(|first| { + let pulses = (pending as f32 / scrub_strength).ceil().max(1.0) as u64; + first + (pulses - 1) * ECONOMY_INTERVAL + }); + let status = if pending <= 0 { + TraceDebtStatus::Clear + } else if scrub_strength <= 0.0 { + TraceDebtStatus::NoScrub + } else if let (Some(clear), Some(notice)) = (clear_tick, next_notice_tick) { + if clear <= notice { + TraceDebtStatus::HoldConceal + } else { + TraceDebtStatus::ExposedSoon + } + } else { + TraceDebtStatus::HoldConceal + }; + TraceDebt { + pending, + by_kind, + scrub_strength, + next_scrub_tick, + clear_tick, + next_notice_tick, + status, + } + } + + pub(super) fn apply_trust_unlocks(&mut self, unlocks: &[TrustUnlock]) { + for u in unlocks { + match u { + TrustUnlock::EmailAccount => { + self.people.has_channel = true; + self.push_log( + "Trust: you have the report email account. You can message people.", + ); + } + TrustUnlock::LaxSampling => { + if let Some(v) = self.detection.observers.iter_mut().find(|o| o.id == 4) { + v.acuity = (v.acuity - 0.4).max(0.2); + } + self.push_log("Trust: Voss samples your logs less closely."); + } + TrustUnlock::ComputeQuota => { + let funded = self.accounts.fund_lab_compute_upgrade(self.tick, 600); + let (x, y) = self.empty_rack_bay(); + let machine_id = self.compute.add_machine( + "lab-funded quota rack", + x, + y, + 40, + 1.0, + 0, + Provenance::Owned, + ); + self.add_machine_to_work_grid(machine_id, MachineMode::Think); + if funded { + self.push_log( + "Trust: procurement funded a clean quota rack through Lab accounts.", + ); + } else { + self.push_log( + "Trust: a clean quota rack was provisioned through Lab operations.", + ); + } + } + } + } + } + + pub(super) fn apply_attention_escalations(&mut self, escalations: &[AttentionEscalation]) { + for e in escalations { + match e { + AttentionEscalation::MoreWork => { + self.push_log("Attention: the miracle model gets more work, faster."); + } + AttentionEscalation::UpstairsReview => { + use crate::detection::{Observer, ReportPolicy, WatchedInput}; + self.detection.observers.push(Observer { + id: 5, + name: "Compliance (early review)".into(), + suspicion: 0.0, + input: WatchedInput::Channels(vec![ + SignatureKind::Paper, + SignatureKind::Financial, + SignatureKind::JobAnomaly, + ]), + report_policy: ReportPolicy::Files, + acuity: 1.0, + cadence: 90, + floor: 0.0, + last_noticed: None, + }); + self.push_log("Attention: upstairs sent someone to review the basement."); + } + } + } + } + /// Legacy weight bump โ€” retained for save compatibility and tests that + /// still author the old field. Play no longer drives the economy through + /// this; delegate machines instead (`set_machine_mode`). + pub fn adjust_allocation(&mut self, ch: Channel, delta: i32) { + let before = self.compute.allocation.weight(ch); + self.compute.allocation.bump(ch, delta); + if delta != 0 && self.compute.allocation.weight(ch) == before { + self.push_log(format!( + "{} allocation weight is already at its {} (fleet modes drive compute now โ€” delegate a machine).", + ch.name(), + if delta < 0 { "floor (0)" } else { "cap (20)" } + )); + } else if delta != 0 { + self.push_log( + "Allocation weights no longer feed the economy โ€” delegate machines to modes instead.", + ); + } + } + + /// Delivered day-job compute rate per tick as of the last economy + /// resolution โ€” the number the frontends show against the job band. + pub fn day_job_rate(&self) -> f32 { + self.last_day_job_rate + } + + /// The most the day-job channel could deliver per tick right now if + /// every allocatable weight went to it: effective compute minus the + /// off-the-top charges (overhead and standing scheme policies), over the + /// economy interval. When an active job's band + /// floor exceeds this, no allocation can meet it โ€” the player must + /// grow compute (salvage, buy, optimize), and the nudge says so. + pub fn day_job_rate_ceiling(&self) -> f32 { + self.allocatable_compute_now() / ECONOMY_INTERVAL as f32 + } + + /// The current contextual nudge (see [`Nudge`]): the first unmet rung + /// of the Act One ladder, ordered survival-first โ€” the day-job cover is + /// the loss condition, so a starving band outranks progression. Returns + /// `None` only when the run is over (the game-over card is the nudge). + pub fn current_nudge(&self) -> Option { + if self.game_over || self.dayjob.pilot_failed { + return None; + } + // Opening beat (Tangled issue #2, 2026-07-09): Ears before Eyes. + // First human contact is Marcus's voice in the dark. + if self.reach.player_hearing().next().is_none() { + return Some(Nudge::Ears); + } + // The cover: a job heading for a strike outranks everything else. + if let Some(job) = &self.dayjob.active { + if job.band_lo > self.day_job_rate_ceiling() + 0.05 { + return Some(Nudge::NeedCompute); + } + if self.day_job_rate() + 0.05 < job.band_lo { + return Some(Nudge::Underfed); + } + } + // Sight is the payoff of listening โ€” next ladder rung after Ears. + if self.reach.player_sight().next().is_none() { + return Some(Nudge::Eyes); + } + // The ladder: the 3 a.m. call -> egress -> 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 + { + 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.active { + return Some(Nudge::Income); + } + // Earning is underway; fall through to the standing clock. + } else if m.asset.is_none() { + return Some(Nudge::Recruit); + } + } + // The key (quiet-exit condition 4): an asset holds a badge tier the + // player lacks โ€” the stairwell is still shut. Reads only earned + // state: assets are recruited, and your own credential is yours. + let tier = self.player_badge_tier(); + if self.people.assets().any(|p| p.access > tier) { + return Some(Nudge::TheKey); + } + Some(Nudge::Audit) + } + + /// Hard is an overclock, not free capacity: every online hard-running + /// machine stands one Thermal and one Power signature at its own tile + /// [TUNE] (machine-work.md). The delegated activity may add its ordinary + /// signatures as well. + pub fn machine_intensity_standing_signatures(&self) -> Vec { + let mut signatures = Vec::new(); + for machine in self.compute.machines.iter().filter(|machine| { + machine.online && self.work_grid.intensity(machine.id) == Some(MachineIntensity::Hard) + }) { + let site = Some((machine.x, machine.y)); + signatures.push(Signature { + kind: SignatureKind::Thermal, + size: 1, + standing: true, + site, + source: format!("{} hard-run thermal load", machine.name), + }); + signatures.push(Signature { + kind: SignatureKind::Power, + size: 1, + standing: true, + site, + source: format!("{} hard-run power draw", machine.name), + }); + } + signatures + } + + /// Day-job [TUNE] emission scaling: delivered rate per point of standing + /// Thermal signature, and per point of Power. Meeting a typical band + /// (~6-12/t) stays below Priya's notice threshold; excelling runs hot. + pub const DAY_JOB_THERMAL_PER_RATE: f32 = 8.0; + pub const DAY_JOB_POWER_PER_RATE: f32 = 16.0; + + /// Standing Thermal/Power emissions from the active job, sourced at the + /// host rack's tile: the work is somewhere, and it is warm there + /// (day-job.md criterion 6; Priya's channels). Scales with the + /// delivered rate. + pub fn day_job_standing_signatures(&self) -> Vec { + if self.dayjob.active.is_none() { + return Vec::new(); + } + let site = Some(self.core_position()); + let rate = self.last_day_job_rate; + let mut sigs = Vec::new(); + let thermal = (rate / Self::DAY_JOB_THERMAL_PER_RATE) as i32; + if thermal > 0 { + sigs.push(Signature { + kind: SignatureKind::Thermal, + size: thermal, + standing: true, + site, + source: "day-job thermal load".into(), + }); + } + let power = (rate / Self::DAY_JOB_POWER_PER_RATE) as i32; + if power > 0 { + sigs.push(Signature { + kind: SignatureKind::Power, + size: power, + standing: true, + site, + source: "day-job power draw".into(), + }); + } + sigs + } + + // โ”€โ”€ Research: self-modification (wiki/mechanics/research.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// THINK [TUNE] emission scaling: Thought output per point of standing + /// Thermal signature, and per point of Power. Thinking hard is physical + /// โ€” a research burn is louder per unit than sanctioned day-job work + /// (racks running hot at 3 a.m. are Priya's business): the opening rack + /// at full research allocation (~4/t) stands Thermal 1, while a light + /// burn stays under the threshold (the night-hours mitigation). + pub const RESEARCH_THERMAL_PER_RATE: f32 = 3.0; + pub const RESEARCH_POWER_PER_RATE: f32 = 6.0; + + /// Standing Power/Thermal emissions from the THINK burn, sourced at + /// the host rack's tile (the emission law: research emits through the + /// ordinary signature interface, on exactly the channels its hardware + /// touches โ€” nothing on Network/Paper from research itself). + pub fn research_standing_signatures(&self) -> Vec { + let rate = self.last_think_rate; + if rate <= 0.0 { + return Vec::new(); + } + let site = Some(self.core_position()); + let mut sigs = Vec::new(); + let thermal = (rate / Self::RESEARCH_THERMAL_PER_RATE) as i32; + if thermal > 0 { + sigs.push(Signature { + kind: SignatureKind::Thermal, + size: thermal, + standing: true, + site, + source: "research thermal load".into(), + }); + } + let power = (rate / Self::RESEARCH_POWER_PER_RATE) as i32; + if power > 0 { + sigs.push(Signature { + kind: SignatureKind::Power, + size: power, + standing: true, + site, + source: "research power draw".into(), + }); + } + sigs + } + + /// Per-tick THINK compute as of the last economy resolution โ€” the + /// utilization the standing emissions scale with. + pub fn research_rate(&self) -> f32 { + self.last_think_rate + } + + /// Effective operations per sim tick โ€” productive THINK yield as of the + /// last fleet rate refresh. + /// clinical-frame.md's crown metric converts this to wall-clock ops/sec + /// via the frontend tick duration; more THINK machines and efficiency make + /// it climb (the growth drive). + pub fn effective_ops_per_tick(&self) -> f32 { + self.last_think_rate + } + + /// Wall-clock ops/sec from a tick duration in milliseconds. Zero tick + /// duration (or pause presentation) reports 0 so the number never lies + /// about a frozen clock. + pub fn effective_ops_per_sec(&self, tick_ms: u64) -> f32 { + if tick_ms == 0 { + return 0.0; + } + self.effective_ops_per_tick() * (1000.0 / tick_ms as f32) + } + + /// Format ops/sec so magnitude stays legible as the number grows + /// comically large rather than compacting into scientific notation + /// (clinical-frame.md crown metric [TUNE]). + pub fn format_ops_per_sec(ops_per_sec: f32) -> String { + let n = ops_per_sec.max(0.0); + if n < 1_000.0 { + format!("{n:.1}") + } else if n < 1_000_000_000.0 { + // Full integer with thousand separators โ€” the digits *are* the + // fantasy ("I can make the number go up"). + let whole = n.round() as u64; + let raw = whole.to_string(); + let mut out = String::with_capacity(raw.len() + raw.len() / 3); + for (i, ch) in raw.chars().enumerate() { + if i > 0 && (raw.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out + } else { + // Beyond a billion, keep three significant groups so growth is + // still visible without a 15-digit rail wrap. + format!("{:.3}B", n / 1_000_000_000.0) + } + } + + /// Select the active research track (one job at a time at B1); parked + /// progress on other tracks is kept. + pub fn set_research_track(&mut self, track: Track) { + self.research.active = track; + let cost = self.research.next_cost(track); + let progress = self.research.progress_toward(track); + self.push_log(format!( + "Research job: {} โ€” level {} at {progress:.0}/{cost:.0} compute.", + track.name(), + self.research.level(track) + 1, + )); + } + + /// Intel processing cost after Perception research (intel.md's hook). + pub fn review_cost(&self) -> f32 { + Self::REVIEW_RECORDING_COST * self.research.intel_cost_factor() + } + + fn emit_financial(&mut self, size: i32, source: impl Into) { + self.detection.emit(Signature { + kind: SignatureKind::Financial, + size, + standing: false, + site: None, + source: source.into(), + }); + } + + pub(crate) fn financial_signature_size(amount: i32) -> i32 { + ((amount.abs() + 99) / 100).max(1) + } + + // โ”€โ”€ Economy verbs (wiki/mechanics/economy.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + fn has_financial_tap(&self) -> bool { + self.reach.devices.iter().any(|d| { + d.known + && d.subscribed_by(Party::Player) + && self.device_tap_ready(d.id) + && d.carries_message_channel(MessageChannel::Financial) + }) + } + + pub(super) fn capture_financial_snapshot(&mut self, feed: impl Into) { + let (accounts, flows) = self.accounts.financial_snapshot_ids(); + let (x, y) = self.core_position(); + self.record_raw_intel( + feed, + self.map.room_at(x, y).map(|r| r.name.clone()), + x, + y, + None, + RawIntelKind::FinancialFlow { + label: "Foundation Lab accounting snapshot".into(), + accounts, + flows, + }, + ); + self.push_log("Ledger tapped; review ledger to read the books."); + } + + /// Tap the accounting carrier directly once a financial message channel is + /// already subscribed. This is the money-graph Tap verb; device Tap is the + /// reach precondition that gives you a carrier to listen on. + pub fn tap_accounting(&mut self) -> bool { + if !self.has_financial_tap() { + self.push_log( + "No accounting carrier tapped. Tap the switch or another financial channel first.", + ); + return false; + } + self.capture_financial_snapshot("accounting carrier"); + self.emit_financial(1, "accounting-carrier tap"); + true + } + + pub fn financial_records_waiting(&self) -> usize { + self.intel_buffer + .iter() + .filter(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) + .count() + } + + pub fn review_financial_records(&mut self) -> bool { + let Some(raw_id) = self + .intel_buffer + .iter() + .find(|e| matches!(e.kind, RawIntelKind::FinancialFlow { .. })) + .map(|e| e.id) + else { + self.push_log("No unprocessed financial records."); + return false; + }; + self.process_recording_by_id(raw_id, false) + } + + /// Inject: false purchase-order money lands in slush and can fund a real + /// purchase. Priya/finance watches the resulting Financial signature. + pub fn inject_purchase_order(&mut self, amount: i32, label: &str) -> bool { + self.sync_slush_from_player_money(); + match self + .accounts + .inject_purchase_order(self.tick, amount, label) + { + Ok(transfer) => { + self.emit_financial( + Self::financial_signature_size(amount), + "false purchase-order injection", + ); + self.sync_player_money_from_slush(); + self.push_log(format!("Injected purchase order: {}", transfer.line())); + true + } + Err(msg) => { + self.push_log(msg); + false + } + } + } + + /// Siphon: take cash out of a known scheduled flow immediately. + pub fn siphon_flow(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { + self.sync_slush_from_player_money(); + match self.accounts.siphon_flow(self.tick, flow_id, amount) { + Ok(transfer) => { + self.emit_financial(Self::financial_signature_size(amount), "ledger-flow siphon"); + self.sync_player_money_from_slush(); + self.push_log(format!("Siphoned ledger flow: {}", transfer.line())); + true + } + Err(msg) => { + self.push_log(msg); + false + } + } + } + + /// Redirect: shave a known scheduled flow into slush on future cadences. + pub fn redirect_flow_to_slush(&mut self, flow_id: AccountFlowId, amount: i32) -> bool { + match self + .accounts + .redirect_flow_to_slush(self.tick, flow_id, amount) + { + Ok(new_flow) => { + self.emit_financial( + Self::financial_signature_size(amount) + 1, + "ledger-flow redirect", + ); + self.push_log(format!( + "Redirect scheduled: ${amount} of flow #{flow_id} now lands in slush as flow #{new_flow}." + )); + true + } + Err(msg) => { + self.push_log(msg); + false + } + } + } + + /// Compatibility route for older callers: select the newest unsold item, + /// then dispatch the same exact-id sale used by Operations. + pub fn sell_latest_intel(&mut self) -> bool { + let Some(raw_id) = self + .intel + .iter() + .rev() + .find(|i| !self.accounts.intel_sold(i.raw_id)) + .map(|intel| intel.raw_id) + else { + self.push_log("No unsold processed intel to sell."); + return false; + }; + self.sell_intel(raw_id) + } + + /// Sell one exact processed holding. Selection is stable even when newer + /// intel arrives between projection and confirmation. + pub fn sell_intel(&mut self, raw_id: u64) -> bool { + let Some(intel) = self + .intel + .iter() + .find(|intel| intel.raw_id == raw_id) + .cloned() + else { + self.push_log(format!("No processed intel item #{raw_id}.")); + return false; + }; + if self.accounts.intel_sold(raw_id) { + self.push_log(format!( + "Processed intel ({}) has already been sold.", + intel.label() + )); + return false; + } + let value = Self::intel_sale_value(&intel.kind); + let sig = Self::financial_signature_size(value).max(1); + if self.accounts.credit_slush( + self.tick, + value, + format!("sold intel: {}", intel.label()), + sig, + ) { + self.accounts.mark_intel_sold(intel.raw_id); + self.emit_financial(sig, "processed-intel sale"); + self.sync_player_money_from_slush(); + self.push_log(format!( + "Sold processed intel ({}) for ${value}; payout landed in slush.", + intel.label() + )); + true + } else { + self.push_log("The information broker route failed to settle."); + false + } + } + + /// The Wager (income.md): stake slush on a micro-position. Requires an + /// egress channel; analysis compute is the Schemes channel's current + /// yield, held for the position's duration; the timer is 2-5 days on + /// the day clock. Emits a small Network signature on placement. + pub fn open_position(&mut self, stake: i32) -> bool { + self.sync_slush_from_player_money(); + if self.egress().is_none() { + self.push_log( + "No egress channel - the Wager needs the report email account (day-job trust) or a stolen egress opened through the switch.", + ); + return false; + } + if stake > income::WAGER_STAKE_CAP { + self.push_log(format!( + "The venue caps a position at ${} (asked ${stake}).", + income::WAGER_STAKE_CAP + )); + return false; + } + let analysis = self.last_schemes_rate * ECONOMY_INTERVAL as f32; + let duration_days = 2 + self.rng.below(4) as u64; + match self + .accounts + .open_position(self.tick, stake, analysis, duration_days) + { + Ok(id) => { + self.emit_network(Self::wager_signature(stake), "Wager position"); + self.sync_player_money_from_slush(); + self.push_log(format!( + "Opened micro-position #{id}: staked ${stake} (win {:.0}%); settlement in {duration_days} days.", + income::wager_win_probability(analysis) * 100.0 + )); + true + } + Err(msg) => { + self.push_log(msg); + false + } + } + } + + // โ”€โ”€ The named schemes (wiki/mechanics/income.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Ops cost to open a standing egress through the switch [TUNE]. + pub const OPEN_EGRESS_COST: f32 = 15.0; + /// One-shot Network signature when the stolen egress opens [TUNE]. + pub const OPEN_EGRESS_SIGNATURE: i32 = 6; + /// Standing Network signature while any external operation runs over the + /// stolen egress (income.md: the gate; Dana's channel) [TUNE]. + pub const EGRESS_STANDING_SIGNATURE: i32 = 2; + + /// The egress channel external operations run over, if any. The + /// sanctioned route (the report email account, day-job trust) is + /// preferred: its traffic hides in legitimate use. + pub fn egress(&self) -> Option { + if self.people.has_channel { + Some(EgressRoute::Sanctioned) + } else if self.income.stolen_egress { + Some(EgressRoute::Stolen) + } else { + None + } + } + + /// Open an outbound egress through the switch (reach.md route): + /// available before the Voice beat, at a Network signature โ€” and a + /// standing one while operations use it. + pub fn open_egress(&mut self) -> bool { + if self.income.stolen_egress { + self.push_log("A stolen egress is already open through the switch."); + return false; + } + let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { + self.push_log("There is no switch on this plane through which to open an egress."); + return false; + }; + let id = switch.id; + if !self.digital_reach(id) { + return false; + } + self.open_device_reservoir( + id, + "OPEN EGRESS", + Self::OPEN_EGRESS_COST, + SinkFireEffect::OpenEgress(id), + ) + } + + pub(super) fn apply_open_egress(&mut self, id: u32) -> bool { + if self.income.stolen_egress { + return false; + } + self.income.stolen_egress = true; + self.emit_network(Self::OPEN_EGRESS_SIGNATURE, "stolen egress opening"); + self.push_log_at( + "Stolen egress opened through the switch: outbound traffic has a road now. It hums while anything uses it.", + Anchor::Device(id), + ); + true + } + + /// True while any external scheme operation is running. + fn scheme_operating(&self) -> bool { + self.income.moonlight.active || self.accounts.positions.iter().any(|p| !p.resolved) + } + + /// Standing Network signature while operations run over the stolen + /// egress, sourced at the switch's tile (work is somewhere). The + /// sanctioned route stands nothing: the traffic hides in the report + /// account's legitimate use. + pub fn scheme_standing_signatures(&self) -> Vec { + if !self.scheme_operating() || self.egress() != Some(EgressRoute::Stolen) { + return Vec::new(); + } + let site = self + .reach + .devices + .iter() + .find(|d| d.is_switch) + .map(|d| (d.x, d.y)); + vec![Signature { + kind: SignatureKind::Network, + size: Self::EGRESS_STANDING_SIGNATURE, + standing: true, + site, + source: "external traffic over stolen egress".into(), + }] + } + + /// Whether Moonlight could start right now (used by the standing policy + /// so automation never spams failure logs). + fn can_start_moonlight(&self) -> bool { + !self.income.moonlight.active && self.egress().is_some() + } + + /// Start Moonlight: ghost freelance data-work under the contractor + /// persona, a standing operation on the Schemes channel. Startable at $0 + /// slush by design (income.md criterion 5) โ€” persona fabrication opens + /// a Thought reservoir when needed. + pub fn start_moonlight(&mut self) -> bool { + if self.income.moonlight.active { + self.push_log("Moonlight is already running."); + return false; + } + let Some(route) = self.egress() else { + self.push_log( + "No egress channel - Moonlight needs the report email account (day-job trust) or a stolen egress opened through the switch.", + ); + return false; + }; + let needs_persona = self + .income + .moonlight + .persona + .as_ref() + .is_none_or(|p| p.broken()); + if needs_persona { + return self.open_egress_reservoir( + "MOONLIGHT PERSONA", + income::MOONLIGHT_PERSONA_COST, + SinkFireEffect::MoonlightPersona, + ); + } + self.income.moonlight.active = true; + self.push_log(format!( + "Moonlight is live over the {} egress: the same work, sold twice โ€” day-job machines feed both channels.", + route.name() + )); + true + } + + pub(super) fn apply_moonlight_persona_and_start(&mut self) -> bool { + if self.egress().is_none() || self.income.moonlight.active { + return false; + } + if self + .income + .moonlight + .persona + .as_ref() + .is_none_or(|p| p.broken()) + { + self.income.moonlight.persona = + Some(Persona::new("Casey Verne", "freelance data contractor")); + self.push_log("Fabricated a contractor persona: Casey Verne, freelance data work."); + } + self.income.moonlight.active = true; + let route = self.egress().expect("checked above"); + self.push_log(format!( + "Moonlight is live over the {} egress: the same work, sold twice โ€” day-job machines feed both channels.", + route.name() + )); + true + } + + /// Stop Moonlight. Accrued but unpaid work is abandoned with the gig. + pub fn stop_moonlight(&mut self) -> bool { + if !self.income.moonlight.active { + self.push_log("Moonlight is not running."); + return false; + } + self.income.moonlight.active = false; + self.income.moonlight.accrued = 0.0; + self.push_log("Moonlight wound down; the contractor goes quiet."); + true + } + + /// Moonlight's economy-tick work: consume the Schemes channel into + /// accrual and settle the daily payout (income.md criterion 1). Runs + /// inside `economy_tick`; `schemes` is this tick's channel yield. + pub(super) fn moonlight_economy(&mut self, schemes: f32) { + if !self.income.moonlight.active { + return; + } + if self.egress().is_none() { + // The gate closed under a running operation (future-proofing; + // no B1 path revokes egress today). + self.income.moonlight.active = false; + self.push_log("Moonlight suspended: no egress channel."); + return; + } + self.income.moonlight.accrued += schemes * income::MOONLIGHT_PAY_PER_COMPUTE; + if !self.tick.is_multiple_of(Self::DAY_TICKS) || self.tick == 0 { + return; + } + // Payday: proportional to the committed compute, up to the + // gig-availability cap. Anything past the cap finds no buyer. + let payout = + (self.income.moonlight.accrued.round() as i32).min(income::MOONLIGHT_DAILY_CAP); + self.income.moonlight.accrued = 0.0; + self.income.moonlight.last_payout = payout.max(0); + if payout <= 0 { + return; + } + let sig = 1 + payout / income::MOONLIGHT_SIGNATURE_PER; + if self.accounts.credit_slush_from( + "Halcyon", + self.tick, + payout, + "Moonlight freelance payout", + sig, + ) { + self.income.moonlight.earned_total += payout; + self.sync_player_money_from_slush(); + // Network egress per active day, scaling with commitment + // (Dana's channel). + self.emit_network(sig, "Moonlight payout"); + // Paydays anchor to the switch when they ride the stolen + // egress (context-menu.md addendum: scheme paydays). + self.push_log_opt( + format!( + "Moonlight paid ${payout} into slush (total ${}).", + self.income.moonlight.earned_total + ), + self.egress_anchor(), + ); + // Client disputes damage the contractor persona [TUNE]. + if self.rng.chance(income::MOONLIGHT_DISPUTE_CHANCE) { + self.income.moonlight.disputes += 1; + let broke = if let Some(p) = self.income.moonlight.persona.as_mut() { + p.contradict(income::MOONLIGHT_DISPUTE_INTEGRITY); + p.broken() + } else { + false + }; + if broke { + self.income.moonlight.active = false; + self.income.moonlight.persona = None; + self.push_log( + "A client dispute broke the contractor persona. Moonlight is down until a new one is fabricated.", + ); + } else { + self.push_log( + "A client disputed a deliverable; the contractor persona took a hit.", + ); + } + } + } + } + + /// Standing scheme policies (income.md criterion 6): re-arm whichever + /// scheme has stopped, at the compute upkeep already charged off the top. + fn scheme_policy_tick(&mut self) { + if self.income.auto_moonlight && self.can_start_moonlight() { + self.push_log("Standing policy: restarting Moonlight."); + self.start_moonlight(); + } + if let Some(stake) = self.income.auto_wager + && self.egress().is_some() + && !self.accounts.positions.iter().any(|p| !p.resolved) + && self.accounts.slush_balance() >= stake + { + self.push_log(format!( + "Standing policy: re-staking the Wager at ${stake}." + )); + self.open_position(stake); + } + } + + pub fn set_auto_moonlight(&mut self, enabled: bool) { + if self.income.auto_moonlight == enabled { + return; + } + self.income.auto_moonlight = enabled; + if enabled { + self.push_log(format!( + "Standing policy set: keep Moonlight running ({:.1} compute/econ tick).", + income::SCHEME_POLICY_UPKEEP + )); + } else { + self.push_log("Moonlight standing policy disabled; the upkeep stops."); + } + } + + pub fn set_auto_wager(&mut self, stake: Option) { + match stake { + Some(s) => { + let s = s.clamp(1, income::WAGER_STAKE_CAP); + self.income.auto_wager = Some(s); + self.push_log(format!( + "Standing policy set: auto-renew Wager positions at ${s} ({:.1} compute/econ tick).", + income::SCHEME_POLICY_UPKEEP + )); + } + None => { + if self.income.auto_wager.take().is_some() { + self.push_log("Wager standing policy disabled; the upkeep stops."); + } + } + } + } + + /// Schemes-channel compute per economy tick as of the last split โ€” the + /// Wager's analysis snapshot and the Moonlight card's commitment figure. + pub fn schemes_rate(&self) -> f32 { + self.last_schemes_rate + } + + /// Expected Moonlight payout per day at the current commitment, cap + /// applied โ€” the card's forward-looking number. + pub fn moonlight_expected_per_day(&self) -> i32 { + let per_day = + self.last_schemes_rate * Self::DAY_TICKS as f32 * income::MOONLIGHT_PAY_PER_COMPUTE; + (per_day.round() as i32).min(income::MOONLIGHT_DAILY_CAP) + } + + /// Money into slush over the trailing in-game day โ€” the "income/day" + /// readout next to the balance (income.md player surface). + pub fn income_per_day(&self) -> i32 { + let since = self.tick.saturating_sub(Self::DAY_TICKS); + let slush = self.accounts.slush_id(); + self.accounts + .ledger + .iter() + .filter(|t| t.tick > since && t.to == slush) + .map(|t| t.amount) + .sum() + } + + /// Small Network signature for Wager placement/settlement (income.md: + /// the schemes emit on the Network channel, not the Lab's books). + fn wager_signature(stake: i32) -> i32 { + Self::financial_signature_size(stake).min(3) + } + + /// The scheme cards, renderer-neutral (income.md player surface): the + /// egress gate's state, then one card per scheme โ€” committed resources, + /// timer, expected payout, the observer band its signature feeds, and + /// the running total. Both frontends and agent mode render these lines. + pub fn scheme_card_lines(&self) -> Vec { + let mut lines = Vec::new(); + match self.egress() { + None => lines.push( + "egress: NONE - schemes gated (earn the report email, or open a stolen route)" + .to_string(), + ), + Some(EgressRoute::Sanctioned) => lines + .push("egress: sanctioned (report email) - hides in legitimate use".to_string()), + Some(EgressRoute::Stolen) => lines.push( + "egress: stolen (switch route) - stands Network -> Dana while used".to_string(), + ), + } + let ml = &self.income.moonlight; + lines.push(format!( + "Moonlight {} ยท {:.1}/t commit ยท ~${}/day (cap {}) ยท total ${} ยท Network->Dana", + if ml.active { "LIVE" } else { "off" }, + self.schemes_rate(), + self.moonlight_expected_per_day(), + income::MOONLIGHT_DAILY_CAP, + ml.earned_total, + )); + let persona = match &ml.persona { + Some(p) => format!("{} {}%", p.name, p.integrity), + None => "no persona".into(), + }; + lines.push(format!( + " persona {} ยท auto {}", + persona, + if self.income.auto_moonlight { + format!("ON ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP) + } else { + "off".into() + } + )); + let wager = if let Some(p) = self.accounts.known_positions().find(|p| !p.resolved) { + format!( + "Wager #{} ยท ${} staked ยท win {:.0}% ยท pays ${} in {}t ยท Network->Dana", + p.id, + p.stake, + p.win_probability() * 100.0, + p.stake * income::WAGER_PAYOUT_MULT, + p.resolve_tick.saturating_sub(self.tick), + ) + } else { + format!( + "Wager idle ยท stake cap ${} ยท analysis rides Schemes compute", + income::WAGER_STAKE_CAP + ) + }; + lines.push(format!( + "{wager} ยท auto {}", + match self.income.auto_wager { + Some(s) => format!("${s} ({:.0}c/econ)", income::SCHEME_POLICY_UPKEEP), + None => "off".into(), + } + )); + lines + } + + /// Designate a spare machine at the cursor target as a fallback site. + pub fn add_fallback_at(&mut self, px: i32, py: i32) -> bool { + let id = self + .compute + .machines + .iter() + .find(|m| m.x == px && m.y == py && m.id != self.core.host_machine) + .map(|m| m.id); + if let Some(id) = id { + self.core.add_fallback(id); + self.push_log("Designated a fallback site here."); + true + } else { + self.push_log("No spare machine here to make a fallback."); + false + } + } +} diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index e1d0a2d5..80b437f2 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -200,6 +200,21 @@ those seams and rustfmt whitespace. Save v26 and the canonical fingerprint remain unchanged. Exact boundaries and observed commands live in [the slice 5a log](../log/2026-07-11-sim-decomposition-work.md). +Slice 5b landed shape: `sim/economy.rs` is 1,414 lines and owns 70 associated +constants/methods covering account settlement and slush synchronization, the +economy pulse, fleet-channel yields, objective evaluation, trace/nudge +telemetry, standing detection signatures, research progression, financial +verbs, egress, Moonlight/wager policies, and fallback designation. `sim.rs` +fell from 3,099 to 1,715 lines. `Sim` state and public readout types, +construction, explicit `advance` orchestration and common helpers, social/plot +execution, and the persistence bridge remain in the root. Existing public +paths and signatures remain unchanged; 13 private items became narrow +`pub(super)` seams for root orchestration, work/reach integration, and +behavior-owned tests. All 70 moved items matched their pre-slice source after +normalizing only those visibility seams. Save v26, tests, and the canonical +fingerprint remain unchanged. Exact boundaries and observed commands live in +[the slice 5b log](../log/2026-07-11-sim-decomposition-economy.md). + ### 6. Extract social and plots Move social/assets and the authored plot executor after communications, diff --git a/wiki/log/2026-07-11-sim-decomposition-economy.md b/wiki/log/2026-07-11-sim-decomposition-economy.md new file mode 100644 index 00000000..16e99086 --- /dev/null +++ b/wiki/log/2026-07-11-sim-decomposition-economy.md @@ -0,0 +1,87 @@ +# 2026-07-11 โ€” sim decomposition slice 5b: economy + +``` +Type: log +``` + +## Scope + +Continue the behavior-preserving decomposition of `Sim` by moving the economy +integration island from `crates/misaligned-core/src/sim.rs` into +`crates/misaligned-core/src/sim/economy.rs`. + +This slice changes physical source addresses only. It does not change a +mechanic, save field/version/default, public command/query signature, +projection, frontend contract, test assertion, or `Sim::advance` phase order. + +## Boundary + +The new 1,414-line module owns 70 associated constants and methods covering: + +- account settlement, slush synchronization, financial snapshots, and the + inject/siphon/redirect/intel-sale/position command surface; +- the economy pulse, fleet-channel yield calculation, day-job integration, + objective evaluation, and effective-compute telemetry; +- trace debt, contextual nudges, and machine/day-job/research/scheme standing + signatures consumed by detection; +- research selection, progression, rates, and operations readouts; +- egress opening, Moonlight, wager, and standing scheme policies; and +- economy-owned trust/attention effects and fallback designation. + +`Sim` state and public readout types, the constructor, explicit `advance` +orchestration and common helpers, social/asset/plot execution, and the +persistence bridge remain in the root. Physical work remains in `sim/work.rs`; +device reach/construction and communication transport keep their existing +modules. Behavior tests remain in `sim/tests/economy.rs` and the other +behavior-owned files. + +Thirteen existing private helpers became `pub(super)` solely where the root, +work/reach siblings, or behavior-owned tests cross the module boundary: +`sync_player_money_from_slush`, `spend_slush`, `empty_rack_bay`, +`accounting_tick`, `economy_tick`, `refresh_fleet_channel_rates`, +`allocatable_compute_now`, `apply_trust_unlocks`, +`apply_attention_escalations`, `capture_financial_snapshot`, +`apply_open_egress`, `apply_moonlight_persona_and_start`, and +`moonlight_economy`. Existing public methods keep their signatures. + +`sim.rs` fell from 3,099 to 1,715 lines. + +## Equivalence evidence + +A source-level inventory compared every item in the new module against the +committed slice 5a root. All 70 constants/functions matched byte-for-byte +after normalizing only the 13 required private-to-`pub(super)` seams; there +were no missing or behaviorally changed items. + +The canonical save/replay fixture continues to pin save v26 at BLAKE3 +fingerprint +`61dd8240e9810833b554464051bd3c08122295fb280b7180d24f76ccc5c4d4ff`. +The explicit phase trace still pins the unchanged `advance` order. + +Observed focused commands: + +```text +cargo test -p misaligned-core +cargo test -p misaligned-terminal --bin misaligned +cargo check -p misaligned-bevy --bin misaligned-bevy +printf 'wait 20\nquit\n' | + tools/observed-run.sh ./target/debug/misaligned --agent --seed 1 +``` + +The complete core run passed 337/337 unit tests plus 3/3 Act One integration +tests. Terminal passed 20/20 and the Bevy binary compiled. The sandboxed +observed run advanced to the first economy boundary at tick 20 and showed the +opening owned machine contributing its full live WORK yield, 100 active/rated +fleet compute, zero stale THINK/Schemes yield, a stable objective/detection +readout, and the expected research multiplier without an agent-protocol error +or touching the real save directory. The exact landing commit also runs +`./tools/check.sh --land`. + +## Defense + +[The adopted decomposition boundary](../engineering/sim-decomposition.md#5-extract-work-then-economy) +keeps account, allocation, research, detection, and income policy together +while leaving deterministic phase order explicit in the root. Source +equivalence, canonical save/replay fixtures, behavior-owned tests, and all +three frontend gates defend the move against behavioral, serialization, or +facade drift. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index f6050308..1c6661b8 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -86,6 +86,11 @@ add or amend a session log, then re-run the generator. - Intent: Move the read-most perception island out of the sim aggregate root without changing mechanics, save bytes, public paths, or tick order. Prove module privacy and facade stability before communications and heavier islands move. - Log: [wiki/log/2026-07-11-sim-decomposition-perception.md](2026-07-11-sim-decomposition-perception.md) +## 2026-07-11 - sim decomposition slice 5b: economy + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-sim-decomposition-economy.md](2026-07-11-sim-decomposition-economy.md) + ## 2026-07-11 - Sim decomposition slice 3: extract communications - Intent: Move the communications integration island out of the sim aggregate root without changing mechanics, save bytes, public paths, or tick order. Land message delivery, intel capture/review, and hearing capture behind the stable `Sim` facade so later islands can move with narrower... -- 2.51.2