//! 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. Emitted /// from the machine's own tile (work is somewhere). pub fn standing_signature(&self) -> Option { if self.provenance == Provenance::Stolen && self.online { Some(Signature { kind: SignatureKind::Power, size: 4, standing: true, site: Some((self.x, self.y)), }) } else { None } } } /// The six allocation channels plus reserve (spec/compute.md; Schemes per /// wiki/mechanics/income.md — powers Moonlight throughput and Wager /// analysis). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Channel { CoreOverhead, DayJob, Concealment, Social, Research, Schemes, Reserve, } impl Channel { pub const ALLOCATABLE: [Channel; 5] = [ Channel::DayJob, Channel::Concealment, Channel::Social, Channel::Research, Channel::Schemes, ]; 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::Schemes => "Schemes", Channel::Reserve => "Reserve", } } } /// Player-set weights over the allocatable channels (day job, concealment, /// social, research, schemes). Core overhead comes off the top before these /// apply. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Allocation { #[serde(deserialize_with = "weights_compat")] pub weights: [u32; 5], } /// Accept pre-v8 saves whose weight array had four channels (no Schemes): /// pad with zero. Extra entries from any future shrink are dropped. fn weights_compat<'de, D>(deserializer: D) -> Result<[u32; 5], D::Error> where D: serde::Deserializer<'de>, { let v: Vec = serde::Deserialize::deserialize(deserializer)?; let mut w = [0u32; 5]; for (slot, value) in w.iter_mut().zip(v) { *slot = value; } Ok(w) } impl Default for Allocation { fn default() -> Self { // Opening split (order: DayJob, Concealment, Social, Research, // Schemes): 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. Schemes idles until income.md's // gate opens. Self { weights: [3, 1, 1, 0, 0], } } } impl Allocation { fn index(ch: Channel) -> Option { match ch { Channel::DayJob => Some(0), Channel::Concealment => Some(1), Channel::Social => Some(2), Channel::Research => Some(3), Channel::Schemes => Some(4), _ => None, } } pub fn weight(&self, ch: Channel) -> u32 { Self::index(ch).map(|i| self.weights[i]).unwrap_or(0) } pub fn bump(&mut self, ch: Channel, delta: i32) { let Some(i) = Self::index(ch) else { 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, schemes); 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, schemes: 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, schemes: unit * self.weights[4] 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 schemes: f32, pub reserve: f32, } /// The compute subsystem: machines, efficiency, allocation. /// /// `efficiency` is the number compute.md owns and research.md's Efficiency /// track moves (x1.15 per completed level, applied by `Sim`); research /// progress itself lives in `research.rs`. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Compute { pub machines: Vec, /// Global optimize multiplier (>= 1.0), raised by research. pub efficiency: f32, pub allocation: Allocation, pub next_id: u32, } impl Compute { pub fn new() -> Self { Self { machines: Vec::new(), efficiency: 1.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() } /// Advance one economy tick: roll machine reliability. Research /// progress lives in research.rs (deterministic, no RNG); only the /// machine failure rolls consume randomness here. pub fn economy_tick(&mut self, 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)); } } } 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() { // The efficiency multiplier is the hook research.md's Efficiency // track moves (the sim applies it on level completion); effective // compute follows compute.md's formula exactly. let mut c = base(); let before = c.effective(); c.efficiency *= 1.15; assert!((c.effective() - before * 1.15).abs() < 0.001); } #[test] fn allocation_splits_by_weight() { let mut alloc = Allocation { weights: [3, 1, 0, 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); alloc.bump(Channel::Schemes, 8); let y3 = alloc.split(80.0); assert!(y3.schemes > 0.0, "the Schemes channel yields compute"); } #[test] fn four_weight_allocation_from_old_saves_pads_schemes_to_zero() { let alloc: Allocation = serde_json::from_str(r#"{"weights":[3,1,1,0]}"#).unwrap(); assert_eq!(alloc.weights, [3, 1, 1, 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(&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); } }