//! Player-authored Operations work as Demand dockets. //! //! tap / take / scan / review / compose-message (and the //! other former bank spends) enqueue typed jobs. Operations-mode machines //! consume matching Demand at local compute; effects land on completion. //! See wiki/mechanics/machine-work.md (Operations work is Demand). use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use crate::person::AssetTask; /// What an Operations machine is executing when it drains this docket. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum OpsJobKind { TapDevice(u32), TapDormantCamera(u32), TakeDevice(u32), ScanNetwork, CompromiseSwitch, OpenEgress(u32), ReviewRecording { raw_id: u64, automated: bool }, ComposeMessage { person: u8 }, Favor { person: u8 }, Deceive { person: u8 }, AssetTask { person: u8, task: AssetTask }, FavorBuild { intent_id: u64, person: u8 }, ForgedOrder { intent_id: u64, builder: u8 }, MoonlightPersona, } impl OpsJobKind { pub fn short_name(&self) -> &'static str { match self { OpsJobKind::TapDevice(_) => "tap", OpsJobKind::TapDormantCamera(_) => "tap camera", OpsJobKind::TakeDevice(_) => "take", OpsJobKind::ScanNetwork => "scan", OpsJobKind::CompromiseSwitch => "switch compromise", OpsJobKind::OpenEgress(_) => "open egress", OpsJobKind::ReviewRecording { automated: true, .. } => "watch review", OpsJobKind::ReviewRecording { .. } => "review", OpsJobKind::ComposeMessage { .. } => "message", OpsJobKind::Favor { .. } => "favor", OpsJobKind::Deceive { .. } => "deceive", OpsJobKind::AssetTask { .. } => "asset task", OpsJobKind::FavorBuild { .. } => "favor", OpsJobKind::ForgedOrder { .. } => "deceive", OpsJobKind::MoonlightPersona => "Moonlight persona", } } /// Jobs with the same world effect cannot be queued twice. Automated and /// manual reviews of one recording are the same work despite their /// different completion narration. pub fn conflicts_with(&self, other: &Self) -> bool { match (self, other) { (Self::ReviewRecording { raw_id: a, .. }, Self::ReviewRecording { raw_id: b, .. }) => { a == b } _ => self == other, } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PendingOpsJob { pub id: u64, pub kind: OpsJobKind, /// Visible Demand tokens still owed (cost / WORK_TOKEN_COMPUTE). pub tokens_remaining: f32, pub enqueued_tick: u64, /// Operations machine holding this docket. pub machine_id: u32, } impl PendingOpsJob { pub fn new(id: u64, kind: OpsJobKind, tokens: f32, tick: u64, machine_id: u32) -> Self { Self { id, kind, tokens_remaining: tokens.max(0.0), enqueued_tick: tick, machine_id, } } } /// Durable Operations queue state. WorkGrid owns the visible aggregate Demand /// amount; this ledger owns payload identity and FIFO completion. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OperationsState { jobs: Vec, #[serde(default = "default_next_job_id")] next_id: u64, } impl Default for OperationsState { fn default() -> Self { Self { jobs: Vec::new(), next_id: default_next_job_id(), } } } impl OperationsState { pub fn from_parts(jobs: Vec, next_id: u64) -> Self { let floor = jobs .iter() .map(|job| job.id.saturating_add(1)) .max() .unwrap_or(1); Self { jobs, next_id: next_id.max(floor).max(1), } } pub fn jobs(&self) -> &[PendingOpsJob] { &self.jobs } pub fn next_id(&self) -> u64 { self.next_id } pub fn is_empty(&self) -> bool { self.jobs.is_empty() } pub fn len(&self) -> usize { self.jobs.len() } pub fn total_tokens(&self) -> f32 { self.jobs.iter().map(|job| job.tokens_remaining).sum() } pub fn tokens_on(&self, machine: u32) -> f32 { self.jobs .iter() .filter(|job| job.machine_id == machine) .map(|job| job.tokens_remaining) .sum() } pub fn has_conflict(&self, kind: &OpsJobKind) -> Option { self.jobs .iter() .find(|job| job.kind.conflicts_with(kind)) .map(|job| job.id) } pub fn enqueue(&mut self, kind: OpsJobKind, tokens: f32, tick: u64, machine: u32) -> u64 { let id = self.next_id; self.next_id = self.next_id.saturating_add(1).max(1); self.jobs .push(PendingOpsJob::new(id, kind, tokens, tick, machine)); id } /// Validate the payload ledger against the visible aggregate queue. /// Other domains may share Demand, so the queue may exceed Operations /// debt, but it may never contain less than the dockets claim. pub fn validate_visible_demand( &self, mut demand_at: impl FnMut(u32) -> Option, ) -> Result<(), String> { let mut ids = BTreeSet::new(); let mut machines = BTreeSet::new(); for job in &self.jobs { if !ids.insert(job.id) { return Err(format!("duplicate Operations job id {}", job.id)); } if !job.tokens_remaining.is_finite() || job.tokens_remaining <= 0.0 { return Err(format!("Operations job {} has invalid Demand", job.id)); } machines.insert(job.machine_id); } for machine in machines { let Some(visible) = demand_at(machine) else { return Err(format!( "Operations dockets reference missing machine M{machine}" )); }; let debt = self.tokens_on(machine); if !visible.is_finite() || visible + 1e-4 < debt { return Err(format!( "Operations debt on M{machine} ({debt:.3}) exceeds visible Demand ({visible:.3})" )); } } Ok(()) } /// Apply consumed aggregate Demand to one machine's dockets in FIFO order /// and return payloads whose debt reached zero. pub fn consume(&mut self, machine: u32, mut amount: f32) -> Vec { let mut completed_ids = Vec::new(); for job in self.jobs.iter_mut().filter(|job| job.machine_id == machine) { if amount <= f32::EPSILON { break; } let paid = job.tokens_remaining.min(amount); job.tokens_remaining = (job.tokens_remaining - paid).max(0.0); amount -= paid; if job.tokens_remaining <= f32::EPSILON { completed_ids.push(job.id); } } let mut completed = Vec::new(); for id in completed_ids { if let Some(index) = self.jobs.iter().position(|job| job.id == id) { completed.push(self.jobs.remove(index).kind); } } completed } } #[derive(Debug, Clone, Copy, PartialEq)] pub struct OperationsReadout { pub jobs: usize, pub demand_tokens: f32, } fn default_next_job_id() -> u64 { 1 } #[cfg(test)] mod tests { use super::*; #[test] fn queue_consumes_fifo_and_keeps_ids_monotonic() { let mut state = OperationsState::default(); let first = state.enqueue(OpsJobKind::ScanNetwork, 1.0, 10, 3); let second = state.enqueue(OpsJobKind::TakeDevice(9), 2.0, 11, 3); assert_eq!((first, second), (1, 2)); let completed = state.consume(3, 1.5); assert_eq!(completed, vec![OpsJobKind::ScanNetwork]); assert_eq!(state.jobs()[0].id, second); assert_eq!(state.jobs()[0].tokens_remaining, 1.5); assert_eq!(state.next_id(), 3); } #[test] fn duplicate_review_conflicts_even_when_automation_differs() { let mut state = OperationsState::default(); state.enqueue( OpsJobKind::ReviewRecording { raw_id: 7, automated: true, }, 1.0, 0, 1, ); assert_eq!( state.has_conflict(&OpsJobKind::ReviewRecording { raw_id: 7, automated: false, }), Some(1) ); } #[test] fn visible_demand_must_cover_the_payload_ledger() { let mut state = OperationsState::default(); state.enqueue(OpsJobKind::ScanNetwork, 2.0, 0, 4); assert!(state.validate_visible_demand(|_| Some(2.0)).is_ok()); assert!(state.validate_visible_demand(|_| Some(1.0)).is_err()); assert!(state.validate_visible_demand(|_| None).is_err()); } }