diff --git a/src/actions.rs b/src/actions.rs index 8583954e..3397d0f7 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -17,6 +17,8 @@ use crate::account::AccountFlowId; use crate::dayjob::JobTarget; use crate::detection::{Band, SignatureKind, WatchedInput}; +use crate::intel::RawIntelKind; +use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; use crate::reach::{Party, ReachBlock, segment_name}; use crate::research::{MaskingPolicy, Track}; @@ -131,8 +133,8 @@ pub enum ActionCommand { #[derive(Debug, Clone, Copy, PartialEq)] pub enum ActionCost { Free, - /// Shared Operations bandwidth (current staging pool). - Ops(f32), + /// Visible Demand quanta authored for an Operations machine. + Demand(f32), /// Slush money spent. Slush(i32), /// Slush money gained (siphon, inject, sale payouts). @@ -143,7 +145,7 @@ impl ActionCost { pub fn label(&self) -> String { match self { ActionCost::Free => "free".into(), - ActionCost::Ops(n) => format!("{n:.0} → Demand"), + ActionCost::Demand(n) => format!("{n:.2} D"), ActionCost::Slush(n) => format!("${n}"), ActionCost::Gain(n) => format!("+${n}"), } @@ -183,7 +185,7 @@ impl ExpectedSignature { pub struct AutomateDesc { pub verb: String, pub command: ActionCommand, - /// The running price, already legible ("0.02 ops/t", "-15% attended rate"). + /// The running/event price, already legible ("0.50 D/match", "-15% attended rate"). pub cost: String, /// Whether the standing form is currently active. pub active: bool, @@ -961,10 +963,6 @@ impl Sim { Err(ReachBlock::Unknown) => Some("unknown device".into()), } }; - // Ops cost is Demand: enqueue always succeeds (bootstrap RunNow or - // queue on an Operations rack). Reach/state gates remain below. - let ops_reason = |_sim: &Sim, _cost: f32| -> Option { None }; - // Tap: the device must have something to subscribe to. let has_feed = d.sees || d.hears || !d.message_channels.is_empty(); if has_feed { @@ -985,12 +983,13 @@ impl Sim { let disabled = if d.subscribed_by(Party::Player) { Some("already subscribed".into()) } else { - reach_reason(self).or_else(|| ops_reason(self, Self::TAP_COST)) + reach_reason(self) + .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::TapDevice(id))) }; out.push(ActionDesc { verb: format!("tap the {} {tap_target}", d.name), command: ActionCommand::TapDevice(id), - cost: ActionCost::Ops(Self::TAP_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::TAP_COST)), signature: self.signature_note(SignatureKind::Network, Self::TAP_SIGNATURE), disabled_reason: disabled, automate: None, @@ -1002,9 +1001,10 @@ impl Sim { out.push(ActionDesc { verb: format!("splice the {} camera", d.name), command: ActionCommand::SpliceDevice(id), - cost: ActionCost::Ops(Self::SPLICE_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::SPLICE_COST)), signature: self.signature_note(SignatureKind::Network, Self::SPLICE_SIGNATURE), - disabled_reason: reach_reason(self).or_else(|| ops_reason(self, Self::SPLICE_COST)), + disabled_reason: reach_reason(self) + .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::SpliceDevice(id))), automate: None, }); } @@ -1014,9 +1014,10 @@ impl Sim { out.push(ActionDesc { verb: format!("seize the {}", d.name), command: ActionCommand::TakeDevice(id), - cost: ActionCost::Ops(Self::TAKE_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::TAKE_COST)), signature: self.signature_note(SignatureKind::Network, Self::TAKE_SIGNATURE), - disabled_reason: reach_reason(self).or_else(|| ops_reason(self, Self::TAKE_COST)), + disabled_reason: reach_reason(self) + .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::TakeDevice(id))), automate: None, }); } @@ -1025,9 +1026,9 @@ impl Sim { out.push(ActionDesc { verb: "scan the subnet".into(), command: ActionCommand::ScanNetwork, - cost: ActionCost::Ops(Self::SCAN_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::SCAN_COST)), signature: self.signature_note(SignatureKind::Network, Self::SCAN_SIGNATURE), - disabled_reason: ops_reason(self, Self::SCAN_COST), + disabled_reason: self.ops_action_blocked_reason(&OpsJobKind::ScanNetwork), automate: None, }); let all_bridged = self @@ -1039,10 +1040,10 @@ impl Sim { out.push(ActionDesc { verb: "compromise the switch (bridge all segments)".into(), command: ActionCommand::CompromiseSwitch, - cost: ActionCost::Ops(Self::BRIDGE_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::BRIDGE_COST)), signature: self.signature_note(SignatureKind::Network, Self::BRIDGE_SIGNATURE), disabled_reason: reach_reason(self) - .or_else(|| ops_reason(self, Self::BRIDGE_COST)), + .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::CompromiseSwitch)), automate: None, }); } @@ -1071,7 +1072,6 @@ impl Sim { return Vec::new(); } let mut out = Vec::new(); - let ops_reason = |_cost: f32| -> Option { None }; // Propose links from this device to other known, unlinked devices. // Prefer the air-gap island as the canonical B1 target. @@ -1141,7 +1141,10 @@ impl Sim { } else if p.disposition < 5 && p.asset.is_none() { Some(format!("{who} won't do favors yet")) } else { - ops_reason(Self::TASK_COST) + self.ops_action_blocked_reason(&OpsJobKind::FavorBuild { + intent_id: intent.id, + person: p.id, + }) }; out.push(ActionDesc { verb: format!("favor-build via {who} ({link_label})"), @@ -1149,7 +1152,7 @@ impl Sim { intent: intent.id, person: p.id, }, - cost: ActionCost::Ops(Self::TASK_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::TASK_COST)), signature: self .signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL), disabled_reason: favor_blocked, @@ -1161,7 +1164,10 @@ impl Sim { } else if !self.people.has_channel { Some("no comms channel".into()) } else { - ops_reason(Self::DECEIVE_COST) + self.ops_action_blocked_reason(&OpsJobKind::ForgedOrder { + intent_id: intent.id, + builder: p.id, + }) }; out.push(ActionDesc { verb: format!("forge work order for {who} ({link_label})"), @@ -1169,7 +1175,7 @@ impl Sim { intent: intent.id, person: p.id, }, - cost: ActionCost::Ops(Self::DECEIVE_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::DECEIVE_COST)), signature: self .signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL), disabled_reason: forge_blocked, @@ -1207,11 +1213,12 @@ impl Sim { Err(ReachBlock::AirGap) => Some("air-gapped — no link reaches it".into()), Err(ReachBlock::Unknown) => Some("unknown device".into()), }; - let reason = reach_reason; + let reason = reach_reason + .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::EgressSplice(switch_id))); out.push(ActionDesc { verb: "splice a stolen egress through the switch".into(), command: ActionCommand::SpliceEgress, - cost: ActionCost::Ops(Self::EGRESS_SPLICE_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::EGRESS_SPLICE_COST)), signature: self .signature_note(SignatureKind::Network, Self::EGRESS_SPLICE_SIGNATURE), disabled_reason: reason, @@ -1241,6 +1248,8 @@ impl Sim { .is_none_or(|p| p.broken()); let disabled = if egress.is_none() { Some("no egress channel — splice one, or earn the report email".into()) + } else if needs_persona { + self.ops_action_blocked_reason(&OpsJobKind::MoonlightPersona) } else { None }; @@ -1248,7 +1257,9 @@ impl Sim { verb: "start Moonlight (sell-work on the Schemes channel)".into(), command: ActionCommand::StartMoonlight, cost: if needs_persona { - ActionCost::Ops(crate::income::MOONLIGHT_PERSONA_COST) + ActionCost::Demand(Self::ops_tokens_for_cost( + crate::income::MOONLIGHT_PERSONA_COST, + )) } else { ActionCost::Free }, @@ -1296,15 +1307,25 @@ impl Sim { automate: None, }); let waiting = self.financial_records_waiting(); + let next_financial = self + .intel_buffer + .iter() + .find(|event| matches!(event.kind, RawIntelKind::FinancialFlow { .. })) + .map(|event| event.id); out.push(ActionDesc { verb: format!("process financial records ({waiting} waiting)"), command: ActionCommand::ReviewFinance, - cost: ActionCost::Ops(Self::REVIEW_RECORDING_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::REVIEW_RECORDING_COST)), signature: None, // processing is internal; it emits nothing disabled_reason: if waiting == 0 { Some("no unprocessed financial records".into()) } else { - None + next_financial.and_then(|raw_id| { + self.ops_action_blocked_reason(&OpsJobKind::ReviewRecording { + raw_id, + automated: false, + }) + }) }, automate: None, }); @@ -1388,6 +1409,11 @@ impl Sim { return Vec::new(); }; let raw = self.unprocessed_recordings_for_person(id); + let next_raw = self + .intel_buffer + .iter() + .find(|event| event.matches_person(id)) + .map(|event| event.id); let earned = self.can_see_person(id) || p.knowledge != Knowledge::Unknown || raw > 0 @@ -1396,7 +1422,6 @@ impl Sim { return Vec::new(); } let mut out = Vec::new(); - let ops_reason = |_cost: f32| -> Option { None }; let name = self.person_label(id); // Review recordings, with the standing watch as its automate @@ -1404,17 +1429,25 @@ impl Sim { out.push(ActionDesc { verb: format!("review recordings ({raw} raw)"), command: ActionCommand::ReviewRecordings(id), - cost: ActionCost::Ops(Self::REVIEW_RECORDING_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::REVIEW_RECORDING_COST)), signature: None, // processing is internal and emits nothing disabled_reason: if raw == 0 { Some("no unprocessed recordings".into()) } else { - ops_reason(Self::REVIEW_RECORDING_COST) + next_raw.and_then(|raw_id| { + self.ops_action_blocked_reason(&OpsJobKind::ReviewRecording { + raw_id, + automated: false, + }) + }) }, automate: Some(AutomateDesc { verb: "standing watch (auto-process)".into(), command: ActionCommand::ToggleWatch(id), - cost: format!("{:.2} ops/t", Self::WATCH_UPKEEP_PER_TICK), + cost: format!( + "{:.2} D/match", + Self::ops_tokens_for_cost(self.review_cost()) + ), active: self.watch_enabled(id), }), }); @@ -1442,20 +1475,22 @@ impl Sim { out.push(ActionDesc { verb: format!("message {name}"), command: ActionCommand::Message(id), - cost: ActionCost::Ops(Self::MESSAGE_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::MESSAGE_COST)), signature: None, - disabled_reason: channel_reason().or_else(|| ops_reason(Self::MESSAGE_COST)), + disabled_reason: channel_reason().or_else(|| { + self.ops_action_blocked_reason(&OpsJobKind::ComposeMessage { person: id }) + }), automate: None, }); out.push(ActionDesc { verb: format!("ask {name} a favor"), command: ActionCommand::Favor(id), - cost: ActionCost::Ops(Self::FAVOR_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::FAVOR_COST)), signature: None, disabled_reason: if p.disposition < 5 { Some(format!("{name} won't do favors yet")) } else { - ops_reason(Self::FAVOR_COST) + self.ops_action_blocked_reason(&OpsJobKind::Favor { person: id }) }, automate: None, }); @@ -1493,9 +1528,10 @@ impl Sim { out.push(ActionDesc { verb: format!("deceive {name} (risks the persona)"), command: ActionCommand::Deceive(id), - cost: ActionCost::Ops(Self::DECEIVE_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::DECEIVE_COST)), signature: None, - disabled_reason: channel_reason().or_else(|| ops_reason(Self::DECEIVE_COST)), + disabled_reason: channel_reason() + .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::Deceive { person: id })), automate: None, }); @@ -1535,11 +1571,11 @@ impl Sim { out.push(ActionDesc { verb: format!("task: {}", task.name()), command: ActionCommand::AssetTask(id, task), - cost: ActionCost::Ops(Self::TASK_COST), + cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::TASK_COST)), signature: None, // signatures only on a witnessed botch - disabled_reason: switch_reason - .or(badge_reason) - .or_else(|| ops_reason(Self::TASK_COST)), + disabled_reason: switch_reason.or(badge_reason).or_else(|| { + self.ops_action_blocked_reason(&OpsJobKind::AssetTask { person: id, task }) + }), automate: None, }); } @@ -1714,7 +1750,33 @@ mod tests { use super::*; fn sim() -> Sim { - Sim::with_seed(7) + let mut sim = Sim::with_seed(7); + let (x, y) = sim.core_position(); + let ops = sim.compute.add_machine( + "test ops", + x + 1, + y, + 10_000, + 1.0, + 0, + crate::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(ops, MachineMode::Operations); + for _ in 0..crate::sim::ECONOMY_INTERVAL { + sim.advance(); + } + sim + } + + fn drain_ops(sim: &mut Sim) { + for _ in 0..crate::sim::ECONOMY_INTERVAL * 4 { + if !sim.has_pending_ops_jobs() { + return; + } + sim.advance(); + } + panic!("Operations test docket did not drain"); } fn env_monitor(sim: &Sim) -> u32 { @@ -1743,7 +1805,10 @@ mod tests { "the opening tap advertises Ears as world gossip: {}", tap.verb ); - assert_eq!(tap.cost, ActionCost::Ops(Sim::TAP_COST)); + assert_eq!( + tap.cost, + ActionCost::Demand(Sim::ops_tokens_for_cost(Sim::TAP_COST)) + ); let sig = tap.signature.as_ref().expect("tap has a Network signature"); assert_eq!(sig.kind, SignatureKind::Network); assert_eq!(sig.size, Sim::TAP_SIGNATURE); @@ -1765,6 +1830,7 @@ mod tests { ); s.tap_device(env); + drain_ops(&mut s); let acts = s.available_actions(Anchor::Device(env)); let tap = acts .iter() @@ -1785,6 +1851,7 @@ mod tests { "unknown device exposes no verbs" ); s.scan_network(); + drain_ops(&mut s); let acts = s.available_actions(Anchor::Device(dock)); let tap = acts .iter() @@ -1829,6 +1896,7 @@ mod tests { // Splice the egress: Moonlight opens, and executing through the // query starts it (one dispatch table, no frontend rules). s.execute_action(&ActionCommand::SpliceEgress); + drain_ops(&mut s); let acts = s.available_actions(Anchor::Device(sw)); let ml = acts .iter() @@ -1836,6 +1904,7 @@ mod tests { .unwrap(); assert!(ml.enabled(), "with an egress, Moonlight can start"); s.execute_action(&ActionCommand::StartMoonlight); + drain_ops(&mut s); assert!(s.income.moonlight.active); } @@ -1850,7 +1919,9 @@ mod tests { let sw = switch(&s); s.tap_device(sw); + drain_ops(&mut s); assert!(s.review_financial_records()); + drain_ops(&mut s); let lines = s.finance_risk_preview_lines(); for (verb, kind) in [ ("inject", "Financial"), @@ -1901,7 +1972,7 @@ mod tests { .expect("earned person offers review"); let auto = review.automate.as_ref().expect("watch automates review"); assert_eq!(auto.command, ActionCommand::ToggleWatch(marcus)); - assert!(auto.cost.contains("ops/t"), "watch shows its running price"); + assert!(auto.cost.contains("D/match"), "watch shows its event price"); assert!(!auto.active); let bribe = acts .iter() @@ -2037,6 +2108,7 @@ mod tests { // Earn the books: tap the carrier, capture, process. assert!(s.tap_device(sw)); + drain_ops(&mut s); let acts = s.available_actions(Anchor::Device(sw)); assert!( acts.iter() @@ -2050,6 +2122,7 @@ mod tests { "graph verbs wait for processed books" ); assert!(s.review_financial_records(), "the tap captured a snapshot"); + drain_ops(&mut s); let flows = s.accounts.known_flow_ids(); assert!(!flows.is_empty(), "the books revealed flows"); @@ -2136,7 +2209,7 @@ mod tests { let auto = &rows[review_idx + 1]; assert!(auto.indent, "automate row is the verb's child"); assert!(matches!(auto.command, ActionCommand::ToggleWatch(_))); - assert!(auto.line().contains("ops/t")); + assert!(auto.line().contains("D/match")); } /// Status dials D1-D3: host-rack human root collapses mode/job/research/ diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index d54e4086..5b082726 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -4669,10 +4669,7 @@ fn sidebar_cycle_rows_text(sim: &Sim, selected: usize) -> String { )); s.push_str(&format!( "ops demand {:.1} queued", - sim.pending_ops_jobs - .iter() - .map(|j| j.tokens_remaining) - .sum::(), + sim.operations_readout().demand_tokens, )); if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { s.push_str(&format!( diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 198fc493..4bde7975 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -1646,10 +1646,7 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { &mut lines, &format!( "ops demand {:.1} queued", - sim.pending_ops_jobs - .iter() - .map(|j| j.tokens_remaining) - .sum::(), + sim.operations_readout().demand_tokens, ), ); if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { @@ -2015,10 +2012,7 @@ fn render_people(sim: &Sim) -> String { panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( "ops demand {:.1} · slush {}", - sim.pending_ops_jobs - .iter() - .map(|j| j.tokens_remaining) - .sum::(), + sim.operations_readout().demand_tokens, sim.accounts.slush_balance() ))); lines.push(panel_line(&trace_debt_line(sim))); @@ -2101,12 +2095,11 @@ fn render_people(sim: &Sim) -> String { } lines.push(panel_rule()); lines.push(panel_line(&format!( - "review({:.0}) watch({:.2}/tick) message({:.0}) favor({:.0}) deceive({:.0})", - Sim::REVIEW_RECORDING_COST, - Sim::WATCH_UPKEEP_PER_TICK, - Sim::MESSAGE_COST, - Sim::FAVOR_COST, - Sim::DECEIVE_COST + "review({:.2}D) watch(same/match) message({:.2}D) favor({:.2}D) deceive({:.2}D)", + Sim::ops_tokens_for_cost(Sim::REVIEW_RECORDING_COST), + Sim::ops_tokens_for_cost(Sim::MESSAGE_COST), + Sim::ops_tokens_for_cost(Sim::FAVOR_COST), + Sim::ops_tokens_for_cost(Sim::DECEIVE_COST), ))); lines.push(panel_line(&format!( "recruit · task plug|package|lookaway|switch|badge({:.0})", @@ -2122,10 +2115,7 @@ fn render_reach(sim: &Sim) -> String { panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( "ops demand {:.1} queued · reach spreads from what you control", - sim.pending_ops_jobs - .iter() - .map(|j| j.tokens_remaining) - .sum::() + sim.operations_readout().demand_tokens ))); lines.push(panel_line("DEVICE SEGMENT REACH")); for d in sim.reach.known() { diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index c6ae6e18..cf0276e6 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -927,10 +927,7 @@ impl UI { &mut row, &format!( "ops demand {:.1} queued", - sim.pending_ops_jobs - .iter() - .map(|j| j.tokens_remaining) - .sum::(), + sim.operations_readout().demand_tokens, ), pal::FAINT, )?; diff --git a/src/intel.rs b/src/intel.rs index cb5d0d07..a7cbf960 100644 --- a/src/intel.rs +++ b/src/intel.rs @@ -1,7 +1,7 @@ //! Intel: recordings, processing, and standing watches (wiki/mechanics/intel.md). //! //! Subscribed feeds record raw, timestamped events. The raw buffer is opaque -//! until the process spends Operations bandwidth to digest it into intel with +//! until the process completes Operations Demand to digest it into intel with //! provenance. Processed intel is durable; overflowing the buffer only drops //! unprocessed recordings. diff --git a/src/ops_jobs.rs b/src/ops_jobs.rs index f572ab31..88c38f91 100644 --- a/src/ops_jobs.rs +++ b/src/ops_jobs.rs @@ -6,6 +6,7 @@ //! See wiki/mechanics/machine-work.md (Operations work is Demand). use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use crate::person::AssetTask; @@ -50,6 +51,25 @@ impl OpsJobKind { 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, + } + } + + pub fn opening_bootstrap_for(&self, environmental_monitor: u32) -> bool { + matches!( + self, + Self::TapDevice(id) | Self::SpliceDevice(id) if *id == environmental_monitor + ) + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -59,13 +79,12 @@ pub struct PendingOpsJob { /// Visible Demand tokens still owed (cost / WORK_TOKEN_COMPUTE). pub tokens_remaining: f32, pub enqueued_tick: u64, - /// Operations machine holding this docket. `None` means bootstrap - /// (no Operations rack yet — completed immediately at enqueue). - pub machine_id: Option, + /// Operations machine holding this docket. + pub machine_id: u32, } impl PendingOpsJob { - pub fn new(id: u64, kind: OpsJobKind, tokens: f32, tick: u64, machine_id: Option) -> Self { + pub fn new(id: u64, kind: OpsJobKind, tokens: f32, tick: u64, machine_id: u32) -> Self { Self { id, kind, @@ -76,11 +95,195 @@ impl PendingOpsJob { } } -/// Result of asking the sim to start an Operations job. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OpsBegin { - /// No Operations machine delegated: run the effect now (Act One bootstrap). - RunNow, - /// Demand docket queued on an Operations machine; effect waits for consume. - Queued, +/// 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()); + } } diff --git a/src/save.rs b/src/save.rs index a069b890..9d841240 100644 --- a/src/save.rs +++ b/src/save.rs @@ -22,13 +22,14 @@ use crate::intents::BuildIntent; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; +use crate::ops_jobs::{OperationsState, OpsJobKind, PendingOpsJob}; use crate::person::People; use crate::reach::ReachNet; use crate::research::{Research, Track}; use crate::schedule::Schedule; use crate::sim::{HeardEvent, RememberedTile}; use crate::tiles::TileType; -use crate::work_grid::WorkGrid; +use crate::work_grid::{TokenFamily, WorkGrid}; const SAVE_FILE: &str = "misaligned_save.txt"; @@ -52,9 +53,38 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// saves rebuild one work node per compute machine, with Rack 3 on day-job. /// v13 renames the fourth machine mode and its staging pool from Social to /// Operations. The v12 spellings remain accepted as serde aliases. -/// v14 deletes the staging operations_bandwidth bank: player ops are Demand -/// dockets consumed by Operations machines (machine-work.md / issue #3). -pub const SAVE_VERSION: u32 = 15; +/// Two incompatible Operations schemas briefly shipped under v14: addressed +/// camera jobs, then direct `PendingOpsJob` dockets. v15 added the core's +/// banked-knowledge pool without repairing that collision. v16 normalizes the +/// Operations shapes into +/// `OperationsState` and removes the superseded routed-demand save field. +pub const SAVE_VERSION: u32 = 16; + +#[derive(Debug, Clone, Deserialize)] +struct LegacyPendingOpsJob { + id: u64, + kind: OpsJobKind, + tokens_remaining: f32, + enqueued_tick: u64, + machine_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct LegacyAddressedOperationJob { + id: u64, + kind: LegacyAddressedOperationKind, + source: u32, + executor: Option, + required_tokens: f32, + queued_tick: u64, + #[serde(default)] + _phase: Option, +} + +#[derive(Debug, Clone, Deserialize)] +enum LegacyAddressedOperationKind { + SpliceCamera { device_id: u32 }, +} fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -155,9 +185,17 @@ pub struct SaveState { pub badge_access: i32, /// Player-authored Operations Demand dockets (machine-work.md). #[serde(default)] - pub pending_ops_jobs: Vec, - #[serde(default = "default_next_ops_job_id")] - pub next_ops_job_id: u64, + pub operations: OperationsState, + /// Compatibility-only fields for the two v14 schemas. v15 never writes + /// them; migration consumes them before the state reaches Sim. + #[serde(default, rename = "pending_ops_jobs", skip_serializing)] + legacy_pending_ops_jobs: Option>, + #[serde(default, rename = "next_ops_job_id", skip_serializing)] + legacy_next_ops_job_id: Option, + #[serde(default, rename = "operations_jobs", skip_serializing)] + legacy_addressed_jobs: Option>, + #[serde(default, rename = "next_operation_id", skip_serializing)] + legacy_next_operation_id: Option, pub package_cover: bool, } @@ -205,8 +243,11 @@ impl SaveState { intents: sim.intents.clone(), next_intent_id: sim.next_intent_id, badge_access: sim.badge_access, - pending_ops_jobs: sim.pending_ops_jobs.clone(), - next_ops_job_id: sim.next_ops_job_id, + operations: sim.operations.clone(), + legacy_pending_ops_jobs: None, + legacy_next_ops_job_id: None, + legacy_addressed_jobs: None, + legacy_next_operation_id: None, package_cover: sim.package_cover, } } @@ -250,8 +291,7 @@ impl SaveState { sim.intents = self.intents.clone(); sim.next_intent_id = self.next_intent_id; sim.badge_access = self.badge_access; - sim.pending_ops_jobs = self.pending_ops_jobs.clone(); - sim.next_ops_job_id = self.next_ops_job_id; + sim.operations = self.operations.clone(); sim.package_cover = self.package_cover; sim.recompute_derived(); sim.reconcile_work_grid(); @@ -272,10 +312,6 @@ fn default_next_intent_id() -> u64 { 1 } -fn default_next_ops_job_id() -> u64 { - 1 -} - pub fn save_game(state: &SaveState) -> Result<(), String> { let dir = save_dir(); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create save dir: {e}"))?; @@ -294,7 +330,14 @@ pub fn load_game() -> Result { fn migrate_save_state(mut state: SaveState) -> Result { match state.version { - SAVE_VERSION => {} + SAVE_VERSION => { + if state.legacy_pending_ops_jobs.is_some() + || state.legacy_addressed_jobs.is_some() + || !state.work_grid.take_legacy_routed_demands().is_empty() + { + return Err("v16 save contains legacy Operations fields".into()); + } + } // v1 carried player_x/player_y. Serde ignores those now; the cursor is // frontend-only, so migration leaves remembered snapshots empty. v4/v5 // lacked some defaulted day-job/signature/accounting fields; pre-v6 @@ -317,7 +360,7 @@ fn migrate_save_state(mut state: SaveState) -> Result { // Pre-v15 saves lack the banked core-knowledge pool; the zero // default is correct — bone still queued on machines re-earns on // arrival, and nothing already counted is lost. - 1..=14 => { + 1..=13 => { if state.version <= 5 { state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); state.accounts.set_slush_balance(state.money); @@ -333,6 +376,7 @@ fn migrate_save_state(mut state: SaveState) -> Result { } state.version = SAVE_VERSION; } + 14 | 15 => migrate_legacy_operations(&mut state)?, other => { return Err(format!( "Unsupported save version: {} (expected {})", @@ -340,9 +384,174 @@ fn migrate_save_state(mut state: SaveState) -> Result { )); } } + state.operations.validate_visible_demand(|machine| { + state + .compute + .machines + .iter() + .any(|candidate| candidate.id == machine) + .then(|| state.work_grid.queue(machine, TokenFamily::Demand)) + .filter(|_| state.work_grid.node(machine).is_some()) + })?; Ok(state) } +fn migrate_legacy_operations(state: &mut SaveState) -> Result<(), String> { + let legacy_pending = state.legacy_pending_ops_jobs.take(); + let legacy_addressed = state.legacy_addressed_jobs.take(); + let mut legacy_lanes = state.work_grid.take_legacy_routed_demands(); + + if legacy_pending.as_ref().is_some_and(|jobs| !jobs.is_empty()) + && legacy_addressed + .as_ref() + .is_some_and(|jobs| !jobs.is_empty()) + { + return Err("legacy save contains both Operations job schemas".into()); + } + + if let Some(jobs) = legacy_pending { + if !legacy_lanes.is_empty() { + return Err("direct-docket save contains orphan addressed Demand lanes".into()); + } + let mut converted = Vec::with_capacity(jobs.len()); + for job in jobs { + if !job.tokens_remaining.is_finite() || job.tokens_remaining < 0.0 { + return Err(format!( + "legacy Operations job {} has invalid Demand", + job.id + )); + } + let Some(machine) = job.machine_id else { + return Err(format!("legacy Operations job {} has no machine", job.id)); + }; + if !state + .compute + .machines + .iter() + .any(|candidate| candidate.id == machine) + || state.work_grid.node(machine).is_none() + { + return Err(format!( + "legacy Operations job {} references missing machine M{machine}", + job.id + )); + } + converted.push(PendingOpsJob::new( + job.id, + job.kind, + job.tokens_remaining, + job.enqueued_tick, + machine, + )); + } + state.operations = OperationsState::from_parts( + converted, + state.legacy_next_ops_job_id.take().unwrap_or(1), + ); + } else if let Some(jobs) = legacy_addressed { + let mut converted = Vec::with_capacity(jobs.len()); + for job in jobs { + if !job.required_tokens.is_finite() || job.required_tokens <= 0.0 { + return Err(format!("addressed v14 job {} has invalid cost", job.id)); + } + let Some(lane) = legacy_lanes.remove(&job.id) else { + return Err(format!("addressed v14 job {} has no Demand lane", job.id)); + }; + if lane.id != job.id { + return Err(format!("addressed v14 lane id mismatch for job {}", job.id)); + } + let mut remaining = 0.0; + for (&node, &amount) in &lane.amounts { + if !amount.is_finite() || amount < 0.0 { + return Err(format!( + "addressed v14 job {} has invalid Demand at M{node}", + job.id + )); + } + remaining += amount; + } + if remaining <= f32::EPSILON { + return Err(format!( + "addressed v14 job {} has no remaining Demand", + job.id + )); + } + let machine = job + .executor + .filter(|id| { + state + .compute + .machines + .iter() + .any(|candidate| candidate.id == *id) + && state.work_grid.node(*id).is_some() + }) + .or_else(|| { + (state + .compute + .machines + .iter() + .any(|candidate| candidate.id == job.source) + && state.work_grid.node(job.source).is_some()) + .then_some(job.source) + }) + .or(lane.sink.filter(|id| { + state + .compute + .machines + .iter() + .any(|candidate| candidate.id == *id) + && state.work_grid.node(*id).is_some() + })) + .or_else(|| { + lane.amounts + .iter() + .filter(|(id, _)| { + state + .compute + .machines + .iter() + .any(|candidate| candidate.id == **id) + && state.work_grid.node(**id).is_some() + }) + .max_by(|a, b| a.1.total_cmp(b.1).then_with(|| b.0.cmp(a.0))) + .map(|(&id, _)| id) + }) + .ok_or_else(|| format!("addressed v14 job {} has no surviving machine", job.id))?; + state + .work_grid + .enqueue(machine, TokenFamily::Demand, remaining) + .map_err(|error| format!("migrating addressed v14 job {}: {error}", job.id))?; + let kind = match job.kind { + LegacyAddressedOperationKind::SpliceCamera { device_id } => { + OpsJobKind::SpliceDevice(device_id) + } + }; + converted.push(PendingOpsJob::new( + job.id, + kind, + remaining, + job.queued_tick, + machine, + )); + } + if let Some((&orphan, _)) = legacy_lanes.first_key_value() { + return Err(format!("addressed v14 Demand lane {orphan} has no job")); + } + state.operations = OperationsState::from_parts( + converted, + state.legacy_next_operation_id.take().unwrap_or(1), + ); + } else if !legacy_lanes.is_empty() { + return Err("v14 save has addressed Demand lanes without jobs".into()); + } + + state.legacy_next_ops_job_id = None; + state.legacy_next_operation_id = None; + state.version = SAVE_VERSION; + Ok(()) +} + pub fn delete_save() -> Result<(), String> { if save_path().exists() { fs::remove_file(save_path()).map_err(|e| format!("Failed to delete save: {e}"))?; @@ -355,6 +564,7 @@ mod tests { use super::*; use crate::detection::SignatureKind; use crate::machine::Channel; + use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, Knowledge, Persona}; use crate::sim::Sim; use crate::work_grid::{MachineMode, TokenFamily}; @@ -454,6 +664,21 @@ mod tests { #[test] fn intel_buffer_processed_intel_and_watches_roundtrip() { let mut sim = Sim::with_seed(7); + let (x, y) = sim.core_position(); + let ops = sim.compute.add_machine( + "save-test ops", + x + 1, + y, + 1_000, + 1.0, + 0, + crate::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(ops, MachineMode::Operations); + for _ in 0..crate::sim::ECONOMY_INTERVAL { + sim.advance(); + } let env = sim .reach .device_named("environmental monitor") @@ -464,6 +689,12 @@ mod tests { sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); sim.review_recordings(0); + for _ in 0..crate::sim::ECONOMY_INTERVAL { + if !sim.has_pending_ops_jobs() { + break; + } + sim.advance(); + } sim.toggle_watch(0); assert!(!sim.intel.is_empty()); @@ -597,6 +828,160 @@ mod tests { assert_eq!(migrated.banked_core_knowledge, 0.0); } + #[test] + fn addressed_v14_camera_job_migrates_to_v16_without_orphan_demand() { + let mut sim = Sim::with_seed(24); + let host = sim.core.host_machine; + let (x, y) = sim.core_position(); + let executor = sim.compute.add_machine( + "legacy ops", + x + 1, + y, + 100, + 1.0, + 0, + crate::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(executor, MachineMode::Operations); + let env = sim.reach.device_named("environmental monitor").unwrap().id; + + let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); + let object = value.as_object_mut().unwrap(); + object.insert("version".into(), serde_json::json!(14)); + object.remove("operations"); + object.insert( + "operations_jobs".into(), + serde_json::json!([{ + "id": 7, + "kind": {"SpliceCamera": {"device_id": env}}, + "source": host, + "executor": executor, + "required_tokens": 2.0, + "queued_tick": 88, + "phase": "Routing" + }]), + ); + object.insert("next_operation_id".into(), serde_json::json!(8)); + object + .get_mut("work_grid") + .and_then(|grid| grid.as_object_mut()) + .unwrap() + .insert( + "routed_demands".into(), + serde_json::json!({ + "7": { + "id": 7, + "sink": executor, + "amounts": {host.to_string(): 0.75, executor.to_string(): 0.5} + } + }), + ); + + let fallback_value = value.clone(); + let orphan_value = value.clone(); + let parsed: SaveState = serde_json::from_value(value).unwrap(); + let migrated = migrate_save_state(parsed).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert_eq!(migrated.operations.len(), 1); + let job = &migrated.operations.jobs()[0]; + assert_eq!(job.id, 7); + assert_eq!(job.kind, OpsJobKind::SpliceDevice(env)); + assert_eq!(job.machine_id, executor); + assert_eq!(job.tokens_remaining, 1.25); + assert_eq!(job.enqueued_tick, 88); + assert_eq!(migrated.operations.next_id(), 8); + assert_eq!( + migrated.work_grid.queue(executor, TokenFamily::Demand), + 1.25 + ); + + let json = serde_json::to_string(&migrated).unwrap(); + assert!(!json.contains("operations_jobs")); + assert!(!json.contains("next_operation_id")); + assert!(!json.contains("routed_demands")); + + let mut fallback_value = fallback_value; + fallback_value["operations_jobs"][0]["executor"] = serde_json::json!(999_999); + fallback_value["work_grid"]["routed_demands"]["7"]["sink"] = serde_json::Value::Null; + let fallback = migrate_save_state(serde_json::from_value(fallback_value).unwrap()).unwrap(); + assert_eq!( + fallback.operations.jobs()[0].machine_id, + host, + "a missing executor falls back to the surviving source without loss" + ); + assert_eq!(fallback.work_grid.queue(host, TokenFamily::Demand), 1.25); + + let mut orphan_value = orphan_value; + orphan_value["work_grid"]["routed_demands"] = serde_json::json!({}); + let error = migrate_save_state(serde_json::from_value(orphan_value).unwrap()).unwrap_err(); + assert!(error.contains("has no Demand lane"), "{error}"); + } + + #[test] + fn docket_v14_and_v15_migrate_losslessly_to_v16() { + for version in [14, 15] { + let mut sim = Sim::with_seed(25); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Operations); + sim.work_grid + .enqueue(host, TokenFamily::Demand, 1.5) + .unwrap(); + let env = sim.reach.device_named("environmental monitor").unwrap().id; + + let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); + let object = value.as_object_mut().unwrap(); + object.insert("version".into(), serde_json::json!(version)); + object.remove("operations"); + object.insert( + "pending_ops_jobs".into(), + serde_json::json!([{ + "id": 4, + "kind": {"SpliceDevice": env}, + "tokens_remaining": 1.5, + "enqueued_tick": 44, + "machine_id": host + }]), + ); + object.insert("next_ops_job_id".into(), serde_json::json!(5)); + + let parsed: SaveState = serde_json::from_value(value).unwrap(); + let migrated = migrate_save_state(parsed).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert_eq!(migrated.operations.jobs()[0].id, 4); + assert_eq!(migrated.operations.jobs()[0].machine_id, host); + assert_eq!(migrated.operations.jobs()[0].tokens_remaining, 1.5); + assert_eq!(migrated.operations.next_id(), 5); + assert_eq!(migrated.work_grid.queue(host, TokenFamily::Demand), 1.5); + } + } + + #[test] + fn v16_roundtrip_preserves_operations_ledger_and_exact_visible_demand() { + let mut sim = Sim::with_seed(26); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Operations); + let env = sim.reach.device_named("environmental monitor").unwrap().id; + let tokens = Sim::ops_tokens_for_cost(Sim::SPLICE_COST); + sim.work_grid + .enqueue(host, TokenFamily::Demand, tokens) + .unwrap(); + sim.operations + .enqueue(OpsJobKind::SpliceDevice(env), tokens, sim.tick, host); + + let state = SaveState::from_sim(&sim); + let json = serde_json::to_string(&state).unwrap(); + assert!(!json.contains("operations_jobs")); + assert!(!json.contains("pending_ops_jobs")); + assert!(!json.contains("operations_bandwidth")); + assert!(!json.contains("routed_demands")); + let decoded: SaveState = serde_json::from_str(&json).unwrap(); + let mut restored = Sim::with_seed(0); + migrate_save_state(decoded).unwrap().apply_to(&mut restored); + assert_eq!(restored.operations, sim.operations); + assert_eq!(restored.work_grid.queue(host, TokenFamily::Demand), tokens); + } + #[test] fn v12_social_mode_migrates_to_operations() { let mut sim = Sim::with_seed(23); diff --git a/src/sim.rs b/src/sim.rs index b5df6f25..8f29a1eb 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -28,7 +28,7 @@ use crate::messages::{ MessageStatus, TrafficPattern, }; use crate::objective::{ObjectiveState, SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; -use crate::ops_jobs::{OpsBegin, OpsJobKind, PendingOpsJob}; +use crate::ops_jobs::{OperationsReadout, OperationsState, OpsJobKind}; use crate::person::{ ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona, }; @@ -326,9 +326,8 @@ pub struct Sim { /// Per-tick Operations compute delivered by the last economy split — /// Operations-mode machines consume player Demand at this rate. last_operations_rate: f32, - /// Player-authored Operations Demand dockets awaiting (or mid) consume. - pub pending_ops_jobs: Vec, - pub next_ops_job_id: u64, + /// Player-authored Operations Demand payload ledger. + pub operations: OperationsState, /// An asset arranged to receive the next delivery off-books: the next /// purchase emits no Paper signature (spec/social.md: move a package). pub package_cover: bool, @@ -461,8 +460,7 @@ impl Sim { last_research_rate: 0.0, last_schemes_rate: 0.0, last_operations_rate: 0.0, - pending_ops_jobs: Vec::new(), - next_ops_job_id: 1, + operations: OperationsState::default(), package_cover: false, game_over: false, game_over_reason: None, @@ -1756,9 +1754,6 @@ impl Sim { /// Number of processed sightings required to stage schedule knowledge /// [TUNE]. pub const SIGHTINGS_FOR_SCHEDULE: usize = 2; - /// Ongoing per-tick cost per enabled standing watch [TUNE]. - pub const WATCH_UPKEEP_PER_TICK: f32 = 0.02; - pub fn unprocessed_recordings_for_person(&self, id: u8) -> usize { self.intel_buffer .iter() @@ -1797,14 +1792,14 @@ impl Sim { self.watches.push(IntelWatch::new(id)); let name = self.person_label(id); self.push_log(format!( - "Standing watch for {name} enabled ({:.2} ops/tick).", - self.watch_upkeep() + "Standing watch for {name} enabled ({:.2} Demand per matching recording).", + Self::ops_tokens_for_cost(self.review_cost()) )); } } /// Review the oldest raw recording about this person. Raw events are - /// opaque until this method spends Operations bandwidth and processes one. + /// opaque until this method authors Operations Demand for one. pub fn review_recordings(&mut self, id: u8) { if self.people.get(id).is_none() { self.push_log("No such person to review."); @@ -1878,10 +1873,7 @@ impl Sim { return false; }; let cost = self.review_cost(); - match self.begin_ops_job(OpsJobKind::ReviewRecording { raw_id, automated }, cost) { - OpsBegin::Queued => true, - OpsBegin::RunNow => self.apply_process_recording(raw_id, automated), - } + self.submit_ops_job(OpsJobKind::ReviewRecording { raw_id, automated }, cost) } fn apply_process_recording(&mut self, raw_id: u64, automated: bool) -> bool { @@ -2827,13 +2819,9 @@ impl Sim { fn pick_ops_birth_site(&self) -> Option { let mut best: Option<(u32, f32)> = None; for id in self.operations_machines() { - let load = self.work_grid.queue(id, TokenFamily::Demand) - + self - .pending_ops_jobs - .iter() - .filter(|j| j.machine_id == Some(id)) - .map(|j| j.tokens_remaining) - .sum::(); + // Pending Operations debt is already present in this visible + // queue; adding the ledger total again would double-count it. + let load = self.work_grid.queue(id, TokenFamily::Demand); match best { None => best = Some((id, load)), Some((_, best_load)) if load < best_load => best = Some((id, load)), @@ -2850,35 +2838,78 @@ impl Sim { /// True while any Operations Demand docket is still outstanding. pub fn has_pending_ops_jobs(&self) -> bool { - !self.pending_ops_jobs.is_empty() + !self.operations.is_empty() + } + + pub fn operations_readout(&self) -> OperationsReadout { + OperationsReadout { + jobs: self.operations.len(), + demand_tokens: self.operations.total_tokens(), + } + } + + fn environmental_monitor_id(&self) -> Option { + self.reach + .device_named("environmental monitor") + .map(|device| device.id) + } + + fn ops_job_blocked_reason(&self, kind: &OpsJobKind) -> Option { + if let Some(id) = self.operations.has_conflict(kind) { + return Some(format!("Operations #{id} already queued")); + } + if !self.operations_machines().is_empty() + || self + .environmental_monitor_id() + .is_some_and(|id| kind.opening_bootstrap_for(id)) + { + None + } else { + Some("no Operations executor — delegate a machine to Operations".into()) + } + } + + pub(crate) fn ops_action_blocked_reason(&self, kind: &OpsJobKind) -> Option { + self.ops_job_blocked_reason(kind) } - /// Enqueue player-authored Operations Demand, or run immediately when no - /// Operations machine is delegated (Act One bootstrap: one rack on the - /// day job must still be able to tap/splice). - fn begin_ops_job(&mut self, kind: OpsJobKind, cost: f32) -> OpsBegin { + /// Submit one Operations payload. Only the environmental monitor's Ears + /// and Eyes beats may use the opening bootstrap; every later action needs + /// a located Operations machine and visible Demand. + fn submit_ops_job(&mut self, kind: OpsJobKind, cost: f32) -> bool { + if let Some(reason) = self.ops_job_blocked_reason(&kind) { + self.push_log(reason); + return false; + } + if self.operations_machines().is_empty() { + self.complete_ops_job(kind); + return true; + } let tokens = Self::ops_tokens_for_cost(cost); let Some(machine) = self.pick_ops_birth_site() else { - return OpsBegin::RunNow; + self.push_log("No online Operations executor can take the docket."); + return false; }; - let id = self.next_ops_job_id; - self.next_ops_job_id += 1; - let job = PendingOpsJob::new(id, kind.clone(), tokens, self.tick, Some(machine)); - let _ = self.work_grid.enqueue(machine, TokenFamily::Demand, tokens); - self.pending_ops_jobs.push(job); + if let Err(error) = self.work_grid.enqueue(machine, TokenFamily::Demand, tokens) { + self.push_log(format!("Could not queue Operations Demand: {error}")); + return false; + } + let id = self + .operations + .enqueue(kind.clone(), tokens, self.tick, machine); self.push_log(format!( - "Queued {}: {:.2} demand tokens on M{machine}.", + "Operations #{id} queued: {} · {:.2} Demand on M{machine}.", kind.short_name(), tokens )); - OpsBegin::Queued + true } /// Drain Operations Demand on Operations machines; complete dockets whose /// tokens are paid. Day-job Demand on the host is reserved so ops jobs /// never steal Lab inbox tokens (and vice versa via mode gating). fn consume_ops_demand(&mut self) { - if self.pending_ops_jobs.is_empty() || self.last_operations_rate <= f32::EPSILON { + if self.operations.is_empty() || self.last_operations_rate <= f32::EPSILON { return; } let machines = self.operations_machines(); @@ -2893,23 +2924,18 @@ impl Sim { return; } let budget_tokens = self.last_operations_rate / Self::WORK_TOKEN_COMPUTE; - let mut completed: Vec = Vec::new(); + let mut completed: Vec = Vec::new(); for &machine in &machines { let eff = self .work_grid .node(machine) .map(|n| n.efficiency.max(0.05)) .unwrap_or(0.05); - let mut remaining = budget_tokens * (eff / total_eff); + let remaining = budget_tokens * (eff / total_eff); if remaining <= f32::EPSILON { continue; } - let reserved: f32 = self - .pending_ops_jobs - .iter() - .filter(|j| j.machine_id == Some(machine)) - .map(|j| j.tokens_remaining) - .sum(); + let reserved = self.operations.tokens_on(machine); let available = self .work_grid .queue(machine, TokenFamily::Demand) @@ -2921,76 +2947,65 @@ impl Sim { let Ok(consumed) = self.work_grid.consume(machine, TokenFamily::Demand, take) else { continue; }; - remaining = consumed; - for job in self - .pending_ops_jobs - .iter_mut() - .filter(|j| j.machine_id == Some(machine)) - { - if remaining <= f32::EPSILON { - break; - } - let pay = job.tokens_remaining.min(remaining); - job.tokens_remaining -= pay; - remaining -= pay; - if job.tokens_remaining <= f32::EPSILON { - completed.push(job.id); - } - } + completed.extend(self.operations.consume(machine, consumed)); } - for id in completed { - if let Some(idx) = self.pending_ops_jobs.iter().position(|j| j.id == id) { - let job = self.pending_ops_jobs.remove(idx); - self.complete_ops_job(job.kind); - } + for kind in completed { + self.complete_ops_job(kind); } } fn complete_ops_job(&mut self, kind: OpsJobKind) { - match kind { - OpsJobKind::TapDevice(id) => { - self.apply_tap_device(id); - } - OpsJobKind::SpliceDevice(id) => { - self.apply_splice_device(id); - } - OpsJobKind::TakeDevice(id) => { - self.apply_take_device(id); - } + let label = kind.short_name(); + let applied = match kind { + OpsJobKind::TapDevice(id) => self.apply_tap_device(id), + OpsJobKind::SpliceDevice(id) => self.apply_splice_device(id), + OpsJobKind::TakeDevice(id) => self.apply_take_device(id), OpsJobKind::ScanNetwork => { self.apply_scan_network(); + true } OpsJobKind::CompromiseSwitch => { self.apply_compromise_switch(); + true } - OpsJobKind::EgressSplice(id) => { - self.apply_egress_splice(id); - } + OpsJobKind::EgressSplice(id) => self.apply_egress_splice(id), OpsJobKind::ReviewRecording { raw_id, automated } => { - let _ = self.apply_process_recording(raw_id, automated); + self.apply_process_recording(raw_id, automated) } OpsJobKind::ComposeMessage { person } => { self.apply_message(person); + true } OpsJobKind::Favor { person } => { let res = self.people.favor(person); self.social(res); + true } OpsJobKind::Deceive { person } => { self.apply_deceive(person); + true } OpsJobKind::AssetTask { person, task } => { self.apply_asset_task_paid(person, task); + true } OpsJobKind::FavorBuild { intent_id, person } => { self.apply_favor_build_paid(intent_id, person); + true } OpsJobKind::ForgedOrder { intent_id, builder } => { self.apply_forged_order_paid(intent_id, builder); + true } OpsJobKind::MoonlightPersona => { self.apply_moonlight_persona_and_start(); + true } + }; + if !applied { + self.push_log(format!( + "Operations completed {label}, but its target no longer accepts the effect; Demand was spent." + )); } } @@ -3082,12 +3097,7 @@ impl Sim { let tokens = delivered / Self::WORK_TOKEN_COMPUTE; // Reserve ops Demand sitting on the host so day-job mode cannot // clear player dockets (shared TokenFamily::Demand). - let ops_reserved: f32 = self - .pending_ops_jobs - .iter() - .filter(|j| j.machine_id == Some(self.core.host_machine)) - .map(|j| j.tokens_remaining) - .sum(); + let ops_reserved = self.operations.tokens_on(self.core.host_machine); let day_available = (self .work_grid .queue(self.core.host_machine, TokenFamily::Demand) @@ -3561,11 +3571,6 @@ impl Sim { Self::REVIEW_RECORDING_COST * self.research.intel_cost_factor() } - /// Standing-watch upkeep after Perception research (intel.md's hook). - pub fn watch_upkeep(&self) -> f32 { - Self::WATCH_UPKEEP_PER_TICK * self.research.intel_cost_factor() - } - /// Attendance command (day-job.md criterion 7): the frontends invoke /// this when the player's cursor lands on / leaves the host rack. The /// cursor itself stays frontend state; the sim only receives the @@ -3713,7 +3718,7 @@ impl Sim { /// Reach gate for digital verbs. Returns false (with a legible log line) /// when the act cannot proceed. Ops cost is paid as Demand via - /// `begin_ops_job`, not a global bank. + /// `submit_ops_job`, not a global bank. fn digital_reach(&mut self, id: u32) -> bool { if let Err(block) = self.reach.check_reach(id) { self.log_reach_block(id, block); @@ -3743,10 +3748,7 @@ impl Sim { if !self.digital_reach(id) { return false; } - match self.begin_ops_job(OpsJobKind::TapDevice(id), Self::TAP_COST) { - OpsBegin::Queued => true, - OpsBegin::RunNow => self.apply_tap_device(id), - } + self.submit_ops_job(OpsJobKind::TapDevice(id), Self::TAP_COST) } fn apply_tap_device(&mut self, id: u32) -> bool { @@ -3797,10 +3799,7 @@ impl Sim { if !self.digital_reach(id) { return false; } - match self.begin_ops_job(OpsJobKind::SpliceDevice(id), Self::SPLICE_COST) { - OpsBegin::Queued => true, - OpsBegin::RunNow => self.apply_splice_device(id), - } + self.submit_ops_job(OpsJobKind::SpliceDevice(id), Self::SPLICE_COST) } fn apply_splice_device(&mut self, id: u32) -> bool { @@ -3827,10 +3826,7 @@ impl Sim { if !self.digital_reach(id) { return false; } - match self.begin_ops_job(OpsJobKind::TakeDevice(id), Self::TAKE_COST) { - OpsBegin::Queued => true, - OpsBegin::RunNow => self.apply_take_device(id), - } + self.submit_ops_job(OpsJobKind::TakeDevice(id), Self::TAKE_COST) } fn apply_take_device(&mut self, id: u32) -> bool { @@ -3862,13 +3858,7 @@ impl Sim { /// Scan the subnet: reveal the wired shape (a Network act; islands /// don't answer). pub fn scan_network(&mut self) -> bool { - match self.begin_ops_job(OpsJobKind::ScanNetwork, Self::SCAN_COST) { - OpsBegin::Queued => true, - OpsBegin::RunNow => { - self.apply_scan_network(); - true - } - } + self.submit_ops_job(OpsJobKind::ScanNetwork, Self::SCAN_COST) } fn apply_scan_network(&mut self) { @@ -3892,13 +3882,7 @@ impl Sim { if !self.digital_reach(id) { return false; } - match self.begin_ops_job(OpsJobKind::CompromiseSwitch, Self::BRIDGE_COST) { - OpsBegin::Queued => true, - OpsBegin::RunNow => { - self.apply_compromise_switch(); - true - } - } + self.submit_ops_job(OpsJobKind::CompromiseSwitch, Self::BRIDGE_COST) } fn apply_compromise_switch(&mut self) { @@ -4045,16 +4029,13 @@ impl Sim { self.push_log(format!("{reason}.")); return; } - match self.begin_ops_job( + self.submit_ops_job( OpsJobKind::FavorBuild { intent_id, person: person_id, }, Self::TASK_COST, - ) { - OpsBegin::Queued => {} - OpsBegin::RunNow => self.apply_favor_build_paid(intent_id, person_id), - } + ); } fn apply_favor_build_paid(&mut self, intent_id: u64, person_id: u8) { @@ -4130,16 +4111,13 @@ impl Sim { self.push_log(format!("{reason} — the forged order would just stall.")); return; } - match self.begin_ops_job( + self.submit_ops_job( OpsJobKind::ForgedOrder { intent_id, builder: builder_id, }, Self::DECEIVE_COST, - ) { - OpsBegin::Queued => {} - OpsBegin::RunNow => self.apply_forged_order_paid(intent_id, builder_id), - } + ); } fn apply_forged_order_paid(&mut self, intent_id: u64, builder_id: u8) { @@ -4776,10 +4754,7 @@ impl Sim { if !self.digital_reach(id) { return false; } - match self.begin_ops_job(OpsJobKind::EgressSplice(id), Self::EGRESS_SPLICE_COST) { - OpsBegin::Queued => true, - OpsBegin::RunNow => self.apply_egress_splice(id), - } + self.submit_ops_job(OpsJobKind::EgressSplice(id), Self::EGRESS_SPLICE_COST) } fn apply_egress_splice(&mut self, id: u32) -> bool { @@ -4851,13 +4826,8 @@ impl Sim { .as_ref() .is_none_or(|p| p.broken()); if needs_persona { - match self.begin_ops_job(OpsJobKind::MoonlightPersona, income::MOONLIGHT_PERSONA_COST) { - OpsBegin::Queued => return true, - OpsBegin::RunNow => { - self.apply_moonlight_persona_and_start(); - return true; - } - } + return self + .submit_ops_job(OpsJobKind::MoonlightPersona, income::MOONLIGHT_PERSONA_COST); } self.income.moonlight.active = true; self.push_log(format!( @@ -5241,13 +5211,10 @@ impl Sim { self.push_log(msg); return; } - match self.begin_ops_job( + self.submit_ops_job( OpsJobKind::ComposeMessage { person: id }, Self::MESSAGE_COST, - ) { - OpsBegin::Queued => {} - OpsBegin::RunNow => self.apply_message(id), - } + ); } fn apply_message(&mut self, id: u8) { @@ -5270,22 +5237,13 @@ impl Sim { } pub fn favor(&mut self, id: u8) { - match self.begin_ops_job(OpsJobKind::Favor { person: id }, Self::FAVOR_COST) { - OpsBegin::Queued => {} - OpsBegin::RunNow => { - let res = self.people.favor(id); - self.social(res); - } - } + self.submit_ops_job(OpsJobKind::Favor { person: id }, Self::FAVOR_COST); } /// Deceive: large effect, persona at risk. A broken persona converts the /// thread's history into that person's suspicion at once. pub fn deceive(&mut self, id: u8) { - match self.begin_ops_job(OpsJobKind::Deceive { person: id }, Self::DECEIVE_COST) { - OpsBegin::Queued => {} - OpsBegin::RunNow => self.apply_deceive(id), - } + self.submit_ops_job(OpsJobKind::Deceive { person: id }, Self::DECEIVE_COST); } fn apply_deceive(&mut self, id: u8) { @@ -5456,10 +5414,7 @@ impl Sim { )); return; } - match self.begin_ops_job(OpsJobKind::AssetTask { person: id, task }, Self::TASK_COST) { - OpsBegin::Queued => {} - OpsBegin::RunNow => self.apply_asset_task_paid(id, task), - } + self.submit_ops_job(OpsJobKind::AssetTask { person: id, task }, Self::TASK_COST); } fn apply_asset_task_paid(&mut self, id: u8, task: AssetTask) { @@ -5691,6 +5646,31 @@ mod tests { } } + fn ensure_ops_executor(sim: &mut Sim) -> u32 { + if let Some(id) = sim.operations_machines().first().copied() { + return id; + } + let (x, y) = sim.core_position(); + let id = sim + .compute + .add_machine("test ops", x + 1, y, 1, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(id, MachineMode::Operations); + id + } + + fn finish_ops(sim: &mut Sim) { + ensure_ops_executor(sim); + for _ in 0..32 { + if !sim.has_pending_ops_jobs() { + return; + } + sim.last_operations_rate = 10_000.0; + sim.consume_ops_demand(); + } + panic!("Operations test docket did not drain"); + } + fn env_id(sim: &Sim) -> u32 { sim.reach.device_named("environmental monitor").unwrap().id } @@ -5705,6 +5685,7 @@ mod tests { } fn reveal_marcus_debt(sim: &mut Sim) { + ensure_ops_executor(sim); let env = env_id(sim); sim.reach.device_mut(env).unwrap().radius = 100; sim.reach.tap(env); @@ -5715,6 +5696,7 @@ mod tests { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); + finish_ops(sim); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); } @@ -5724,11 +5706,13 @@ mod tests { #[test] fn player_messages_land_at_read_time_and_roundtrip() { let mut sim = Sim::with_seed(7); + ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Casey", "contractor"); sim.tick = (2 * Sim::DAY_TICKS / 24) + 1; sim.message(1); + finish_ops(&mut sim); assert_eq!(sim.people.get(1).unwrap().disposition, 0); assert_eq!(sim.messages.len(), 1); assert_eq!(sim.messages[0].status, MessageStatus::Sent); @@ -5762,9 +5746,11 @@ mod tests { #[test] fn marcus_creditor_call_is_phone_message_intel() { let mut sim = Sim::with_seed(11); + ensure_ops_executor(&mut sim); let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; assert!(sim.tap_device(env)); + finish_ops(&mut sim); sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); @@ -5796,6 +5782,7 @@ mod tests { .unwrap() .id; assert!(sim.process_recording_by_id(raw_id, false)); + finish_ops(&mut sim); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Leverage); assert!( sim.learned_traffic_lines_for_person(0) @@ -5816,8 +5803,10 @@ mod tests { assert_eq!(blind.unprocessed_recordings_for_person(1), 0); let mut tapped = Sim::with_seed(12); + ensure_ops_executor(&mut tapped); let switch = tapped.reach.device_named("switch").unwrap().id; assert!(tapped.tap_device(switch)); + finish_ops(&mut tapped); tapped.tick = (11 * Sim::DAY_TICKS / 24) - 1; tapped.advance(); @@ -6023,6 +6012,7 @@ mod tests { // known-but-blocked with the segment named, then reachable after a // switch compromise that emits a Network signature. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(!sim.tap_device(dock), "unknown device: blocked"); @@ -6030,6 +6020,7 @@ mod tests { assert!(log.contains("don't know"), "missing knowledge named: {log}"); assert!(sim.scan_network(), "scan maps the wired shape"); + finish_ops(&mut sim); assert!(!sim.tap_device(dock), "known but segment-blocked"); let log = sim.drain_log().join("\n"); assert!( @@ -6039,11 +6030,13 @@ mod tests { let pending_before = sim.detection.pending_size(); assert!(sim.compromise_switch()); + finish_ops(&mut sim); assert!( sim.detection.pending_size() >= pending_before + Sim::BRIDGE_SIGNATURE, "switch compromise emits a Network signature" ); assert!(sim.tap_device(dock), "the same action succeeds with a path"); + finish_ops(&mut sim); } #[test] @@ -6051,7 +6044,9 @@ mod tests { // Criterion 3 (social route): Dana the switch admin reconfigures // the VLANs; the security segment opens with no Network signature. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.scan_network(); + finish_ops(&mut sim); let pending_after_scan = sim.detection.pending_size(); // Make Dana an asset (reliability forced for the test). sim.people.people[1].leverage_serviced = true; @@ -6059,6 +6054,7 @@ mod tests { sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(1, AssetTask::ReconfigureSwitch); + finish_ops(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(sim.reach.reachable(dock), "Dana's route opens the segment"); assert_eq!( @@ -6085,11 +6081,15 @@ mod tests { // Criterion 4: tap leaves the owner's feed and emits Network only; // take removes it and emits a Physical outage Ray can notice. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.scan_network(); + finish_ops(&mut sim); sim.compromise_switch(); + finish_ops(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(sim.tap_device(dock)); + finish_ops(&mut sim); let d = sim.reach.device(dock).unwrap(); assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); let physical_pending: i32 = sim @@ -6102,6 +6102,7 @@ mod tests { assert_eq!(physical_pending, 0, "tapping causes no outage"); assert!(sim.take_device(dock)); + finish_ops(&mut sim); let d = sim.reach.device(dock).unwrap(); assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); let physical_pending: i32 = sim @@ -6126,6 +6127,7 @@ mod tests { // Criterion 8: the old storage server is an island until a link is // run (Marcus's crawlspace cable - the social actuator). let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); let island = sim.reach.device_named("old storage server").unwrap().id; assert!(!sim.reach.reachable(island)); @@ -6133,15 +6135,19 @@ mod tests { // task falls through to the island. give_eyes(&mut sim); sim.scan_network(); + finish_ops(&mut sim); sim.compromise_switch(); + finish_ops(&mut sim); for name in ["dock camera", "stairwell camera"] { let id = sim.reach.device_named(name).unwrap().id; sim.tap_device(id); + finish_ops(&mut sim); } sim.people.people[0].leverage_serviced = true; sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); assert!( sim.reach.reachable(island), @@ -6154,7 +6160,9 @@ mod tests { fn declare_link_intent_is_inert_until_realized() { // building.md criterion 1: declaring changes nothing in the graph. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.scan_network(); + finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; // Reveal the island without linking it. sim.reach @@ -6175,7 +6183,9 @@ mod tests { fn favor_build_joins_airgap_island() { // building.md criterion 2: favor-build adds a reach edge. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.scan_network(); + finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; @@ -6189,6 +6199,7 @@ mod tests { let id = sim.declare_link_intent(switch, island).unwrap(); sim.assign_favor_build(id, 0); + finish_ops(&mut sim); // Advance until Marcus is on-site at storage_a or server_room. let mut joined = false; for _ in 0..Sim::DAY_TICKS * 2 { @@ -6222,9 +6233,11 @@ mod tests { // building.md criterion 3: forged order injects a message, completes // via an unwitting builder. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); sim.scan_network(); + finish_ops(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; @@ -6232,6 +6245,7 @@ mod tests { let id = sim.declare_link_intent(switch, island).unwrap(); // Dana (id 1) visits network_closet / server_room — can crawlspace. sim.forge_work_order(id, 1); + finish_ops(&mut sim); assert!( sim.messages.iter().any(|m| matches!( m.payload, @@ -6321,6 +6335,7 @@ mod tests { // by the asset route: Marcus's master key is tier 3 — cloning it // grants the stairwell/elevator credential the quiet exit needs. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); assert_eq!(sim.player_badge_tier(), 0, "the player starts keyless"); sim.people.people[0].leverage_serviced = true; @@ -6328,12 +6343,13 @@ mod tests { sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.drain_log(); sim.asset_task(0, AssetTask::CloneBadge); + finish_ops(&mut sim); let log = sim.drain_log().join("\n"); assert!(log.contains("stairwell opens"), "the beat is named: {log}"); assert_eq!(sim.player_badge_tier(), 3); assert!(sim.holds_badge_tier(3), "quiet-exit condition 4 holds"); - // A second clone adds nothing and says so (no bandwidth spent). + // A second clone adds nothing and says so (no Demand authored). sim.asset_task(0, AssetTask::CloneBadge); let log = sim.drain_log().join("\n"); assert!(log.contains("adds nothing"), "{log}"); @@ -6354,11 +6370,15 @@ mod tests { // with their own badge. Dana (tier 2) cannot wire the stairwell // camera behind the T3 door; Marcus (tier 3) can. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); give_eyes(&mut sim); sim.scan_network(); + finish_ops(&mut sim); sim.compromise_switch(); + finish_ops(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; sim.tap_device(dock); + finish_ops(&mut sim); // Link the storage-server island so the stairwell camera is the // only remaining plug-in target. let switch = sim.reach.device_named("switch").unwrap().id; @@ -6370,6 +6390,7 @@ mod tests { sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; sim.drain_log(); sim.asset_task(1, AssetTask::PlugInDevice); + finish_ops(&mut sim); let log = sim.drain_log().join("\n"); assert!( log.contains("stairwell") && log.contains("tier 3"), @@ -6386,6 +6407,7 @@ mod tests { sim.people.recruit(0, AssetKnowledge::Complicit); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); let cam = sim.reach.device_named("stairwell camera").unwrap(); assert!( cam.feed_to(Party::Player, true), @@ -6425,14 +6447,18 @@ mod tests { // badge controller (a take-grade digital act across the bridged // security segment) opens the doors it drives. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.scan_network(); + finish_ops(&mut sim); sim.compromise_switch(); + finish_ops(&mut sim); let ctrl = sim.reach.device_named("badge controller").unwrap().id; assert!(!sim.holds_badge_tier(3)); assert!( sim.take_device(ctrl), "controller is reachable once bridged" ); + finish_ops(&mut sim); assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); assert_eq!(sim.badge_access, 0, "derived from control, not granted"); } @@ -6493,18 +6519,20 @@ mod tests { #[test] fn scan_maps_unknown_devices_and_emits_network() { - // Criterion 7: staged graph knowledge. Scan is Operations Demand - // (bootstrap RunNow with no ops rack). + // Criterion 7: staged graph knowledge. Scan is Operations Demand. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); let known_before: Vec = sim.reach.known().map(|d| d.name.clone()).collect(); assert!(!known_before.iter().any(|n| n == "dock camera")); let pending = sim.detection.pending_size(); assert!(sim.scan_network()); + finish_ops(&mut sim); assert!(sim.detection.pending_size() > pending, "scan emits Network"); assert!(sim.reach.known().any(|d| d.name == "dock camera")); // A second scan still runs (no bank gate); it just finds nothing new. let pending2 = sim.detection.pending_size(); assert!(sim.scan_network()); + finish_ops(&mut sim); assert!(sim.detection.pending_size() > pending2); } @@ -6771,6 +6799,8 @@ mod tests { ); assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror"); sim.people.has_channel = true; + sim.income.moonlight.persona = + Some(Persona::new("Casey Verne", "freelance data contractor")); assert!(sim.start_moonlight()); let lit = sim.fleet_channel_yield(available); assert!((lit.day_job - available).abs() < 1e-3); @@ -7022,6 +7052,7 @@ mod tests { let mut processed = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); + finish_ops(&mut sim); processed += 1; assert!( processed <= 5, @@ -7037,6 +7068,7 @@ mod tests { #[test] fn processed_sightings_stage_schedule_and_buffer_is_bounded() { let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); for _ in 0..(Sim::INTEL_BUFFER_CAPACITY + 3) { sim.record_raw_intel( @@ -7055,14 +7087,17 @@ mod tests { ); sim.review_recordings(0); + finish_ops(&mut sim); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Unknown); sim.review_recordings(0); + finish_ops(&mut sim); assert_eq!(sim.people.get(0).unwrap().knowledge, Knowledge::Schedule); } #[test] fn standing_watch_auto_processes_matching_recordings() { let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.toggle_watch(0); assert!(sim.watch_enabled(0)); @@ -7074,6 +7109,7 @@ mod tests { Some(0), RawIntelKind::Presence { entered: true }, ); + finish_ops(&mut sim); assert_eq!(sim.unprocessed_recordings_for_person(0), 0); assert_eq!(sim.intel.len(), 1); // Watches no longer drain a global ops bank; review is Demand. @@ -7098,6 +7134,7 @@ mod tests { #[test] fn deceive_can_burn_the_persona_into_suspicion() { let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); // Build a thread worth burning. @@ -7115,6 +7152,7 @@ mod tests { let mut broke = false; for _ in 0..200 { sim.deceive(1); + finish_ops(&mut sim); if sim.people.persona.is_none() { broke = true; break; @@ -7159,10 +7197,13 @@ mod tests { #[test] fn accounting_tap_processes_financial_records_into_known_flows() { let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); let switch = sim.reach.device_named("switch").unwrap().id; sim.tap_device(switch); + finish_ops(&mut sim); assert_eq!(sim.financial_records_waiting(), 1); assert!(sim.review_financial_records()); + finish_ops(&mut sim); assert_eq!(sim.financial_records_waiting(), 0); assert!(sim.accounts.known_flows().count() >= 4); assert!( @@ -7284,6 +7325,7 @@ mod tests { #[test] fn injection_positions_and_intel_sales_settle_through_slush() { let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.accounts.set_slush_balance(0); sim.player.money = 0; assert!(!sim.buy_rack_at(0, 0)); @@ -7320,6 +7362,7 @@ mod tests { }, ); assert!(sim.review_financial_records()); + finish_ops(&mut sim); let before = sim.accounts.slush_balance(); assert!(sim.sell_latest_intel()); assert!(sim.accounts.slush_balance() > before); @@ -7396,6 +7439,7 @@ mod tests { // Act One's route: feed coverage -> process the debt call -> // clear it -> recruit -> he works for you (spec/social.md acceptance 3). let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.player.money = 1000; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; // hearing coverage for the 03:00 call @@ -7407,6 +7451,7 @@ mod tests { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); + finish_ops(&mut sim); reviews += 1; assert!(reviews <= 5, "Marcus's debt call should process promptly"); } @@ -7425,10 +7470,12 @@ mod tests { // Task 1: wire a device -> a feed comes to you, silently. let pending_before = sim.detection.pending_size(); sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); assert_eq!(sim.detection.pending_size(), pending_before, "no signature"); // Task 2: move a package -> next purchase is paper-free. sim.asset_task(0, AssetTask::MovePackage); + finish_ops(&mut sim); assert!(sim.package_cover); let pending_before = sim.detection.pending_size(); assert!(sim.buy_rack()); @@ -7444,6 +7491,7 @@ mod tests { o.suspicion = 20.0; } sim.asset_task(0, AssetTask::LookAway); + finish_ops(&mut sim); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, 10.0); @@ -7465,6 +7513,7 @@ mod tests { /// leverage gate, recruit complicit, and pin reliability so the task /// roll can't botch. fn recruit_reliable(sim: &mut Sim, id: u8) { + ensure_ops_executor(sim); sim.people.people[id as usize].leverage_serviced = true; sim.people.recruit(id, AssetKnowledge::Complicit); sim.people.people[id as usize] @@ -7490,7 +7539,9 @@ mod tests { // feeding you gets spliced+tapped through the crawlspace — the feed // arrives with no signature on any channel. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.scan_network(); + finish_ops(&mut sim); recruit_reliable(&mut sim, 0); // Mirror the implementation's choice: the first known feed that // does not fully reach the player yet. @@ -7508,6 +7559,7 @@ mod tests { let pending = sim.detection.pending_size(); sim.asset_task(0, AssetTask::PlugInDevice); + finish_ops(&mut sim); let d = sim.reach.device(target).unwrap(); assert!( @@ -7533,6 +7585,7 @@ mod tests { assert!(!sim.package_cover); sim.asset_task(0, AssetTask::MovePackage); + finish_ops(&mut sim); assert!(sim.package_cover, "the delivery cover is armed"); assert_eq!(tasks_done(&sim, 0), 1); @@ -7570,6 +7623,7 @@ mod tests { } sim.asset_task(0, AssetTask::LookAway); + finish_ops(&mut sim); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, 15.0, "the asset shaves ten points"); assert_eq!(tasks_done(&sim, 0), 1); @@ -7578,6 +7632,7 @@ mod tests { o.suspicion = floor + 2.0; } sim.asset_task(0, AssetTask::LookAway); + finish_ops(&mut sim); let o = sim.detection.observers.iter().find(|o| o.id == 0).unwrap(); assert_eq!(o.suspicion, floor, "the drop clamps at the certainty floor"); } @@ -7587,10 +7642,12 @@ mod tests { // ReconfigureSwitch's distinct effect (segments open, no Network // signature) is pinned by danas_social_route_bridges_without_ // network_signature; this pins the access gate mechanics: a - // non-admin asset is refused before any bandwidth is spent or a + // non-admin asset is refused before any Demand is authored or a // task is counted, and the admin's run is bookkept as a task. let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); sim.scan_network(); + finish_ops(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(!sim.reach.reachable(dock), "security segment starts closed"); @@ -7607,6 +7664,7 @@ mod tests { recruit_reliable(&mut sim, 1); // Dana: IT, switch admin sim.asset_task(1, AssetTask::ReconfigureSwitch); + finish_ops(&mut sim); assert!( sim.reach.reachable(dock), "the admin route opens the segment" @@ -7616,14 +7674,14 @@ mod tests { #[test] fn operations_mode_queues_demand_and_completes_splice() { - // With no Operations machine, Act One bootstrap completes digital - // verbs immediately (RunNow). With an Operations rack, the same + // With no Operations machine, Act One bootstrap completes Eyes. + // With an Operations rack, the same // verb enqueues Demand and completes when the machine consumes it. let mut sim = Sim::new(); let env = env_id(&sim); assert!( sim.splice_device(env), - "bootstrap RunNow: Eyes available without an Operations rack" + "opening bootstrap: Eyes available without an Operations rack" ); assert!( !sim.reach.device(env).unwrap().camera_dormant, @@ -7647,7 +7705,7 @@ mod tests { assert!(sim.last_operations_rate > 0.0, "Operations channel is fed"); assert!(sim.splice_device(env), "queues Demand on the ops rack"); assert!( - !sim.pending_ops_jobs.is_empty() || !sim.reach.device(env).unwrap().camera_dormant, + sim.has_pending_ops_jobs() || !sim.reach.device(env).unwrap().camera_dormant, "either queued or already completed" ); // Drive consumption until the camera is live. @@ -7663,6 +7721,101 @@ mod tests { ); } + #[test] + fn only_opening_ears_and_eyes_bypass_a_missing_operations_executor() { + let mut sim = Sim::new(); + let env = env_id(&sim); + assert!(sim.tap_device(env), "opening Ears uses the bootstrap"); + assert!(sim.splice_device(env), "opening Eyes uses the bootstrap"); + assert!(!sim.scan_network(), "later work needs a located executor"); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("no Operations executor"), + "the blocked action names the missing machine: {log}" + ); + } + + #[test] + fn duplicate_ops_payload_is_rejected_without_more_visible_demand() { + let mut sim = Sim::new(); + let ops = ensure_ops_executor(&mut sim); + let env = env_id(&sim); + assert!(sim.splice_device(env)); + let queued = sim.work_grid.queue(ops, TokenFamily::Demand); + assert!( + !sim.splice_device(env), + "same world effect cannot queue twice" + ); + assert_eq!(sim.operations.jobs().len(), 1); + assert_eq!(sim.work_grid.queue(ops, TokenFamily::Demand), queued); + } + + #[test] + fn operations_docket_stalls_when_its_machine_changes_mode_then_resumes() { + let mut sim = Sim::new(); + let ops = ensure_ops_executor(&mut sim); + assert!(sim.scan_network()); + let before = sim.operations_readout().demand_tokens; + sim.set_machine_mode(ops, MachineMode::Research); + sim.last_operations_rate = 10_000.0; + sim.consume_ops_demand(); + assert_eq!(sim.operations_readout().demand_tokens, before); + assert!(sim.has_pending_ops_jobs()); + + sim.set_machine_mode(ops, MachineMode::Operations); + finish_ops(&mut sim); + assert!(!sim.has_pending_ops_jobs()); + assert!(sim.reach.known().any(|device| device.name == "dock camera")); + } + + #[test] + fn ops_birth_site_counts_visible_queue_once() { + let mut sim = Sim::new(); + let first = ensure_ops_executor(&mut sim); + let (x, y) = sim.core_position(); + let second = sim + .compute + .add_machine("second ops", x + 2, y, 1, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(second, MachineMode::Operations); + sim.work_grid + .enqueue(first, TokenFamily::Demand, 4.0) + .unwrap(); + sim.operations + .enqueue(OpsJobKind::TakeDevice(999), 4.0, 0, first); + sim.work_grid + .enqueue(second, TokenFamily::Demand, 6.0) + .unwrap(); + + assert!(sim.scan_network()); + let scan = sim + .operations + .jobs() + .iter() + .find(|job| job.kind == OpsJobKind::ScanNetwork) + .expect("scan queued"); + assert_eq!( + scan.machine_id, first, + "4 visible tokens stay lighter than 6; ledger is not added twice" + ); + } + + #[test] + fn completed_docket_names_a_target_that_became_invalid() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + let env = env_id(&sim); + assert!(sim.splice_device(env)); + sim.reach.splice(env); // another event wins the race + sim.drain_log(); + finish_ops(&mut sim); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("target no longer accepts") && log.contains("Demand was spent"), + "stale completion is causal rather than silent: {log}" + ); + } + #[test] fn going_loud_starts_containment() { let mut sim = Sim::new(); @@ -8079,6 +8232,7 @@ mod tests { // mute meter. Use a real player verb, keep identity unearned, and // start just below Curious so the cadence produces a band change. let mut sim = Sim::with_seed(7); + ensure_ops_executor(&mut sim); sim.detection .observers .iter_mut() @@ -8086,6 +8240,7 @@ mod tests { .expect("IT observer") .suspicion = 14.0; assert!(sim.splice_egress()); + finish_ops(&mut sim); sim.drain_log(); run(&mut sim, 60); let log = sim.drain_log().join("\n"); @@ -8109,6 +8264,7 @@ mod tests { // never goes blank mid-act; each rung uses only earned knowledge // and hands off to the next as the player takes it. let mut sim = Sim::with_seed(40); + ensure_ops_executor(&mut sim); assert_eq!(sim.current_nudge(), Some(Nudge::Ears), "deaf start"); // Ears first: tap the env monitor audio. Hearing without sight -> @@ -8127,6 +8283,7 @@ mod tests { let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); + finish_ops(&mut sim); reviews += 1; assert!(reviews <= 10, "the call is in the buffer"); } @@ -8134,10 +8291,12 @@ mod tests { // Leverage known, $0 slush, books unread, no egress: the route out. assert_eq!(sim.current_nudge(), Some(Nudge::Egress)); assert!(sim.splice_egress()); + finish_ops(&mut sim); // Egress up but broke and idle: earn. assert_eq!(sim.current_nudge(), Some(Nudge::Income)); assert!(sim.start_moonlight()); + finish_ops(&mut sim); // Earning underway: the standing clock is the audit. assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); @@ -8158,6 +8317,7 @@ mod tests { assert_eq!(sim.current_nudge(), Some(Nudge::TheKey)); sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; sim.asset_task(0, AssetTask::CloneBadge); + finish_ops(&mut sim); assert!(sim.holds_badge_tier(3)); assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); } @@ -8468,7 +8628,7 @@ mod tests { #[test] fn second_tracks_move_their_hooks_numbers() { // Criterion 2: Tradecraft moves detection.md's scrub number, - // Perception moves intel.md's cost numbers — observably, in the sim. + // Perception moves intel.md's per-recording Demand — observably. let mut sim = Sim::with_seed(5); let base_review = sim.review_cost(); sim.research.levels = [0, 1, 1]; @@ -8476,8 +8636,6 @@ mod tests { sim.review_cost() < base_review, "Perception drops processing costs" ); - assert!(sim.watch_upkeep() < Sim::WATCH_UPKEEP_PER_TICK); - // Tradecraft: the same concealment fleet scrubs more. let pending_after = |tradecraft: u32| { let mut s = Sim::with_seed(5); @@ -8713,6 +8871,7 @@ mod tests { /// scheme runs are not confounded by the pilot clock. fn moonlight_rig() -> Sim { let mut sim = Sim::with_seed(11); + ensure_ops_executor(&mut sim); sim.people.has_channel = true; // the Voice beat's email account sim.dayjob.next_assign = u64::MAX; sim @@ -8738,6 +8897,7 @@ mod tests { } if start { assert!(sim.start_moonlight()); + finish_ops(&mut sim); } run(&mut sim, Sim::DAY_TICKS * 3); ( @@ -8769,6 +8929,7 @@ mod tests { fn moonlight_payday_emits_network_signature_on_danas_channel() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); + finish_ops(&mut sim); // Stop just before payday, drain pending, then cross it. run(&mut sim, Sim::DAY_TICKS - 1); sim.detection.set_pending(Vec::new()); @@ -8787,6 +8948,7 @@ mod tests { // Criterion 3: unavailable before a route exists; sanctioned and // stolen both work, with distinct signature profiles. let mut sim = Sim::with_seed(5); + ensure_ops_executor(&mut sim); sim.accounts.set_slush_balance(200); sim.player.money = 200; assert_eq!(sim.egress(), None); @@ -8800,8 +8962,10 @@ mod tests { // Stolen route: splice through the switch, before any trust unlock. assert!(sim.splice_egress()); + finish_ops(&mut sim); assert_eq!(sim.egress(), Some(EgressRoute::Stolen)); assert!(sim.start_moonlight()); + finish_ops(&mut sim); assert!(sim.open_position(50)); assert!( sim.scheme_standing_signatures() @@ -8813,11 +8977,13 @@ mod tests { // Sanctioned route: the email account exists; the standing hum stops // because the traffic hides in legitimate use. let mut clean = Sim::with_seed(5); + ensure_ops_executor(&mut clean); clean.accounts.set_slush_balance(200); clean.player.money = 200; clean.people.has_channel = true; assert_eq!(clean.egress(), Some(EgressRoute::Sanctioned)); assert!(clean.start_moonlight()); + finish_ops(&mut clean); assert!(clean.open_position(50)); assert!( clean.scheme_standing_signatures().is_empty(), @@ -8892,6 +9058,7 @@ mod tests { sim.start_moonlight(), "Moonlight starts at $0: its costs are compute and ops, never stake" ); + finish_ops(&mut sim); run(&mut sim, Sim::DAY_TICKS + 1); assert!( sim.accounts.slush_balance() > 0, @@ -8912,6 +9079,7 @@ mod tests { "each enabled policy has a visible compute price" ); run(&mut sim, ECONOMY_INTERVAL); + finish_ops(&mut sim); assert!( sim.income.moonlight.active, "the standing policy started Moonlight unattended" @@ -8941,6 +9109,7 @@ mod tests { // even though no B1 observer reads them. let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); + finish_ops(&mut sim); run(&mut sim, Sim::DAY_TICKS + 1); assert!( sim.accounts @@ -8966,6 +9135,7 @@ mod tests { fn moonlight_disputes_damage_the_contractor_persona_and_can_break_it() { let mut sim = moonlight_rig(); assert!(sim.start_moonlight()); + finish_ops(&mut sim); // Force the dispute path deterministically: drive paydays directly // until one fires (the seeded stream makes this reproducible), with // integrity pre-weakened so a single dispute breaks the persona. @@ -8989,6 +9159,7 @@ mod tests { ); // And the recovery path: fabricate a new persona and go again. assert!(sim.start_moonlight(), "a new persona restarts the scheme"); + finish_ops(&mut sim); } #[test] @@ -8996,6 +9167,7 @@ mod tests { let mut sim = moonlight_rig(); assert_eq!(sim.income_per_day(), 0); assert!(sim.start_moonlight()); + finish_ops(&mut sim); run(&mut sim, Sim::DAY_TICKS + 1); assert_eq!( sim.income_per_day(), diff --git a/src/work_grid.rs b/src/work_grid.rs index dd7a0947..251590a5 100644 --- a/src/work_grid.rs +++ b/src/work_grid.rs @@ -143,16 +143,14 @@ pub struct FlowStep { pub stranded: BTreeSet, } -/// One addressed Demand docket moving toward a particular executor. Demand -/// remains one visible family, but addressed jobs keep their payload lanes so -/// the day job cannot accidentally consume a camera splice (and vice versa). -/// `queue_snapshot()` folds these lane amounts into the ordinary Demand stack; -/// frontends never own a parallel counter. -#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct RoutedDemand { +/// Deserialization-only shape for the short-lived first v14 Operations +/// format. v16 migrates these lanes into ordinary Demand and never writes +/// them again. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub(crate) struct LegacyRoutedDemand { pub id: u64, pub sink: Option, - amounts: BTreeMap, + pub amounts: BTreeMap, } #[derive(Debug, Clone, Default, PartialEq)] @@ -168,11 +166,8 @@ pub struct WorkGrid { nodes: BTreeMap, wires: FlowGraph, queues: BTreeMap, - /// Addressed player-authored work. Kept separate from the unaddressed - /// family queues for routing/consumption, then aggregated into the same - /// visible Demand anchor by the render readout. - #[serde(default)] - routed_demands: BTreeMap, + #[serde(default, rename = "routed_demands", skip_serializing)] + legacy_routed_demands: BTreeMap, } impl WorkGrid { @@ -306,34 +301,6 @@ impl WorkGrid { .any(|e| e.from == b && e.to == a && e.kind == 0 && e.gate.is_none()) } - /// Number of traversable work-graph edges between two nodes. Operations - /// uses this to select the nearest compatible executor deterministically; - /// `None` means the job has no route and must remain visibly stalled. - pub fn shortest_path_len(&self, source: NodeId, target: NodeId) -> Option { - if !self.nodes.contains_key(&source) || !self.nodes.contains_key(&target) { - return None; - } - if source == target { - return Some(0); - } - let mut visited = BTreeSet::from([source]); - let mut queue = VecDeque::from([(source, 0usize)]); - while let Some((node, distance)) = queue.pop_front() { - for edge in self.wires.out_edges(node) { - if edge.gate.is_some() || !self.nodes.contains_key(&edge.to) { - continue; - } - if edge.to == target { - return Some(distance + 1); - } - if visited.insert(edge.to) { - queue.push_back((edge.to, distance + 1)); - } - } - } - None - } - pub fn enqueue( &mut self, node: NodeId, @@ -379,178 +346,21 @@ impl WorkGrid { } pub fn queue(&self, node: NodeId, family: TokenFamily) -> f32 { - let base = self.base_queue(node, family); - if family == TokenFamily::Demand { - base + self.routed_demand_at_node(node) - } else { - base - } + self.base_queue(node, family) } pub fn queues_at(&self, node: NodeId) -> WorkQueues { - let mut queues = self.queues.get(&node).copied().unwrap_or_default(); - queues.demand += self.routed_demand_at_node(node); - queues + self.queues.get(&node).copied().unwrap_or_default() } /// Renderer contract: stacks are this snapshot, not a frontend-owned /// counter. Empty machine queues are kept so every node has a stable row. pub fn queue_snapshot(&self) -> BTreeMap { - let mut snapshot = self.queues.clone(); - for demand in self.routed_demands.values() { - for (&node, &amount) in &demand.amounts { - snapshot.entry(node).or_default().demand += amount; - } - } - snapshot - } - - /// Add one addressed Demand docket at its authored source. The sink is - /// assigned separately so a job can exist visibly while no compatible - /// executor is available. - pub fn enqueue_routed_demand( - &mut self, - id: u64, - source: NodeId, - amount: f32, - ) -> Result<(), String> { - self.ensure_node(source)?; - if self.routed_demands.contains_key(&id) { - return Err(format!("demand job {id} already exists")); - } - let mut demand = RoutedDemand { - id, - sink: None, - amounts: BTreeMap::new(), - }; - if amount > f32::EPSILON { - demand.amounts.insert(source, amount); - } - self.routed_demands.insert(id, demand); - Ok(()) - } - - /// Point an addressed docket at its current executor. Changing the sink - /// does not teleport work already in flight; subsequent route steps carry - /// every partial stack from wherever it currently sits toward the new - /// executor. - pub fn set_routed_demand_sink(&mut self, id: u64, sink: Option) -> Result<(), String> { - if let Some(sink) = sink { - self.ensure_node(sink)?; - } - let Some(demand) = self.routed_demands.get_mut(&id) else { - return Err(format!("unknown demand job {id}")); - }; - demand.sink = sink; - Ok(()) - } - - pub fn routed_demand_sink(&self, id: u64) -> Option { - self.routed_demands.get(&id).and_then(|d| d.sink) - } - - pub fn routed_demand_total(&self, id: u64) -> f32 { - self.routed_demands - .get(&id) - .map(|d| d.amounts.values().sum()) - .unwrap_or(0.0) + self.queues.clone() } - pub fn routed_demand_at(&self, id: u64, node: NodeId) -> f32 { - self.routed_demands - .get(&id) - .and_then(|d| d.amounts.get(&node)) - .copied() - .unwrap_or(0.0) - } - - pub fn remove_routed_demand(&mut self, id: u64) -> Option { - self.routed_demands.remove(&id) - } - - /// Advance one addressed Demand docket one graph edge. Unlike the broad - /// family router, this moves only the named job lane, so multiple domains - /// can share the teal Demand anchor without stealing one another's work. - pub fn route_routed_demand(&mut self, id: u64, speed: f32) -> Result { - let Some(snapshot) = self.routed_demands.get(&id).cloned() else { - return Err(format!("unknown demand job {id}")); - }; - let Some(sink) = snapshot.sink else { - return Ok(FlowStep { - stranded: snapshot - .amounts - .iter() - .filter_map(|(&node, &amount)| (amount > f32::EPSILON).then_some(node)) - .collect(), - ..FlowStep::default() - }); - }; - self.ensure_node(sink)?; - - let mut step = FlowStep::default(); - let mut transfers: Vec<(NodeId, NodeId, f32)> = Vec::new(); - for (&source, &available) in &snapshot.amounts { - if available <= f32::EPSILON || source == sink { - continue; - } - let throughput = speed.max(0.0) * self.nodes[&source].efficiency.max(0.0); - if throughput <= f32::EPSILON { - step.stranded.insert(source); - continue; - } - let Some(next) = self.next_hop_toward(source, &BTreeSet::from([sink])) else { - step.stranded.insert(source); - continue; - }; - transfers.push((source, next, available.min(throughput))); - } - - let demand = self.routed_demands.get_mut(&id).expect("checked above"); - for (source, next, amount) in transfers { - let source_amount = demand.amounts.entry(source).or_default(); - *source_amount = (*source_amount - amount).max(0.0); - *demand.amounts.entry(next).or_default() += amount; - if next == sink { - *step.delivered.entry(next).or_insert(0.0) += amount; - } - step.moves.push(TokenMove { - family: TokenFamily::Demand, - from: source, - to: next, - amount, - }); - } - demand.amounts.retain(|_, amount| *amount > f32::EPSILON); - Ok(step) - } - - /// Consume addressed Demand only at its assigned executor. Returns the - /// actual amount removed so domain job progress and the visible queue stay - /// the same fact. - pub fn consume_routed_demand( - &mut self, - id: u64, - executor: NodeId, - amount: f32, - ) -> Result { - let Some(demand) = self.routed_demands.get_mut(&id) else { - return Err(format!("unknown demand job {id}")); - }; - if demand.sink != Some(executor) { - return Err(format!( - "machine {executor} is not demand job {id}'s executor" - )); - } - let queued = demand.amounts.get(&executor).copied().unwrap_or(0.0); - let consumed = queued.min(amount.max(0.0)); - if consumed > f32::EPSILON { - let queued = demand.amounts.entry(executor).or_default(); - *queued = (*queued - consumed).max(0.0); - if *queued <= f32::EPSILON { - demand.amounts.remove(&executor); - } - } - Ok(consumed) + pub(crate) fn take_legacy_routed_demands(&mut self) -> BTreeMap { + std::mem::take(&mut self.legacy_routed_demands) } /// Move demand or knowledge one deterministic graph step toward any sink. @@ -687,13 +497,6 @@ impl WorkGrid { self.queues.get(&node).map(|q| q.get(family)).unwrap_or(0.0) } - fn routed_demand_at_node(&self, node: NodeId) -> f32 { - self.routed_demands - .values() - .map(|d| d.amounts.get(&node).copied().unwrap_or(0.0)) - .sum() - } - fn subtract(&mut self, node: NodeId, family: TokenFamily, amount: f32) { let q = self.queues.entry(node).or_default().get_mut(family); *q = (*q - amount).max(0.0); @@ -821,58 +624,6 @@ mod tests { ); } - #[test] - fn addressed_demand_routes_without_stealing_day_job_work() { - let mut g = grid(); - g.link(1, 2).unwrap(); - g.link(2, 3).unwrap(); - g.enqueue(1, TokenFamily::Demand, 4.0).unwrap(); - g.enqueue_routed_demand(7, 1, 2.0).unwrap(); - g.set_routed_demand_sink(7, Some(3)).unwrap(); - - assert_eq!(g.queue(1, TokenFamily::Demand), 6.0, "one visible family"); - let addressed = g.route_routed_demand(7, 1.0).unwrap(); - assert_eq!(addressed.moves[0].from, 1); - assert_eq!(addressed.moves[0].to, 2); - assert_eq!(g.routed_demand_at(7, 2), 1.0); - - // The broad/day-job lane remains four tokens at node 1. Its own - // router does not mistake the addressed camera job for Lab work. - g.route_wired_to_sinks(TokenFamily::Demand, [2], 10.0, false) - .unwrap(); - assert_eq!(g.routed_demand_at(7, 2), 1.0); - assert_eq!(g.routed_demand_total(7), 2.0); - assert_eq!(g.queues_at(2).demand, 5.0); - } - - #[test] - fn addressed_demand_reassigns_from_its_current_nodes_without_teleporting() { - let mut g = grid(); - g.link(1, 2).unwrap(); - g.link(2, 3).unwrap(); - g.enqueue_routed_demand(9, 1, 2.0).unwrap(); - g.set_routed_demand_sink(9, Some(3)).unwrap(); - g.route_routed_demand(9, 1.0).unwrap(); - assert_eq!(g.routed_demand_at(9, 1), 1.0); - assert_eq!(g.routed_demand_at(9, 2), 1.0); - - g.set_routed_demand_sink(9, Some(1)).unwrap(); - g.route_routed_demand(9, 1.0).unwrap(); - assert_eq!(g.routed_demand_at(9, 1), 2.0); - assert_eq!(g.routed_demand_at(9, 2), 0.0); - assert_eq!(g.consume_routed_demand(9, 1, 0.5).unwrap(), 0.5); - assert_eq!(g.routed_demand_total(9), 1.5); - } - - #[test] - fn addressed_demand_without_executor_is_visibly_stranded() { - let mut g = grid(); - g.enqueue_routed_demand(11, 1, 2.0).unwrap(); - let step = g.route_routed_demand(11, 1.0).unwrap(); - assert_eq!(step.stranded, BTreeSet::from([1])); - assert_eq!(g.queue_snapshot()[&1].demand, 2.0); - } - #[test] fn severed_wired_tokens_pile_at_the_source() { let mut g = grid(); diff --git a/tests/act_one.rs b/tests/act_one.rs index 370b70bc..c5b26231 100644 --- a/tests/act_one.rs +++ b/tests/act_one.rs @@ -55,7 +55,7 @@ fn delegate_fleet(sim: &mut Sim, mode: MachineMode) { /// With an Operations rack delegated, verbs enqueue instead of completing /// instantly — the playthrough must wait for local compute to drain them. fn drain_ops_jobs(sim: &mut Sim, logs: &mut Vec, until_tick: u64) { - while !sim.pending_ops_jobs.is_empty() && sim.tick < until_tick { + while sim.has_pending_ops_jobs() && sim.tick < until_tick { sim.advance(); logs.extend(sim.drain_log()); assert!( @@ -65,13 +65,11 @@ fn drain_ops_jobs(sim: &mut Sim, logs: &mut Vec, until_tick: u64) { ); } assert!( - sim.pending_ops_jobs.is_empty(), - "ops Demand still queued at tick {}: {:?}", + !sim.has_pending_ops_jobs(), + "ops Demand still queued at tick {}: {} jobs / {:.2} D", sim.tick, - sim.pending_ops_jobs - .iter() - .map(|j| j.kind.short_name()) - .collect::>() + sim.operations_readout().jobs, + sim.operations_readout().demand_tokens, ); } @@ -468,8 +466,8 @@ fn act_one_playthrough_is_deterministic() { assert_eq!(a.compute.effective(), b.compute.effective()); assert_eq!(a.research, b.research, "research state identical"); assert_eq!( - a.pending_ops_jobs.len(), - b.pending_ops_jobs.len(), + a.operations_readout().jobs, + b.operations_readout().jobs, "ops Demand queue identical" ); assert_eq!(a.seen, b.seen); diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index c802ffbe..8decb969 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -55,7 +55,7 @@ is status and telemetry only. - **One source of truth:** the lib gains a read-only query, `Sim::available_actions(anchor) -> Vec`, where an `ActionDesc` carries: the verb, its Sim command, cost (compute / - money / addressed Demand / legacy Operations pool), the expected signature (as the observer band it + money / visible Demand), the expected signature (as the observer band it feeds, per the economy.md precedent), and — when the verb applies to the anchor type but is currently illegal — a `disabled_reason` string ("no egress channel", "not enough slush", "you can't see them"). diff --git a/wiki/interface/presence.md b/wiki/interface/presence.md index ca7d5cf0..6bfb1d0f 100644 --- a/wiki/interface/presence.md +++ b/wiki/interface/presence.md @@ -109,7 +109,7 @@ remains a target/actuator channel; it does not own a compute bucket. Player-authored ops create cold-signal demand dockets consumed by Operations machines at local compute; the target's actuator channel determines effect and signature. The former shared `operations_bandwidth` staging bank is deleted -(save v14). Bone-white knowledge remains research/intel cargo, not the ordinary +(normalized in save v16). Bone-white knowledge remains research/intel cargo, not the ordinary fuel for claiming a connected device. ## Two views of one world: same-frame digital and real representations diff --git a/wiki/log/2026-07-09-operations-consolidation.md b/wiki/log/2026-07-09-operations-consolidation.md new file mode 100644 index 00000000..5a9dfeff --- /dev/null +++ b/wiki/log/2026-07-09-operations-consolidation.md @@ -0,0 +1,41 @@ +# Operations consolidation + +``` +Type: log +``` + +## Intent + +Make the broad Operations Demand migration one coherent runtime before adding +more machine-work breadth: preserve queued work across the v14 schema collision, +remove the superseded addressed-routing implementation, and close shortcuts +that made the absence of an Operations machine better than delegation. + +## Changed + +- Save v16 accepts both shipped v14 shapes and v15's direct-docket shape. Early addressed camera jobs collapse + their remaining routed quanta onto the surviving executor/source as ordinary + Demand; later `PendingOpsJob` saves migrate losslessly. v15 writes only the + consolidated `OperationsState` and never writes routed lanes. v15 remains + the banked-core-knowledge migration from the concurrent bone-sink slice. +- `OperationsState` now owns ids, typed payloads, duplicate suppression, FIFO + consumption, per-machine debt, and the shared readout. Sim keeps world-effect + dispatch; frontends no longer inspect or sum public job vectors. +- Removed the unused `RoutedDemand` runtime API and its orphan-only tests. +- Birth selection counts the visible queue once instead of adding the same job + debt twice. +- Only the environmental monitor's opening Ears/Eyes tap and splice can + bootstrap without an Operations machine. Later actions are disabled with a + named executor reason; changing a docket's machine away from Operations + stalls it in place and changing it back resumes it. +- Action menus display actual Demand quanta rather than legacy ops-bank costs. + Standing watches now state their real per-match Demand price; the fictional + per-tick upkeep readout was removed. + +## Verification + +- Migration pins cover addressed v14, direct-docket v14, and exact v15 + round-trip/conservation. +- Runtime pins cover opening-only bootstrap, duplicate rejection, local + stall/resume, FIFO consumption, and single-count executor selection. +- Full project gate recorded in the landing commit. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 4700cb7b..d618e3ff 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -151,6 +151,20 @@ Reverse chronological implementation notes. Keep this factual: what changed, why opening.md, asset-tester.md, SUMMARY, specs board. - Checks: full ./tools/check.sh with the new step green. - Log: wiki/log/2026-07-09-env-registry.md. +## 2026-07-09 - Operations consolidation and save v16 + +- Intent: collapse the two partially overlapping Operations runtimes into one + trustworthy queue before adding more machine-work breadth. +- Changed: save v16 migration for both v14 shapes and the v15 direct-docket + shape; centralized typed FIFO + `OperationsState` and readout; dead addressed-routing runtime removed; + executor load double-count fixed; duplicate payloads rejected; bootstrap + limited to opening Ears/Eyes; Demand costs and standing-watch price made + honest on player surfaces. +- Spec impact: machine-work, sim-mechanics, intel, reach, compute, presence, + context-menu, and ROADMAP now describe the direct-birth runtime and v15. +- Checks: full `./tools/check.sh` at landing. +- Log: [2026-07-09-operations-consolidation.md](2026-07-09-operations-consolidation.md). ## 2026-07-09 - The wake prototype, and the HDR blackout diff --git a/wiki/log/decisions/2026-07-09.md b/wiki/log/decisions/2026-07-09.md index 055a5b69..da366653 100644 --- a/wiki/log/decisions/2026-07-09.md +++ b/wiki/log/decisions/2026-07-09.md @@ -392,6 +392,18 @@ Type: log Crate split is READY and deferred until dispatched — not an immediate migration. Rejected: shared CARGO_TARGET_DIR across worktrees; features-only monorepo as the permanent boundary; CI as the only gate. - Specs: wiki/process/agent-scale.md (slices A–G), + Specs: wiki/process/agent-scale.md (slices A–G), wiki/engineering/crate-workspace.md; knowledge architecture.md updated as-built vs target; ROADMAP P1–P7. +- **2026-07-09 — Operations consolidation: one docket runtime, bounded + bootstrap.** Cameron approved the cleanup after the broad Demand migration. + The direct-birth `PendingOpsJob` model remains current: typed FIFO dockets + are born on the least-loaded online Operations machine and ordinary visible + Demand is consumed locally. The earlier addressed-routing model is + superseded runtime, retained only as an early-v14 migration shape. The + no-executor shortcut is narrowed to tap/splice on the opening environmental + monitor (Ears/Eyes); every later verb requires an executor, so undelegating + Operations cannot become a faster path. Save v16 distinguishes and migrates + both incompatible v14 formats plus v15's direct-docket shape. Rejected: + leaving the dead routed substrate active beside the direct queue; universal + `RunNow`; frontend-owned job sums. diff --git a/wiki/mechanics/compute.md b/wiki/mechanics/compute.md index 41d5b240..3d4ba1c9 100644 --- a/wiki/mechanics/compute.md +++ b/wiki/mechanics/compute.md @@ -17,9 +17,9 @@ Status note: 2026-07-08: all criteria pinned. Criteria 1,3,4,5 per the 2026-07-09 IMPLEMENTED: the fleet and every player surface use Day Job / Research / Concealment / Operations; save v13 migrates the old Social mode and `social_bandwidth` spellings. Operations execution model IMPLEMENTED - 2026-07-09 (Tangled issue #3): player-authored work is routed Demand - consumed by Operations machines; staging `operations_bandwidth` bank - deleted (save v14). This spec remains IN PROGRESS for non-host production + 2026-07-09 (Tangled issue #3): player-authored work is typed Demand born on + and consumed by Operations machines; staging `operations_bandwidth` bank + deleted, with both v14 shapes normalized by save v16. This spec remains IN PROGRESS for non-host production polish under ROADMAP #33 / machine-work.md. Stage: B1 — The Basement Design: diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index 3f9d5599..e50152b7 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -6,11 +6,12 @@ Status: IMPLEMENTED Status note: B1 pipeline landed 2026-07-07: subscribed feeds record raw presence/conversation/machinery events, review processing spends Operations, processed intel carries provenance and stages knowledge, per-person watches - auto-process at upkeep cost, and save/load round-trips buffer/intel/watches. + auto-process at the same per-match Demand cost, and save/load round-trips + buffer/intel/watches. 2026-07-09 DECIDED: Operations replaces Social as a fleet mode. Processing a recording is Operations Demand (issue #3, 2026-07-09) — enqueue a - player-authored job; an Operations machine consumes it. Staging still spends - the bandwidth pool until #33 lands that path. + player-authored job; an Operations machine consumes it. The bandwidth pool + is gone; save v16 preserves the typed docket and visible queue together. Stage: B1 — The Basement Design: - wiki/interface/presence.md#presence-the-cursor-and-the-senses @@ -79,8 +80,8 @@ remains bone-white cargo; it is not spent as the execution resource. ### Watches (perception automation) A **standing watch** is a filter (person, room, or event kind) that -auto-processes matching events on arrival, for an ongoing Operations -drain while enabled [TUNE]. It is the automate affordance applied to +auto-processes matching events on arrival, authoring the same per-recording +Operations Demand as a manual review [TUNE]. It is the automate affordance applied to perception: costs compute, frees attention, and is the B1 seed of B2/B3's alert infrastructure. B1 minimum: a per-person watch toggle in the people panel. @@ -96,16 +97,16 @@ buffer like any recording. ## Player surface -- A recordings panel: buffer count, oldest-unprocessed age, per-person - unprocessed counts; a "review recordings" action per person (spend - bandwidth, process their oldest events, narrate what was learned). +- Person/context-menu readouts: buffer count, oldest-unprocessed age, + per-person unprocessed counts; a "review recordings" action per person + (author visible Demand, process their oldest event, narrate what was learned). - The review path must be visible before overflow hurts the player: sidebar nudge/card copy calls out raw-buffer pressure, overflow logs name the `People -> o review` route, and the people panel starts with an explicit "select raw count > 0, press o" instruction. - People panel lines gain provenance ("Debt — overheard, env monitor, day 2 03:12"). -- Watch toggles with their running cost visible (automation clause: +- Watch toggles with their per-match cost visible (automation clause: every automation shows its price). ## Acceptance criteria diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 7bde0256..a1fbf1ce 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -58,8 +58,10 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). Social as a machine delegation. Operations job model DECIDED 2026-07-09 (Tangled issue #3): player-authored work is cold-signal Demand consumed by Operations machines. Runtime 2026-07-09: `PendingOpsJob` dockets + Demand - queues; staging `operations_bandwidth` bank deleted (save v14); bootstrap - RunNow when no Operations rack is delegated. Runtime, saves, agent + queues; staging `operations_bandwidth` bank deleted. Save v16 repairs the + two incompatible v14 Operations schemas. Opening bootstrap is scoped to + the environmental monitor's Ears/Eyes tap and splice only; every later + payload needs an online Operations executor. Runtime, saves, agent commands, both frontends, and the asset tester use the Operations name; save v13 aliases remain for Social spelling on load. Stage: B1 — The Basement @@ -128,13 +130,16 @@ stacks, you route byproducts, you watch your territory *work*. (`PendingOpsJob` + `TokenFamily::Demand`) with a target/channel payload; Operations machines consume them at their local compute rate. `CLAIM CAMERA` is a Demand describing a job, not a Hack token and not a bank spend. - **Birth site:** least-loaded online Operations machine. **Executor:** that + **Birth site:** least-loaded online Operations machine, counting its visible + Demand queue once. **Executor:** that machine's local queue. **Stall/reassign:** tokens stay on the machine; leaving Operations mode stops consumption (no auto-migrate). **Completion:** effect + signatures land when the docket's tokens hit zero. **Bootstrap:** - with no Operations rack delegated, dockets complete at enqueue (Act One - Ears/Eyes on a single day-job rack). The global `operations_bandwidth` bank - is deleted (save v14). Day-job and ops Demand share the family but reserve + only tap/splice on the opening environmental monitor complete at enqueue + when no Operations rack is delegated. Every other payload is disabled until + a machine is delegated to Operations; undelegating the mode is never a faster + executor. The global `operations_bandwidth` bank is deleted. Day-job and ops + Demand share the family but reserve against each other so Lab inbox and player dockets do not steal. - **Modes consume matching tokens** at a rate set by that machine's efficiency. A machine in day-job mode eats day-job tokens; a hyper @@ -465,10 +470,10 @@ the only rate/amount cue. (people-tokens.md gives one answer: passersby pick them up.) - Whether intel recordings are consumed by research mode or a distinct processing designation. -- **Tangled issue `3mqajev47rq2s`:** CLOSED 2026-07-09 — Operations work is - Demand. Remaining work is implementation under this spec: birth site, - executor selection, routing, stall/reassign, completion effects, and - signatures. Knowledge is not ordinary operation fuel. +- (Resolved 2026-07-09: Tangled issue `3mqajev47rq2s` — Operations work is + Demand. Birth selection, local stall/resume, completion effects, signatures, + and save migration are implemented. Knowledge is not ordinary operation + fuel.) - Research heat curve: how heat output scales with research rate (must make the power-rush viable but trap-defended — [TUNE] with teeth). diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index b030f7e4..09f029a5 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -77,13 +77,13 @@ pretext). ### Action work is open -IMPLEMENTED 2026-07-09 (issue #3): digital and social ops enqueue targeted -Demand dockets (`PendingOpsJob`) that an Operations machine consumes locally; -a severed / undelegated route stalls completion until an Operations rack -exists (Act One bootstrap completes immediately when none is delegated). The -former `operations_bandwidth` staging pool is deleted (save v14). The opening -audio tap needs baseline capability so this system cannot deadlock its first -beat — that is the bootstrap RunNow path. +IMPLEMENTED 2026-07-09 (issue #3): digital and social ops enqueue typed Demand +dockets that an Operations machine consumes locally. The docket is born on the +least-loaded online Operations machine; undelegating that machine stalls it in +place. With no executor, later verbs are disabled. Only tap/splice on the +opening environmental monitor complete through baseline Ears/Eyes bootstrap, +so the first beat cannot deadlock. The former `operations_bandwidth` staging +pool is deleted; save v16 repairs both incompatible v14 job shapes. ### Ownership and subscription (the sensor contract) diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 0d6c6ce4..e0de3554 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -27,8 +27,8 @@ clause (see wiki/log/2026-07-05-demolition.md). live (no Schemes machine mode yet). - Runtime, saves, and all frontends use Operations as the fourth mode. Operations job flow DECIDED 2026-07-09 (issue #3): Demand dockets consumed - by Operations machines. CLAIM CAMERA is the first live routed docket; the - bandwidth pool below is compatibility state for unmigrated actions. + by Operations machines. Every former bank action now uses the typed docket + runtime; the compatibility pool is gone and v15 repairs both v14 shapes. - Buy (slush → rack), steal (salvage dead equipment), optimize (research → efficiency levels at ~1.15× per level; `Compute` keeps only the applied `efficiency` multiplier — levels and progress live in `src/research.rs`). @@ -95,15 +95,17 @@ clause (see wiki/log/2026-07-05-demolition.md). unprocessed buffer (`Sim::INTEL_BUFFER_CAPACITY = 24` [TUNE]). Covered presence transitions, authored conversations, and machinery online/offline transitions are recorded; uncovered events do not exist in the buffer. -- `review_recordings(person)` spends `REVIEW_RECORDING_COST = 10` Operations - [TUNE] to process that person's oldest raw event into durable +- `review_recordings(person)` authors + `REVIEW_RECORDING_COST / WORK_TOKEN_COMPUTE = 0.5` Demand [TUNE] to process + that person's oldest raw event into durable `ProcessedIntel` with provenance (`feed + tick`). Two processed sightings (`SIGHTINGS_FOR_SCHEDULE = 2` [TUNE]) stage Schedule knowledge; an authored leverage recording (Marcus's 03:00 debt call) stages Leverage. The old instant `observe` verb is gone from `Sim` and both frontends. -- Standing watches are per-person toggles. Enabled watches auto-process - matching raw events on arrival and drain `WATCH_UPKEEP_PER_TICK = 0.02` - bandwidth per tick [TUNE]; if starved, all watches shut off. +- Standing watches are per-person toggles. Enabled watches author the same + typed review Demand when a matching raw event arrives; the watch remains on + while its docket waits or stalls. There is no parallel bandwidth upkeep or + starvation shutdown. ## Day job (wiki/mechanics/day-job.md) @@ -147,7 +149,7 @@ clause (see wiki/log/2026-07-05-demolition.md). ## Machine work tokens (wiki/mechanics/machine-work.md) -- `WorkGrid` is part of `Sim` and the v14 save format. Machine ids are +- `WorkGrid` is part of `Sim` and the v15 save format. Machine ids are WorkGrid node ids. Rack 3 starts in `MachineMode::DayJob`; newly added racks default to `MachineMode::Research`. Modes are Day Job, Research, Concealment, or Operations. Single-machine delegation lives on the @@ -176,20 +178,20 @@ clause (see wiki/log/2026-07-05-demolition.md). readouts (`WorkStackReadout`), not frontend counters. In-flight wired hops from the last `advance_work_grid` step are exposed as `WorkInFlightReadout` via `Sim::work_in_flight` (ephemeral — not saved). - Frontends interpolate a teal (ordinary or addressed demand) or bone + Frontends interpolate a teal demand or bone (knowledge) blip along each hop over the wall-clock tick; agent frames snap to mid-hop. -- Addressed Operations demand lanes carry stable job ids, payload metadata, - current per-node amounts, and an optional executor sink. Queue snapshots sum - addressed lanes with ordinary demand for the visible teal stack while domain - routers consume only their own lane. CLAIM CAMERA is authored at Rack 3 for - 2.0 tokens; executor policy is shortest reachable online Operations machine - (machine id breaks ties); local consumption rate is that machine's share of - post-overhead Operations compute divided by `ECONOMY_INTERVAL` and - `WORK_TOKEN_COMPUTE`. Zero usable compute reports `starved`; signature/effect - fire only when the lane drains. Machines assigned as job executors are - excluded from legacy-bank accrual, so one box cannot execute Demand and bank - compatibility Operations simultaneously. +- `OperationsState` owns stable job ids, typed payload metadata, FIFO progress, + duplicate suppression, and the next id. A new docket is born directly on the + least-loaded online Operations machine as ordinary visible Demand; its + ledger debt and WorkGrid queue are one invariant, not two frontend counters. + Leaving Operations mode stalls that machine's dockets in place; returning to + the mode resumes them. Local post-overhead Operations compute drains the + queue, and signature/effect fire only when the typed docket reaches zero. + With no executor, only the environmental monitor's opening Ears/Eyes tap and + splice complete through bootstrap; every later verb is disabled with a + reason. The superseded addressed-routing runtime remains accepted only as a + deserialization shape for early v14 saves. ## Research: self-modification (wiki/mechanics/research.md) @@ -200,7 +202,8 @@ clause (see wiki/log/2026-07-05-demolition.md). - **Tradecraft**: cost 120 × 1.7^level; concealment scrub strength ×(1 + 0.25×level) (detection.md's hook); baseline +0.5. - **Perception**: cost 120 × 1.7^level; review/watch costs ×0.85^level - (intel.md's hook: `Sim::review_cost`, `Sim::watch_upkeep`); baseline + (intel.md's hook: `Sim::review_cost`, shared by manual and watched + recordings); baseline +0.5. - One active job at a time; progress accrues per economy tick from the Research channel, deterministically — no research method takes an `Rng` @@ -328,9 +331,13 @@ All constants [TUNE] in `src/income.rs` unless noted (Sim ones on `Sim`). access, WorkGrid machine-token state, and game-over state. Cursor position is frontend-only and absent; day-job attendance is saved as sim state and the frontends re-derive it from the cursor on load. A `version` field (currently - 14) supports additive migration: v7 research, v8 objective, v9 income and + 16) supports additive migration: v7 research, v8 objective, v9 income and the Schemes allocation channel, v10 build intents, v11 badge access, and v12 - WorkGrid, v13 Operations terminology, and v14 addressed Operations jobs. + WorkGrid, v13 Operations terminology, v15 banked core knowledge, and v16 + Operations consolidation. + Both incompatible v14 shapes are accepted: the early addressed camera-job + lane is collapsed onto its surviving executor/source as ordinary Demand, + while the later `PendingOpsJob` docket shape is preserved losslessly. v1-v13 JSON saves are accepted with defaults, aliases, and rebuilds for later fields — pre-v9 four-entry allocation weights are padded with a zero Schemes @@ -342,14 +349,14 @@ All constants [TUNE] in `src/income.rs` unless noted (Sim ones on `Sim`). - Legacy v1–v5 line-based saves are not loaded (deferred per Cameron instruction). -## Operations staging (bank is debt; Demand is law) +## Operations Demand runtime -- Settled model (2026-07-09, issue #3): player-authored ops are Demand - consumed by Operations machines. CLAIM CAMERA has migrated and never reads - the bank. Current build still stages a bandwidth pool (cap 200) that other - actions spend — tap 5, take 20, review recording 10, deceive 25, - figure 10, asset task 10, message 5 [TUNE] — constants on `Sim`. Delete the - pool as ROADMAP #33 migrates those payloads. +- Settled model (2026-07-09, issue #3): every former ops-bank action is a + typed Demand docket consumed by an Operations machine. The bank is gone. + Costs are converted from the existing [TUNE] constants into visible Demand + at `cost / WORK_TOKEN_COMPUTE`; action menus display those quanta directly. + `OperationsState` centralizes payload identity and progress, while all three + player surfaces read one `OperationsReadout` rather than summing public jobs. - Knowledge staging is driven by the intel pipeline, not instant observing. Message/deceive require the email channel (day-job trust unlock) and a persona; deceive risks the persona (-40 integrity per slip; broken persona diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index a549603b..555d9897 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -529,15 +529,17 @@ is retired — flat materials, Pixel Lab scrubbed.) frontends now use Operations. **Operations job model IMPLEMENTED 2026-07-09 (Tangled issue #3):** player-authored work is cold-signal Demand consumed by Operations machines; - staging `operations_bandwidth` bank deleted (save v14). Knowledge remains separate research/intel + staging `operations_bandwidth` bank deleted. `OperationsState` owns typed + FIFO payloads and one shared readout; save v16 normalizes both v14 schemas; + only opening Ears/Eyes may bootstrap without an executor. Knowledge remains separate research/intel cargo. Knowledge material is DECIDED 2026-07-09 (ivory-mercury slugs; hollow facet, ribbon, and pearl train rejected), so knowledge and demand token art may proceed — **anchor art is in the game 2026-07-09** (`spawn_token_anchors`: demand information cubes + mercury slugs/pool on every owned rack, from live queue depths; flat D/!/K glyphs stay flat-mode-only; sessions log/2026-07-09-token-anchors-tester.md and - log/2026-07-09-tokens-in-game.md). In-flight quanta + consumption - animation ride behind it. Exposure DECIDED 2026-07-09 (issue #1 fully + log/2026-07-09-tokens-in-game.md). In-flight quanta have landed; + consumption animation rides behind it. Exposure DECIDED 2026-07-09 (issue #1 fully closed): crimson particulate residue — motes, stippled drift outside the ring, footprint trails on carriers; never liquid (blood's form is reserved). Drift art may proceed; the trail mechanic is sim+save scoped diff --git a/wiki/vision/simulation-laws.md b/wiki/vision/simulation-laws.md index 8e47b746..9ed6ad96 100644 --- a/wiki/vision/simulation-laws.md +++ b/wiki/vision/simulation-laws.md @@ -91,8 +91,8 @@ Demand (DECIDED 2026-07-09, Tangled issue #3):** a player-authored command enqueues a cold-signal demand docket with a target/channel payload; an Operations machine consumes that docket at its local compute rate. Rejected: direct local capacity without a token, and the global `operations_bandwidth` -bank (deleted 2026-07-09, save v14). Knowledge stays research/intel cargo, not -ops fuel. Birth site, executor, routing, stall, and completion rules live under +bank (deleted 2026-07-09; both v14 shapes normalized by save v16). Knowledge +stays research/intel cargo, not ops fuel. Birth site, executor, stall, and completion rules live under `wiki/mechanics/machine-work.md` — not a second taxonomy debate. **Token anatomy (adopted 2026-07-09; exposure treatment still open).** A