//! Machines and compute allocation (spec/compute.md). //! //! Effective compute = sum(capacity * reliability) * efficiency, split each //! economy tick across channels. Acquisition is the buy/steal/optimize //! triangle. All numbers live here; frontends render them. use crate::detection::{Signature, SignatureKind}; use crate::rng::Rng; /// How a machine came to be yours — sets its reliability and signature. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Provenance { /// The pilot's own hardware (Rack 3 and the empty bays you fill legit). Owned, /// Purchased and delivered — high reliability, a paper trail. Bought, /// Scavenged / ghosted — cheap, unreliable, a standing signature. Stolen, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Machine { pub id: u32, pub name: String, /// Map tile hosting it (racks, salvaged boxes). pub x: i32, pub y: i32, pub capacity: i32, /// 0.0-1.0. Stolen iron rolls to fail; owned/bought ~1.0. pub reliability: f32, /// Power drawn from the grid while running. pub power_draw: i32, pub provenance: Provenance, /// True while running; a failed stolen box goes offline until repaired. pub online: bool, /// Ticks until a failed machine can be retried [TUNE]. pub down_for: i32, } impl Machine { /// Effective contribution this tick (0 when offline). pub fn effective(&self) -> f32 { if self.online { self.capacity as f32 * self.reliability } else { 0.0 } } /// A standing signature emitted while a stolen machine runs. pub fn standing_signature(&self) -> Option { if self.provenance == Provenance::Stolen && self.online { Some(Signature { kind: SignatureKind::Power, size: 4, standing: true, }) } else { None } } } /// The five allocation channels plus reserve (spec/compute.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Channel { CoreOverhead, DayJob, Concealment, Social, Research, Reserve, } impl Channel { pub const ALLOCATABLE: [Channel; 4] = [ Channel::DayJob, Channel::Concealment, Channel::Social, Channel::Research, ]; pub fn name(self) -> &'static str { match self { Channel::CoreOverhead => "Core", Channel::DayJob => "Day Job", Channel::Concealment => "Conceal", Channel::Social => "Social", Channel::Research => "Research", Channel::Reserve => "Reserve", } } } /// Player-set weights over the allocatable channels (day job, concealment, /// social, research). Core overhead comes off the top before these apply. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Allocation { pub weights: [u32; 4], } impl Default for Allocation { fn default() -> Self { // Opening split (order: DayJob, Concealment, Social, Research): mostly // day job for cover, plus concealment and social ops so the player can // scrub signatures and reach for their first eyes without reallocating // on turn one. Self { weights: [3, 1, 1, 0], } } } impl Allocation { pub fn weight(&self, ch: Channel) -> u32 { match ch { Channel::DayJob => self.weights[0], Channel::Concealment => self.weights[1], Channel::Social => self.weights[2], Channel::Research => self.weights[3], _ => 0, } } pub fn bump(&mut self, ch: Channel, delta: i32) { let i = match ch { Channel::DayJob => 0, Channel::Concealment => 1, Channel::Social => 2, Channel::Research => 3, _ => return, }; self.weights[i] = (self.weights[i] as i32 + delta).clamp(0, 20) as u32; } fn total(&self) -> u32 { self.weights.iter().sum() } /// Split `available` compute across channels by weight. Returns per-channel /// amounts (day job, concealment, social, research); leftover is reserve. pub fn split(&self, available: f32) -> ChannelYield { let total = self.total(); if total == 0 || available <= 0.0 { return ChannelYield { day_job: 0.0, concealment: 0.0, social: 0.0, research: 0.0, reserve: available.max(0.0), }; } let unit = available / total as f32; ChannelYield { day_job: unit * self.weights[0] as f32, concealment: unit * self.weights[1] as f32, social: unit * self.weights[2] as f32, research: unit * self.weights[3] as f32, reserve: 0.0, } } } #[derive(Debug, Clone, Copy)] pub struct ChannelYield { pub day_job: f32, pub concealment: f32, pub social: f32, pub research: f32, pub reserve: f32, } /// The compute subsystem: machines, efficiency, allocation. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Compute { pub machines: Vec, /// Global optimize multiplier (>= 1.0), raised by research. pub efficiency: f32, /// Research efficiency level (each level multiplies, escalating cost). pub efficiency_level: u32, /// Accumulated research progress toward the next efficiency level. pub research_progress: f32, pub allocation: Allocation, pub next_id: u32, } impl Compute { pub fn new() -> Self { Self { machines: Vec::new(), efficiency: 1.0, efficiency_level: 0, research_progress: 0.0, allocation: Allocation::default(), next_id: 1, } } #[allow(clippy::too_many_arguments)] pub fn add_machine( &mut self, name: impl Into, x: i32, y: i32, capacity: i32, reliability: f32, power_draw: i32, provenance: Provenance, ) -> u32 { let id = self.next_id; self.next_id += 1; self.machines.push(Machine { id, name: name.into(), x, y, capacity, reliability, power_draw, provenance, online: true, down_for: 0, }); id } /// Raw effective compute across online machines, times efficiency. pub fn effective(&self) -> f32 { let raw: f32 = self.machines.iter().map(Machine::effective).sum(); raw * self.efficiency } pub fn total_power_draw(&self) -> i32 { self.machines .iter() .filter(|m| m.online) .map(|m| m.power_draw) .sum() } /// Cost in research-compute for the next efficiency level (escalating). pub fn next_efficiency_cost(&self) -> f32 { 100.0 * 1.6_f32.powi(self.efficiency_level as i32) } /// Advance one economy tick: roll machine reliability, apply research. /// Returns log lines for the frontend. pub fn economy_tick(&mut self, research_compute: f32, rng: &mut Rng) -> Vec { let mut log = Vec::new(); // Recover downed machines; roll running stolen ones for failure. for m in &mut self.machines { if !m.online { m.down_for -= 1; if m.down_for <= 0 { m.online = true; log.push(format!("{} back online.", m.name)); } continue; } if m.provenance == Provenance::Stolen { // Failure chance scales with (1 - reliability) [TUNE]. let fail_p = (1.0 - m.reliability) * 0.04; if rng.chance(fail_p) { m.online = false; m.down_for = 30; log.push(format!("{} dropped offline (unreliable).", m.name)); } } } // Research toward efficiency. if research_compute > 0.0 { self.research_progress += research_compute; let cost = self.next_efficiency_cost(); if self.research_progress >= cost { self.research_progress -= cost; self.efficiency_level += 1; self.efficiency *= 1.15; log.push(format!( "Efficiency up: level {} (x{:.2}).", self.efficiency_level, self.efficiency )); } } log } /// Standing signatures from all running stolen machines. pub fn standing_signatures(&self) -> Vec { self.machines .iter() .filter_map(Machine::standing_signature) .collect() } } impl Default for Compute { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; fn base() -> Compute { let mut c = Compute::new(); c.add_machine("Rack 3", 24, 14, 100, 1.0, 5, Provenance::Owned); c } #[test] fn effective_follows_formula() { let mut c = base(); assert_eq!(c.effective(), 100.0); // Buy: high capacity, high reliability. c.add_machine("Bought rack", 25, 14, 80, 1.0, 4, Provenance::Bought); assert_eq!(c.effective(), 180.0); // Steal: capacity but low reliability. c.add_machine("Salvage", 4, 4, 40, 0.5, 2, Provenance::Stolen); assert_eq!(c.effective(), 200.0); // 180 + 40*0.5 } #[test] fn optimize_multiplies() { let mut c = base(); let before = c.effective(); // Feed enough research to cross a level. let mut rng = Rng::new(1); let cost = c.next_efficiency_cost(); c.economy_tick(cost, &mut rng); assert_eq!(c.efficiency_level, 1); assert!(c.effective() > before); } #[test] fn allocation_splits_by_weight() { let mut alloc = Allocation { weights: [3, 1, 0, 0], }; let y = alloc.split(80.0); assert!((y.day_job - 60.0).abs() < 0.01); assert!((y.concealment - 20.0).abs() < 0.01); alloc.bump(Channel::Research, 4); let y2 = alloc.split(80.0); assert!(y2.research > 0.0); } #[test] fn stolen_machines_can_fail_and_recover() { let mut c = base(); c.add_machine("Ghost", 4, 4, 40, 0.3, 2, Provenance::Stolen); let mut rng = Rng::new(99); let mut failed = false; for _ in 0..500 { c.economy_tick(0.0, &mut rng); if c.machines.iter().any(|m| !m.online) { failed = true; break; } } assert!( failed, "an unreliable stolen machine should eventually fail" ); } #[test] fn stolen_machine_emits_standing_signature() { let mut c = base(); c.add_machine("Ghost", 4, 4, 40, 0.5, 2, Provenance::Stolen); assert_eq!(c.standing_signatures().len(), 1); } }