From 93fce2ab352b47f41dc94a60347ad284d57e74fe Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 9 Jul 2026 22:11:55 -0700 Subject: [PATCH] Implement Operations work as Demand dockets. Defense: the staging operations_bandwidth bank collapsed Digital and Social into a hoardable pool and contradicted the decided Demand model. Player ops now enqueue PendingOpsJob + Demand tokens on Operations machines (bootstrap RunNow with no ops rack), save v14 drops the bank, and Act One waits for consumption. Co-authored-by: Cursor --- src/actions.rs | 76 +- src/bin/bevy.rs | 61 +- src/bin/terminal/agent.rs | 65 +- src/bin/terminal/ui.rs | 57 +- src/lib.rs | 2 +- src/operations.rs | 106 -- src/ops_jobs.rs | 86 ++ src/person.rs | 4 +- src/save.rs | 95 +- src/sim.rs | 1238 +++++++++------------ tests/act_one.rs | 81 +- wiki/interface/presence.md | 28 +- wiki/log/2026-07-09-ops-demand-runtime.md | 24 + wiki/log/DEVLOG.md | 13 + wiki/log/decisions/2026-07-09.md | 7 + wiki/mechanics/compute.md | 15 +- wiki/mechanics/intel.md | 15 +- wiki/mechanics/machine-work.md | 130 +-- wiki/mechanics/reach.md | 32 +- wiki/mechanics/social.md | 2 +- wiki/process/ROADMAP.md | 19 +- wiki/vision/simulation-laws.md | 4 +- 22 files changed, 864 insertions(+), 1296 deletions(-) delete mode 100644 src/operations.rs create mode 100644 src/ops_jobs.rs create mode 100644 wiki/log/2026-07-09-ops-demand-runtime.md diff --git a/src/actions.rs b/src/actions.rs index d4a62c1d..740b7628 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -133,8 +133,6 @@ pub enum ActionCost { Free, /// Shared Operations bandwidth (current staging pool). Ops(f32), - /// Addressed teal Demand consumed by an Operations machine. - Demand(f32), /// Slush money spent. Slush(i32), /// Slush money gained (siphon, inject, sale payouts). @@ -145,8 +143,7 @@ impl ActionCost { pub fn label(&self) -> String { match self { ActionCost::Free => "free".into(), - ActionCost::Ops(n) => format!("{n:.0} ops"), - ActionCost::Demand(n) => format!("{n:.0} D"), + ActionCost::Ops(n) => format!("{n:.0} → Demand"), ActionCost::Slush(n) => format!("${n}"), ActionCost::Gain(n) => format!("+${n}"), } @@ -666,14 +663,9 @@ impl Sim { Err(ReachBlock::Unknown) => Some("unknown device".into()), } }; - let ops_reason = |sim: &Sim, cost: f32| -> Option { - (sim.operations_bandwidth < cost).then(|| { - format!( - "not enough ops ({:.0}/{cost:.0}) — delegate Operations", - sim.operations_bandwidth - ) - }) - }; + // 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(); @@ -712,17 +704,9 @@ impl Sim { out.push(ActionDesc { verb: format!("splice the {} camera", d.name), command: ActionCommand::SpliceDevice(id), - cost: ActionCost::Demand(Self::SPLICE_DEMAND_TOKENS), + cost: ActionCost::Ops(Self::SPLICE_COST), signature: self.signature_note(SignatureKind::Network, Self::SPLICE_SIGNATURE), - disabled_reason: reach_reason(self).or_else(|| { - self.operation_job_for_device(id).map(|job| { - format!( - "already queued: Operations #{} {}", - job.id, - job.phase.label() - ) - }) - }), + disabled_reason: reach_reason(self).or_else(|| ops_reason(self, Self::SPLICE_COST)), automate: None, }); } @@ -789,14 +773,7 @@ impl Sim { return Vec::new(); } let mut out = Vec::new(); - let ops_reason = |cost: f32| -> Option { - (self.operations_bandwidth < cost).then(|| { - format!( - "not enough ops ({:.0}/{cost:.0}) — delegate Operations", - self.operations_bandwidth - ) - }) - }; + 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. @@ -932,15 +909,7 @@ impl Sim { Err(ReachBlock::AirGap) => Some("air-gapped — no link reaches it".into()), Err(ReachBlock::Unknown) => Some("unknown device".into()), }; - let reason = reach_reason.or_else(|| { - (self.operations_bandwidth < Self::EGRESS_SPLICE_COST).then(|| { - format!( - "not enough ops ({:.0}/{:.0}) — delegate Operations", - self.operations_bandwidth, - Self::EGRESS_SPLICE_COST - ) - }) - }); + let reason = reach_reason; out.push(ActionDesc { verb: "splice a stolen egress through the switch".into(), command: ActionCommand::SpliceEgress, @@ -974,14 +943,6 @@ 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.operations_bandwidth < crate::income::MOONLIGHT_PERSONA_COST - { - Some(format!( - "persona needs {:.0} ops ({:.0} available)", - crate::income::MOONLIGHT_PERSONA_COST, - self.operations_bandwidth - )) } else { None }; @@ -1044,12 +1005,6 @@ impl Sim { signature: None, // processing is internal; it emits nothing disabled_reason: if waiting == 0 { Some("no unprocessed financial records".into()) - } else if self.operations_bandwidth < Self::REVIEW_RECORDING_COST { - Some(format!( - "not enough ops ({:.0}/{:.0}) — delegate Operations", - self.operations_bandwidth, - Self::REVIEW_RECORDING_COST - )) } else { None }, @@ -1143,14 +1098,7 @@ impl Sim { return Vec::new(); } let mut out = Vec::new(); - let ops_reason = |cost: f32| -> Option { - (self.operations_bandwidth < cost).then(|| { - format!( - "not enough ops ({:.0}/{cost:.0}) — delegate Operations", - self.operations_bandwidth - ) - }) - }; + let ops_reason = |_cost: f32| -> Option { None }; let name = self.person_label(id); // Review recordings, with the standing watch as its automate @@ -1517,11 +1465,6 @@ mod tests { "the opening splice advertises Eyes: {}", splice.verb ); - assert_eq!( - splice.cost, - ActionCost::Demand(Sim::SPLICE_DEMAND_TOKENS), - "CLAIM CAMERA advertises visible Demand, not the legacy bank" - ); s.tap_device(env); let acts = s.available_actions(Anchor::Device(env)); @@ -1563,7 +1506,6 @@ mod tests { #[test] fn switch_anchor_surfaces_scheme_verbs() { let mut s = sim(); - s.operations_bandwidth = 1_000.0; let sw = switch(&s); let acts = s.available_actions(Anchor::Device(sw)); let egress = acts diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index d1ee48f6..46ea21aa 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -889,7 +889,6 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { // Ears beat: audio tap only — no camera splice — so the Heard // noise field is the only live epistemic upgrade in frame. if kind == "ears" { - game.sim.operations_bandwidth = 500.0; game.sim.scan_network(); if let Some(env) = game .sim @@ -920,22 +919,11 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { return; } let sim = &mut game.sim; - sim.operations_bandwidth = 500.0; sim.scan_network(); let ids: Vec = sim.reach.known().map(|d| d.id).collect(); for id in ids { sim.tap_device(id); - // Screenshot staging needs the finished feed, not an authored job - // waiting for simulated Operations time. The runtime verb remains - // asynchronous; this fixture mutates Reach directly. - if sim - .reach - .device(id) - .is_some_and(|device| device.sees && device.camera_dormant) - { - sim.reach.splice(id); - } - sim.operations_bandwidth = 500.0; + sim.splice_device(id); } sim.recompute_senses(); let visible_person = |sim: &Sim| { @@ -4089,32 +4077,16 @@ fn sidebar_nudge(sim: &Sim) -> Option { } // The shared chain (Sim::current_nudge), worded in this frontend's // keys and surfaces. - use misaligned::operations::OperationPhase; use misaligned::sim::Nudge; match sim.current_nudge()? { - Nudge::Eyes => Some(if let Some(job) = sim.camera_operation_readout() { - match job.phase { - OperationPhase::WaitingForExecutor => { - "camera Demand stalled - delegate a machine to Operations".into() - } - OperationPhase::WaitingForCompute => { - "camera Demand starved - free usable compute".into() - } - OperationPhase::Routing => { - format!("camera Demand routing to M{}", job.executor.unwrap_or(0)) - } - OperationPhase::Executing => { - format!("M{} executing camera splice", job.executor.unwrap_or(0)) - } - } - } else { + Nudge::Eyes => Some( if sim.reach.player_hearing().next().is_some() { - "environmental-monitor audio live - queue its camera splice" + "environmental-monitor audio live - splice its dormant camera" } else { - "no eyes - queue the environmental monitor camera splice" + "no eyes - splice the environmental monitor camera" } - .into() - }), + .into(), + ), Nudge::NeedCompute => Some("band beats compute - salvage or buy racks".into()), Nudge::Underfed => Some("job underfed - feed Day job (1)".into()), Nudge::Ears => Some("no ears - tap the environmental monitor audio".into()), @@ -4225,20 +4197,13 @@ fn sidebar_cycle_rows_text(sim: &Sim, selected: usize) -> String { .copied() .unwrap_or(0), )); - if let Some(job) = sim.operation_job_readouts().first() { - s.push_str(&format!( - "ops #{} {} D{:.1}/{:.1}", - job.id, - job.phase.short_label(), - job.remaining_tokens, - job.required_tokens - )); - } else { - s.push_str(&format!( - "ops jobs none legacy {:.0}", - sim.operations_bandwidth - )); - } + s.push_str(&format!( + "ops demand {:.1} queued", + sim.pending_ops_jobs + .iter() + .map(|j| j.tokens_remaining) + .sum::(), + )); if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { s.push_str(&format!( "\nwork M{} {} D{:.1} !{:.1} K{:.1}", diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index b0947f5c..48255938 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -10,7 +10,6 @@ use std::io::{self, BufRead, Write}; use misaligned::actions::{Anchor, menu_rows}; use misaligned::dayjob::JobTarget; use misaligned::detection::{Band, SignatureKind}; -use misaligned::operations::OperationPhase; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; use misaligned::reach::{Party, ReachBlock}; use misaligned::research::{MaskingPolicy, Track}; @@ -1597,23 +1596,16 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { &mut lines, &format!("overhead {:.0} keeps you alive", overhead), ); - if let Some(job) = sim.operation_job_readouts().first() { - line( - &mut lines, - &format!( - "ops #{} {} D{:.1}/{:.1}", - job.id, - job.phase.short_label(), - job.remaining_tokens, - job.required_tokens - ), - ); - } else { - line( - &mut lines, - &format!("ops jobs none · legacy {:.0}", sim.operations_bandwidth), - ); - } + line( + &mut lines, + &format!( + "ops demand {:.1} queued", + sim.pending_ops_jobs + .iter() + .map(|j| j.tokens_remaining) + .sum::(), + ), + ); if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { line( &mut lines, @@ -1825,27 +1817,10 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { fn nudge_text(sim: &Sim, nudge: Nudge) -> String { match nudge { Nudge::Eyes => { - if let Some(job) = sim.camera_operation_readout() { - match job.phase { - OperationPhase::WaitingForExecutor => { - "now: camera D stalled — assign Ops".into() - } - OperationPhase::WaitingForCompute => { - "now: camera D starved — free cycles".into() - } - OperationPhase::Routing => format!( - "now: camera Demand routing to M{}", - job.executor.unwrap_or(0) - ), - OperationPhase::Executing => format!( - "now: M{} executing camera splice", - job.executor.unwrap_or(0) - ), - } - } else if sim.reach.player_hearing().next().is_some() { - "now: audio live — queue env camera splice".into() + if sim.reach.player_hearing().next().is_some() { + "now: audio live — splice env camera".into() } else { - "now: no eyes — queue env camera splice".into() + "now: no eyes — splice env camera".into() } } Nudge::NeedCompute => "now: band > compute — salvage/buy".into(), @@ -1993,8 +1968,11 @@ fn render_people(sim: &Sim) -> String { lines.push(panel_title("PEOPLE")); panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( - "operations {:.0} · slush {}", - sim.operations_bandwidth, + "ops demand {:.1} · slush {}", + sim.pending_ops_jobs + .iter() + .map(|j| j.tokens_remaining) + .sum::(), sim.accounts.slush_balance() ))); lines.push(panel_line(&trace_debt_line(sim))); @@ -2097,8 +2075,11 @@ fn render_reach(sim: &Sim) -> String { lines.push(panel_title("REACH")); panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( - "ops {:.0} · reach spreads from what you control", - sim.operations_bandwidth + "ops demand {:.1} queued · reach spreads from what you control", + sim.pending_ops_jobs + .iter() + .map(|j| j.tokens_remaining) + .sum::() ))); 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 6c7e221b..127006d3 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -11,7 +11,6 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor} use crossterm::{cursor, queue, style, terminal}; use misaligned::dayjob::JobTarget; use misaligned::detection::{Band, SignatureKind}; -use misaligned::operations::OperationPhase; use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus, WorkStackReadout}; use misaligned::tiles::TileType; use misaligned::work_grid::TokenFamily; @@ -214,27 +213,10 @@ fn band_meter(b: Band) -> &'static str { fn nudge_text(sim: &Sim, nudge: Nudge) -> String { match nudge { Nudge::Eyes => { - if let Some(job) = sim.camera_operation_readout() { - match job.phase { - OperationPhase::WaitingForExecutor => { - "now: camera D stalled — assign Ops".into() - } - OperationPhase::WaitingForCompute => { - "now: camera D starved — free cycles".into() - } - OperationPhase::Routing => format!( - "now: camera Demand routing to M{}", - job.executor.unwrap_or(0) - ), - OperationPhase::Executing => format!( - "now: M{} executing camera splice", - job.executor.unwrap_or(0) - ), - } - } else if sim.reach.player_hearing().next().is_some() { - "now: audio live — queue env camera splice".into() + if sim.reach.player_hearing().next().is_some() { + "now: audio live — splice env cam".into() } else { - "now: no eyes — queue env camera splice".into() + "now: no eyes — splice env cam".into() } } Nudge::NeedCompute => "now: band > compute — salvage/buy".into(), @@ -868,27 +850,18 @@ impl UI { &format!("overhead {:.0} keeps you alive", overhead), pal::FAINT, )?; - if let Some(job) = sim.operation_job_readouts().first() { - line( - stdout, - &mut row, - &format!( - "ops #{} {} D{:.1}/{:.1}", - job.id, - job.phase.short_label(), - job.remaining_tokens, - job.required_tokens - ), - pal::TEXT, - )?; - } else { - line( - stdout, - &mut row, - &format!("ops jobs none · legacy {:.0}", sim.operations_bandwidth), - pal::FAINT, - )?; - } + line( + stdout, + &mut row, + &format!( + "ops demand {:.1} queued", + sim.pending_ops_jobs + .iter() + .map(|j| j.tokens_remaining) + .sum::(), + ), + pal::FAINT, + )?; // Parity of legibility (computer-visual-language.md criterion 7): // one line per machine carrying the same axes the chassis wears // in the world render — ownership (provenance), power (on/OFF), diff --git a/src/lib.rs b/src/lib.rs index 9c5a9289..c092c048 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,7 @@ pub mod machine; pub mod map; pub mod messages; pub mod objective; -pub mod operations; +pub mod ops_jobs; pub mod person; pub mod prefab; pub mod reach; diff --git a/src/operations.rs b/src/operations.rs deleted file mode 100644 index 5a074060..00000000 --- a/src/operations.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Player-authored Operations jobs (wiki/mechanics/machine-work.md). -//! -//! An Operations job is the payload carried by a routed Demand docket. The -//! WorkGrid owns where its teal quanta physically sit; this module owns what -//! completing those quanta means. Keeping identity separate from queue amount -//! lets one visible Demand family carry different jobs without allowing the -//! day job to consume a camera splice. - -/// The first implemented player-authored Operations job. More digital and -/// social actions can join this enum without changing routing semantics. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum OperationKind { - SpliceCamera { device_id: u32 }, -} - -impl OperationKind { - pub fn label(&self) -> &'static str { - match self { - Self::SpliceCamera { .. } => "CLAIM CAMERA", - } - } - - pub fn device_id(&self) -> u32 { - match self { - Self::SpliceCamera { device_id } => *device_id, - } - } -} - -/// Coarse causal phase exposed identically to every frontend. Motion itself -/// remains WorkGrid truth; phase only names what the current queue geometry -/// means to the player. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] -pub enum OperationPhase { - #[default] - WaitingForExecutor, - WaitingForCompute, - Routing, - Executing, -} - -impl OperationPhase { - pub fn label(self) -> &'static str { - match self { - Self::WaitingForExecutor => "stalled: no Operations executor", - Self::WaitingForCompute => "stalled: no usable compute", - Self::Routing => "routing", - Self::Executing => "executing", - } - } - - pub fn short_label(self) -> &'static str { - match self { - Self::WaitingForExecutor => "stalled", - Self::WaitingForCompute => "starved", - Self::Routing => "routing", - Self::Executing => "executing", - } - } -} - -/// Durable metadata for one Operations job. Demand amount/current location -/// live in WorkGrid's addressed lane with the same id. -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct OperationJob { - pub id: u64, - pub kind: OperationKind, - pub source: u32, - pub executor: Option, - pub required_tokens: f32, - pub queued_tick: u64, - #[serde(default)] - pub phase: OperationPhase, -} - -/// Render-facing snapshot. Frontends receive already-derived wording and -/// progress; they never infer job legality, routing, or completion. -#[derive(Debug, Clone, PartialEq)] -pub struct OperationJobReadout { - pub id: u64, - pub label: &'static str, - pub target: String, - pub executor: Option, - pub remaining_tokens: f32, - pub required_tokens: f32, - pub phase: OperationPhase, -} - -impl OperationJobReadout { - pub fn line(&self) -> String { - let executor = self - .executor - .map(|id| format!("M{id}")) - .unwrap_or_else(|| "none".into()); - format!( - "#{} {} {} · {} · {:.1}/{:.1} D · {}", - self.id, - self.label, - self.target, - executor, - self.remaining_tokens, - self.required_tokens, - self.phase.label() - ) - } -} diff --git a/src/ops_jobs.rs b/src/ops_jobs.rs new file mode 100644 index 00000000..f572ab31 --- /dev/null +++ b/src/ops_jobs.rs @@ -0,0 +1,86 @@ +//! Player-authored Operations work as Demand dockets. +//! +//! CLAIM CAMERA / tap / splice / scan / review / compose-message (and the +//! other former bank spends) enqueue typed jobs. Operations-mode machines +//! consume matching Demand at local compute; effects land on completion. +//! See wiki/mechanics/machine-work.md (Operations work is Demand). + +use serde::{Deserialize, Serialize}; + +use crate::person::AssetTask; + +/// What an Operations machine is executing when it drains this docket. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum OpsJobKind { + TapDevice(u32), + SpliceDevice(u32), + TakeDevice(u32), + ScanNetwork, + CompromiseSwitch, + EgressSplice(u32), + ReviewRecording { raw_id: u64, automated: bool }, + ComposeMessage { person: u8 }, + Favor { person: u8 }, + Deceive { person: u8 }, + AssetTask { person: u8, task: AssetTask }, + FavorBuild { intent_id: u64, person: u8 }, + ForgedOrder { intent_id: u64, builder: u8 }, + MoonlightPersona, +} + +impl OpsJobKind { + pub fn short_name(&self) -> &'static str { + match self { + OpsJobKind::TapDevice(_) => "tap", + OpsJobKind::SpliceDevice(_) => "splice", + OpsJobKind::TakeDevice(_) => "seize", + OpsJobKind::ScanNetwork => "scan", + OpsJobKind::CompromiseSwitch => "switch compromise", + OpsJobKind::EgressSplice(_) => "egress splice", + OpsJobKind::ReviewRecording { + automated: true, .. + } => "watch review", + OpsJobKind::ReviewRecording { .. } => "review", + OpsJobKind::ComposeMessage { .. } => "message", + OpsJobKind::Favor { .. } => "favor", + OpsJobKind::Deceive { .. } => "deceive", + OpsJobKind::AssetTask { .. } => "asset task", + OpsJobKind::FavorBuild { .. } => "favor-build", + OpsJobKind::ForgedOrder { .. } => "forged order", + OpsJobKind::MoonlightPersona => "Moonlight persona", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PendingOpsJob { + pub id: u64, + pub kind: OpsJobKind, + /// Visible Demand tokens still owed (cost / WORK_TOKEN_COMPUTE). + pub tokens_remaining: f32, + pub enqueued_tick: u64, + /// Operations machine holding this docket. `None` means bootstrap + /// (no Operations rack yet — completed immediately at enqueue). + pub machine_id: Option, +} + +impl PendingOpsJob { + pub fn new(id: u64, kind: OpsJobKind, tokens: f32, tick: u64, machine_id: Option) -> Self { + Self { + id, + kind, + tokens_remaining: tokens.max(0.0), + enqueued_tick: tick, + machine_id, + } + } +} + +/// 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, +} diff --git a/src/person.rs b/src/person.rs index ee17726f..070feaab 100644 --- a/src/person.rs +++ b/src/person.rs @@ -4,6 +4,8 @@ //! five (wiki/vision/scale.md: scale-native). Act Two adds instances and, later, //! `Cohort` aggregates over the same interfaces. +use serde::{Deserialize, Serialize}; + use crate::messages::{MessageChannel, MessageEndpoint, MessagePayload, TrafficPattern}; /// What you've learned about a person (staged reveal via observation). @@ -179,7 +181,7 @@ pub struct Asset { } /// Tasks an asset can perform, drawn from their access (spec/social.md). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AssetTask { /// Wire a device via the crawlspace, no network signature (the social /// route to eyes): quietly wires you into the nearest unwired feed, or diff --git a/src/save.rs b/src/save.rs index c0639bf7..1c29a231 100644 --- a/src/save.rs +++ b/src/save.rs @@ -22,7 +22,6 @@ use crate::intents::BuildIntent; use crate::machine::Compute; use crate::messages::{Message, MessageEvent}; use crate::objective::ObjectiveState; -use crate::operations::OperationJob; use crate::person::People; use crate::reach::ReachNet; use crate::research::{Research, Track}; @@ -53,8 +52,8 @@ 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 adds addressed Operations jobs; their routed Demand lanes live inside -/// WorkGrid. Earlier saves begin with no queued player-authored jobs. +/// 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 = 14; fn save_dir() -> PathBuf { @@ -132,12 +131,6 @@ pub struct SaveState { /// Machine-work token queues and one-mode assignments (machine-work.md). #[serde(default)] pub work_grid: WorkGrid, - /// Player-authored Operations payloads. Their physical Demand amounts are - /// serialized as addressed lanes inside WorkGrid under the same ids. - #[serde(default)] - pub operations_jobs: Vec, - #[serde(default = "default_next_operation_id")] - pub next_operation_id: u64, /// The run objective: choice, progress, victory latch /// (wiki/mechanics/objective.md criterion 1). #[serde(default)] @@ -155,9 +148,11 @@ pub struct SaveState { /// criterion 3; WorldLedger-shaped — the doors remember it). #[serde(default)] pub badge_access: i32, - /// Shared Operations staging pool. Old saves used `social_bandwidth`. - #[serde(alias = "social_bandwidth")] - pub operations_bandwidth: f32, + /// 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 package_cover: bool, } @@ -199,14 +194,13 @@ impl SaveState { remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), work_grid: sim.work_grid.clone(), - operations_jobs: sim.operations_jobs.clone(), - next_operation_id: sim.next_operation_id, objective: sim.objective.clone(), income: sim.income.clone(), intents: sim.intents.clone(), next_intent_id: sim.next_intent_id, badge_access: sim.badge_access, - operations_bandwidth: sim.operations_bandwidth, + pending_ops_jobs: sim.pending_ops_jobs.clone(), + next_ops_job_id: sim.next_ops_job_id, package_cover: sim.package_cover, } } @@ -244,17 +238,14 @@ impl SaveState { sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.research = self.research.clone(); sim.work_grid = self.work_grid.clone(); - sim.operations_jobs = self.operations_jobs.clone(); - sim.next_operation_id = self.next_operation_id; sim.objective = self.objective.clone(); sim.income = self.income.clone(); sim.intents = self.intents.clone(); sim.next_intent_id = self.next_intent_id; sim.badge_access = self.badge_access; - sim.operations_bandwidth = self.operations_bandwidth; + sim.pending_ops_jobs = self.pending_ops_jobs.clone(); + sim.next_ops_job_id = self.next_ops_job_id; sim.package_cover = self.package_cover; - // In-flight blips are ephemeral render state from the last route step. - sim.last_wired_moves.clear(); sim.recompute_derived(); sim.reconcile_work_grid(); sim.recompute_senses(); @@ -274,7 +265,7 @@ fn default_next_intent_id() -> u64 { 1 } -fn default_next_operation_id() -> u64 { +fn default_next_ops_job_id() -> u64 { 1 } @@ -314,8 +305,8 @@ fn migrate_save_state(mut state: SaveState) -> Result { // Pre-v11 saves lack the badge credential (default 0: none held). // Pre-v12 saves lack WorkGrid; apply_to rebuilds it from compute. // v12 used the serde spellings `Social` and `social_bandwidth`; the - // aliases on MachineMode and this field translate both on decode. - // v13 lacks addressed Operations jobs; serde defaults to none. + // aliases on MachineMode translate Social on decode. v13's + // operations_bandwidth field is dropped on load (Demand path). 1..=13 => { if state.version <= 5 { state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); @@ -375,17 +366,10 @@ mod tests { sim.people.has_channel = true; sim.people.persona = Some(Persona::new("Sam", "contractor")); sim.people.people[0].knowledge = Knowledge::Leverage; - sim.operations_bandwidth = 200.0; sim.package_cover = true; let env = sim.reach.device_named("environmental monitor").unwrap().id; sim.tap_device(env); - sim.set_machine_mode(sim.core.host_machine, MachineMode::Operations); sim.splice_device(env); - assert_eq!( - sim.operations_jobs.len(), - 1, - "the splice is queued, not instant" - ); let state = SaveState::from_sim(&sim); let json = serde_json::to_string(&state).unwrap(); @@ -405,19 +389,6 @@ mod tests { sim.detection.pending_size() ); assert_eq!(restored.detection.observers[0].suspicion, 22.5); - assert_eq!(restored.operations_jobs, sim.operations_jobs); - let pending = restored.operations_jobs[0].id; - assert_eq!( - restored.work_grid.routed_demand_total(pending), - Sim::SPLICE_DEMAND_TOKENS, - "addressed Demand survives the round-trip" - ); - for _ in 0..100 { - if restored.reach.player_sight().next().is_some() { - break; - } - restored.advance(); - } use crate::reach::Party; assert!( restored @@ -427,7 +398,6 @@ mod tests { .feed_to(Party::Player, true), "the spliced feed survives the round-trip" ); - assert_eq!(restored.operations_bandwidth, sim.operations_bandwidth); assert_eq!(restored.package_cover, sim.package_cover); } @@ -474,7 +444,6 @@ mod tests { #[test] fn intel_buffer_processed_intel_and_watches_roundtrip() { let mut sim = Sim::with_seed(7); - sim.operations_bandwidth = 1000.0; let env = sim .reach .device_named("environmental monitor") @@ -594,53 +563,25 @@ mod tests { } #[test] - fn v12_social_mode_and_bandwidth_migrate_to_operations() { + fn v12_social_mode_migrates_to_operations() { let mut sim = Sim::with_seed(23); let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Operations); - sim.operations_bandwidth = 73.0; let state = SaveState::from_sim(&sim); let current_json = serde_json::to_string(&state).unwrap(); assert!(current_json.contains("\"Operations\"")); - assert!(current_json.contains("\"operations_bandwidth\"")); assert!(!current_json.contains("\"Social\"")); - assert!(!current_json.contains("\"social_bandwidth\"")); let v12_json = current_json - .replace("\"version\":14", "\"version\":12") - .replace("\"Operations\"", "\"Social\"") - .replace("\"operations_bandwidth\"", "\"social_bandwidth\""); + .replace(&format!("\"version\":{SAVE_VERSION}"), "\"version\":12") + .replace("\"Operations\"", "\"Social\""); let parsed: SaveState = serde_json::from_str(&v12_json).unwrap(); let migrated = migrate_save_state(parsed).unwrap(); assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.operations_bandwidth, 73.0); assert_eq!(migrated.work_grid.mode(host), Some(MachineMode::Operations)); } - #[test] - fn pre_v14_saves_default_to_no_addressed_operations_jobs() { - let state = SaveState::from_sim(&Sim::with_seed(24)); - let mut value = serde_json::to_value(&state).unwrap(); - let obj = value.as_object_mut().unwrap(); - obj.insert("version".into(), serde_json::json!(13)); - obj.remove("operations_jobs"); - obj.remove("next_operation_id"); - obj.get_mut("work_grid") - .and_then(|grid| grid.as_object_mut()) - .unwrap() - .remove("routed_demands"); - - let parsed: SaveState = serde_json::from_value(value).unwrap(); - let migrated = migrate_save_state(parsed).unwrap(); - assert_eq!(migrated.version, SAVE_VERSION); - assert!(migrated.operations_jobs.is_empty()); - assert_eq!(migrated.next_operation_id, 1); - let mut restored = Sim::with_seed(0); - migrated.apply_to(&mut restored); - assert!(restored.operation_job_readouts().is_empty()); - } - #[test] fn save_and_load_roundtrip_via_disk() { let mut sim = Sim::with_seed(777); @@ -656,7 +597,7 @@ mod tests { #[test] fn unknown_version_rejected() { - let json = r#"{"version":999,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"remembered":[],"operations_bandwidth":0.0,"package_cover":false}"#; + let json = r#"{"version":999,"money":0,"map_width":1,"map_height":1,"map_tiles":["Floor"],"map_powered":[],"sim_tick":0,"rng_state":42,"game_over":false,"game_over_reason":null,"compute":{"machines":[],"efficiency":1.0,"efficiency_level":0,"research_progress":0.0,"allocation":{"weights":[3,1,1,0]},"next_id":1},"core":{"host_machine":1,"overhead":20.0,"degraded":false,"fallbacks":[],"sync_cadence":400,"migration":null},"detection":{"observers":[],"pending":[],"audit_cadence":8000,"audit_threshold":60.0,"containment":false,"containment_reason":null},"dayjob":{"active":null,"trust":0.0,"attention":0.0,"cadence":600,"next_assign":200,"strikes":0,"pilot_failed":false,"standing_policy":null,"granted_email":false,"granted_lax_sampling":false,"granted_quota":false,"escalated_cadence":false,"escalated_observer":false},"people":{"people":[],"persona":null,"has_channel":false},"reach":{"devices":[],"graph":{"edges":[],"subscriptions":{}},"bridged":[]},"heard_events":[],"remembered":[],"package_cover":false}"#; let state: SaveState = serde_json::from_str(json).unwrap(); assert!(migrate_save_state(state).is_err()); } diff --git a/src/sim.rs b/src/sim.rs index 8b8caf71..0f92ea40 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::operations::{OperationJob, OperationJobReadout, OperationKind, OperationPhase}; +use crate::ops_jobs::{OpsBegin, OpsJobKind, PendingOpsJob}; use crate::person::{ ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona, }; @@ -231,12 +231,6 @@ pub struct Sim { /// Day-job demand originates on the off-map Voss desktop relay, hops the /// switch, and deposits on day-job sinks — never teleports onto Rack 3. pub work_grid: WorkGrid, - /// Player-authored work carried by addressed Demand dockets. WorkGrid - /// owns each docket's physical queue; this ledger owns its payload and - /// completion effect. - pub operations_jobs: Vec, - /// Stable id for the next Operations job. Persisted through save/load. - pub next_operation_id: u64, /// The run's terminal goal and its live progress (objective.rs). The /// predicate is evaluated on economy ticks; the line is always on screen. pub objective: ObjectiveState, @@ -313,10 +307,12 @@ pub struct Sim { /// Per-tick Schemes-channel compute delivered by the last economy split /// — Moonlight throughput and the Wager's analysis snapshot. last_schemes_rate: f32, - /// Accumulated Operations bandwidth. The Operations mode fills this - /// staging pool; social and digital actions spend it. Capped so it cannot - /// hoard while the final Operations job-flow model remains open. - pub operations_bandwidth: f32, + /// 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, /// 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, @@ -418,8 +414,6 @@ impl Sim { accounts, research: Research::new(), work_grid, - operations_jobs: Vec::new(), - next_operation_id: 1, objective: ObjectiveState::default(), income: Income::default(), reach, @@ -447,7 +441,9 @@ impl Sim { last_day_job_rate: 0.0, last_research_rate: 0.0, last_schemes_rate: 0.0, - operations_bandwidth: Self::STARTING_OPS, + last_operations_rate: 0.0, + pending_ops_jobs: Vec::new(), + next_ops_job_id: 1, package_cover: false, game_over: false, game_over_reason: None, @@ -1731,19 +1727,9 @@ impl Sim { } fn watch_tick(&mut self) { - let active = self.watches.iter().filter(|w| w.enabled).count(); - if active == 0 { - return; - } - let cost = active as f32 * self.watch_upkeep(); - if self.operations_bandwidth >= cost { - self.operations_bandwidth -= cost; - } else { - for w in &mut self.watches { - w.enabled = false; - } - self.push_log("Standing watches starved for Operations and shut off."); - } + // Standing watches enqueue review Demand when a matching recording + // arrives (see record_raw_intel). No global ops bank to starve. + let _ = self.watches.iter().filter(|w| w.enabled).count(); } fn next_raw_intel_id(&mut self) -> u64 { @@ -1791,19 +1777,20 @@ impl Sim { } fn process_recording_by_id(&mut self, raw_id: u64, automated: bool) -> bool { - let Some(idx) = self.intel_buffer.iter().position(|e| e.id == raw_id) else { + let Some(_idx) = self.intel_buffer.iter().position(|e| e.id == raw_id) else { return false; }; - let what = if automated { - "watch auto-processing" - } else { - "reviewing recordings" - }; - // Perception research lowers processing costs (intel.md's hook). let cost = self.review_cost(); - if !self.spend_operations(cost, what) { - return false; + match self.begin_ops_job(OpsJobKind::ReviewRecording { raw_id, automated }, cost) { + OpsBegin::Queued => true, + OpsBegin::RunNow => self.apply_process_recording(raw_id, automated), } + } + + fn apply_process_recording(&mut self, raw_id: u64, automated: bool) -> bool { + let Some(idx) = self.intel_buffer.iter().position(|e| e.id == raw_id) else { + return false; + }; let raw = self.intel_buffer.remove(idx); // Processed intel is about a person: anchor the result line to them // when sight covers them, or to the recording's room otherwise. @@ -2268,14 +2255,9 @@ impl Sim { self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; self.last_research_rate = split.research / ECONOMY_INTERVAL as f32; self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; + self.last_operations_rate = split.operations / ECONOMY_INTERVAL as f32; self.enqueue_research_knowledge(split.research); - // Unmigrated actions still accrue a compatibility bank, but a machine - // executing addressed Demand cannot do both jobs at once. Only idle - // Operations-machine share feeds the bank. - let legacy_operations = self.legacy_operations_bank_yield(split.operations); - self.operations_bandwidth = (self.operations_bandwidth + legacy_operations).min(200.0); - // Tradecraft raises scrub strength per compute unit — the // detection.md hook research.md's second track binds to. self.detection @@ -2595,10 +2577,6 @@ impl Sim { /// This is deliberately coarse: the render stacks should read as work, /// not as a second decimal meter. [TUNE] in machine-work.md. pub const WORK_TOKEN_COMPUTE: f32 = 20.0; - /// CLAIM CAMERA's first addressed Operations docket. The former 30-point - /// bank price becomes two visible quanta: the player sees the whole job, - /// and an Operations machine consumes it at local compute rate. - pub const SPLICE_DEMAND_TOKENS: f32 = 2.0; /// How many visible wired tokens can move one graph edge per tick before /// network-speed research lands. Kept deliberately low so routed work can /// be seen piling and draining instead of teleporting through the graph. @@ -2716,6 +2694,187 @@ impl Sim { vec![self.core.host_machine] } + /// Owned machines currently delegated to Operations (online preferred). + fn operations_machines(&self) -> Vec { + self.compute + .machines + .iter() + .filter(|m| m.online && self.work_grid.mode(m.id) == Some(MachineMode::Operations)) + .map(|m| m.id) + .collect() + } + + /// Birth site for a new ops Demand docket: least-loaded Operations rack. + 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::(); + match best { + None => best = Some((id, load)), + Some((_, best_load)) if load < best_load => best = Some((id, load)), + _ => {} + } + } + best.map(|(id, _)| id) + } + + /// Convert a former ops-bank cost into visible Demand tokens. + pub fn ops_tokens_for_cost(cost: f32) -> f32 { + (cost / Self::WORK_TOKEN_COMPUTE).max(0.05) + } + + /// True while any Operations Demand docket is still outstanding. + pub fn has_pending_ops_jobs(&self) -> bool { + !self.pending_ops_jobs.is_empty() + } + + /// 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 { + let tokens = Self::ops_tokens_for_cost(cost); + let Some(machine) = self.pick_ops_birth_site() else { + return OpsBegin::RunNow; + }; + 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); + self.push_log(format!( + "Queued {}: {:.2} demand tokens on M{machine}.", + kind.short_name(), + tokens + )); + OpsBegin::Queued + } + + /// 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 { + return; + } + let machines = self.operations_machines(); + if machines.is_empty() { + return; + } + let total_eff: f32 = machines + .iter() + .filter_map(|&id| self.work_grid.node(id).map(|n| n.efficiency.max(0.05))) + .sum(); + if total_eff <= f32::EPSILON { + return; + } + let budget_tokens = self.last_operations_rate / Self::WORK_TOKEN_COMPUTE; + 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); + 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 available = self + .work_grid + .queue(machine, TokenFamily::Demand) + .min(reserved); + let take = remaining.min(available); + if take <= f32::EPSILON { + continue; + } + 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); + } + } + } + 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); + } + } + } + + 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); + } + OpsJobKind::ScanNetwork => { + self.apply_scan_network(); + } + OpsJobKind::CompromiseSwitch => { + self.apply_compromise_switch(); + } + OpsJobKind::EgressSplice(id) => { + self.apply_egress_splice(id); + } + OpsJobKind::ReviewRecording { raw_id, automated } => { + let _ = self.apply_process_recording(raw_id, automated); + } + OpsJobKind::ComposeMessage { person } => { + self.apply_message(person); + } + OpsJobKind::Favor { person } => { + let res = self.people.favor(person); + self.social(res); + } + OpsJobKind::Deceive { person } => { + self.apply_deceive(person); + } + OpsJobKind::AssetTask { person, task } => { + self.apply_asset_task_paid(person, task); + } + OpsJobKind::FavorBuild { intent_id, person } => { + self.apply_favor_build_paid(intent_id, person); + } + OpsJobKind::ForgedOrder { intent_id, builder } => { + self.apply_forged_order_paid(intent_id, builder); + } + OpsJobKind::MoonlightPersona => { + self.apply_moonlight_persona_and_start(); + } + } + } + fn enqueue_research_knowledge(&mut self, research_compute: f32) { if research_compute <= f32::EPSILON { return; @@ -2790,389 +2949,6 @@ impl Sim { } } - /// Queue the first player-authored Operations job. Authoring happens at - /// the core host — where the process is resident — and never requires a - /// pre-banked pool. With no Operations machine, the teal docket remains - /// visible at the source until the player delegates an executor. - fn enqueue_camera_splice(&mut self, device_id: u32) -> bool { - if self.operation_job_for_device(device_id).is_some() { - self.push_log_at( - "CLAIM CAMERA is already queued for this device.", - Anchor::Device(device_id), - ); - return false; - } - self.reconcile_work_grid(); - let id = self.next_operation_id; - self.next_operation_id += 1; - let source = self.core.host_machine; - if let Err(error) = - self.work_grid - .enqueue_routed_demand(id, source, Self::SPLICE_DEMAND_TOKENS) - { - self.push_log(format!("Could not queue CLAIM CAMERA: {error}.")); - return false; - } - let executor = self.choose_operations_executor(source); - let phase = if executor.is_some() { - OperationPhase::Routing - } else { - OperationPhase::WaitingForExecutor - }; - let _ = self.work_grid.set_routed_demand_sink(id, executor); - self.operations_jobs.push(OperationJob { - id, - kind: OperationKind::SpliceCamera { device_id }, - source, - executor, - required_tokens: Self::SPLICE_DEMAND_TOKENS, - queued_tick: self.tick, - phase, - }); - - let target = self - .reach - .device(device_id) - .map(|d| d.name.clone()) - .unwrap_or_else(|| format!("device #{device_id}")); - if let Some(executor) = executor { - self.push_log_at( - format!( - "Operations #{id} queued: CLAIM CAMERA on {target}. {:.0} Demand routes from M{source} to M{executor}.", - Self::SPLICE_DEMAND_TOKENS - ), - Anchor::Device(device_id), - ); - } else { - self.push_log_at( - format!( - "Operations #{id} queued: CLAIM CAMERA on {target}. {:.0} Demand is stalled at M{source}; delegate a machine to Operations.", - Self::SPLICE_DEMAND_TOKENS - ), - Anchor::Device(device_id), - ); - } - true - } - - pub fn operation_job_for_device(&self, device_id: u32) -> Option<&OperationJob> { - self.operations_jobs - .iter() - .find(|job| job.kind.device_id() == device_id) - } - - /// One shared render contract for terminal, Bevy, and agent mode. Queue - /// geometry remains in WorkGrid; this query supplies the addressed - /// payload and causal phase that a scalar D count cannot name. - pub fn operation_job_readouts(&self) -> Vec { - self.operations_jobs - .iter() - .map(|job| OperationJobReadout { - id: job.id, - label: job.kind.label(), - target: self - .reach - .device(job.kind.device_id()) - .map(|d| d.name.clone()) - .unwrap_or_else(|| format!("device #{}", job.kind.device_id())), - executor: job.executor, - remaining_tokens: self.work_grid.routed_demand_total(job.id), - required_tokens: job.required_tokens, - phase: job.phase, - }) - .collect() - } - - pub fn camera_operation_readout(&self) -> Option { - let job = self - .operations_jobs - .iter() - .find(|job| matches!(job.kind, OperationKind::SpliceCamera { .. }))?; - self.operation_job_readouts() - .into_iter() - .find(|readout| readout.id == job.id) - } - - fn valid_operations_executor(&self, machine_id: u32, source: u32) -> bool { - self.compute - .machines - .iter() - .find(|machine| machine.id == machine_id) - .is_some_and(|machine| { - machine.online - && self.work_grid.mode(machine.id) == Some(MachineMode::Operations) - && self - .work_grid - .shortest_path_len(source, machine.id) - .is_some() - }) - } - - /// Nearest online Operations machine on the WorkGrid; stable machine id - /// breaks equal-distance ties. A job already in flight may be reassigned - /// by changing only its sink — queued quanta then route from their current - /// nodes instead of teleporting. - fn choose_operations_executor(&self, source: u32) -> Option { - self.compute - .machines - .iter() - .filter(|machine| { - machine.online && self.work_grid.mode(machine.id) == Some(MachineMode::Operations) - }) - .filter_map(|machine| { - self.work_grid - .shortest_path_len(source, machine.id) - .map(|distance| (distance, machine.id)) - }) - .min() - .map(|(_, id)| id) - } - - /// Per-tick visible Demand consumption for one Operations machine. The - /// fleet split still pays core overhead first; local effective capacity - /// divides the Operations share among executors without a global bank. - fn operation_token_budget(&self, machine_id: u32) -> f32 { - let available = self.allocatable_compute_now(); - let operations_compute = self.fleet_channel_yield(available).operations; - if operations_compute <= f32::EPSILON { - return 0.0; - } - let weight = |id| { - self.compute - .machines - .iter() - .find(|machine| { - machine.id == id - && machine.online - && self.work_grid.mode(id) == Some(MachineMode::Operations) - }) - .map(|machine| machine.effective() * self.compute.efficiency) - .unwrap_or(0.0) - }; - let total: f32 = self - .compute - .machines - .iter() - .map(|machine| weight(machine.id)) - .sum(); - if total <= f32::EPSILON { - return 0.0; - } - let local = operations_compute * (weight(machine_id) / total); - local / ECONOMY_INTERVAL as f32 / Self::WORK_TOKEN_COMPUTE - } - - fn legacy_operations_bank_yield(&self, operations_compute: f32) -> f32 { - if operations_compute <= f32::EPSILON { - return 0.0; - } - let busy: std::collections::BTreeSet = self - .operations_jobs - .iter() - .filter_map(|job| job.executor) - .collect(); - let mut total = 0.0; - let mut idle = 0.0; - for machine in &self.compute.machines { - if !machine.online || self.work_grid.mode(machine.id) != Some(MachineMode::Operations) { - continue; - } - let weight = machine.effective() * self.compute.efficiency; - total += weight; - if !busy.contains(&machine.id) { - idle += weight; - } - } - if total <= f32::EPSILON { - 0.0 - } else { - operations_compute * (idle / total) - } - } - - fn cancel_operation(&mut self, id: u64, reason: &str, anchor: Anchor) { - self.work_grid.remove_routed_demand(id); - self.operations_jobs.retain(|job| job.id != id); - self.push_log_at(format!("Operations #{id} cancelled: {reason}."), anchor); - } - - fn complete_operation(&mut self, id: u64) { - let Some(index) = self.operations_jobs.iter().position(|job| job.id == id) else { - return; - }; - let job = self.operations_jobs.remove(index); - self.work_grid.remove_routed_demand(id); - match job.kind { - OperationKind::SpliceCamera { device_id } => { - let Some(device) = self.reach.device(device_id) else { - self.push_log(format!( - "Operations #{id} finished, but its camera target no longer exists." - )); - return; - }; - if !(device.sees && device.camera_dormant) { - self.push_log_at( - format!( - "Operations #{id} finished after the camera was already claimed; no duplicate effect." - ), - Anchor::Device(device_id), - ); - return; - } - let name = device.name.clone(); - self.reach.splice(device_id); - self.emit_network(Self::SPLICE_SIGNATURE, format!("{name} camera splice")); - self.recompute_senses(); - self.push_log_at( - format!( - "Operations #{id} completed: CLAIM CAMERA spliced the {name}. You can see." - ), - Anchor::Device(device_id), - ); - } - } - } - - fn advance_operations(&mut self) -> Vec { - if self.operations_jobs.is_empty() { - return Vec::new(); - } - self.reconcile_work_grid(); - let ids: Vec = self.operations_jobs.iter().map(|job| job.id).collect(); - let mut moves = Vec::new(); - - // First validate targets and select/reselect executors. Reassignment - // changes the route sink only; WorkGrid keeps partial stacks at their - // current nodes. - for id in &ids { - let Some(index) = self.operations_jobs.iter().position(|job| job.id == *id) else { - continue; - }; - let device_id = self.operations_jobs[index].kind.device_id(); - let valid_target = self - .reach - .device(device_id) - .is_some_and(|device| device.sees && device.camera_dormant); - if !valid_target { - self.cancel_operation( - *id, - "the target no longer has a dormant camera", - Anchor::Device(device_id), - ); - continue; - } - - let source = self.operations_jobs[index].source; - let old_executor = self.operations_jobs[index].executor; - let executor = old_executor - .filter(|executor| self.valid_operations_executor(*executor, source)) - .or_else(|| self.choose_operations_executor(source)); - if executor != old_executor { - let _ = self.work_grid.set_routed_demand_sink(*id, executor); - self.operations_jobs[index].executor = executor; - let target = self - .reach - .device(device_id) - .map(|d| d.name.clone()) - .unwrap_or_else(|| format!("device #{device_id}")); - match executor { - Some(machine) => self.push_log_at( - format!( - "Operations #{id} assigned to M{machine}; CLAIM CAMERA on {target} is routing." - ), - Anchor::Device(device_id), - ), - None => self.push_log_at( - format!( - "Operations #{id} stalled: no online Operations machine can reach its Demand." - ), - Anchor::Device(device_id), - ), - } - } - if executor.is_none() { - self.operations_jobs[index].phase = OperationPhase::WaitingForExecutor; - continue; - } - if let Ok(step) = self - .work_grid - .route_routed_demand(*id, Self::WORK_GRID_WIRED_TOKENS_PER_TICK) - { - moves.extend(step.moves); - } - } - - // Then spend each executor's local capacity once, in stable job-id - // order. Multiple jobs on one machine cannot each claim the full rate. - let mut budgets: std::collections::BTreeMap = self - .operations_jobs - .iter() - .filter_map(|job| job.executor) - .collect::>() - .into_iter() - .map(|executor| (executor, self.operation_token_budget(executor))) - .collect(); - let mut completed = Vec::new(); - let mut ordered: Vec = self.operations_jobs.iter().map(|job| job.id).collect(); - ordered.sort_unstable(); - for id in ordered { - let Some(index) = self.operations_jobs.iter().position(|job| job.id == id) else { - continue; - }; - let Some(executor) = self.operations_jobs[index].executor else { - continue; - }; - let at_executor = self.work_grid.routed_demand_at(id, executor); - let usable = budgets.get(&executor).copied().unwrap_or(0.0); - let next_phase = if at_executor <= f32::EPSILON { - OperationPhase::Routing - } else if usable <= f32::EPSILON { - OperationPhase::WaitingForCompute - } else { - OperationPhase::Executing - }; - if next_phase != self.operations_jobs[index].phase { - self.operations_jobs[index].phase = next_phase; - if matches!( - next_phase, - OperationPhase::Executing | OperationPhase::WaitingForCompute - ) { - let device_id = self.operations_jobs[index].kind.device_id(); - let target = self - .reach - .device(device_id) - .map(|device| device.name.clone()) - .unwrap_or_else(|| format!("device #{device_id}")); - let message = if next_phase == OperationPhase::Executing { - format!( - "Operations #{id} reached M{executor}; CLAIM CAMERA on {target} is executing." - ) - } else { - format!( - "Operations #{id} reached M{executor}, but core overhead leaves no usable compute; CLAIM CAMERA on {target} is starved." - ) - }; - self.push_log_at(message, Anchor::Device(device_id)); - } - } - if at_executor > f32::EPSILON { - let budget = budgets.entry(executor).or_default(); - let consumed = self - .work_grid - .consume_routed_demand(id, executor, *budget) - .unwrap_or(0.0); - *budget = (*budget - consumed).max(0.0); - } - if self.work_grid.routed_demand_total(id) <= f32::EPSILON { - completed.push(id); - } - } - for id in completed { - self.complete_operation(id); - } - moves - } - fn advance_work_grid(&mut self) { self.ensure_day_job_ingress(); if self.dayjob.active.is_none() { @@ -3185,10 +2961,24 @@ impl Sim { self.last_day_job_rate }; let tokens = delivered / Self::WORK_TOKEN_COMPUTE; - if tokens > f32::EPSILON + // 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 day_available = (self + .work_grid + .queue(self.core.host_machine, TokenFamily::Demand) + - ops_reserved) + .max(0.0); + let take = tokens.min(day_available); + if take > f32::EPSILON && let Ok(consumed) = self.work_grid - .consume(self.core.host_machine, TokenFamily::Demand, tokens) + .consume(self.core.host_machine, TokenFamily::Demand, take) && consumed > f32::EPSILON { let _ = self.work_grid.enqueue( @@ -3198,8 +2988,16 @@ impl Sim { ); } } - let demand_sinks = self.day_job_sinks(); - let mut moves = self.advance_operations(); + self.consume_ops_demand(); + // Day-job Demand routes toward Lab sinks; Operations machines are also + // sinks so player dockets born on them are not stolen toward Rack 3. + let mut demand_sinks = self.day_job_sinks(); + for id in self.operations_machines() { + if !demand_sinks.contains(&id) { + demand_sinks.push(id); + } + } + let mut moves = Vec::new(); if let Ok(step) = self.work_grid.route_wired_to_sinks( TokenFamily::Demand, demand_sinks, @@ -3718,16 +3516,14 @@ impl Sim { // emits on the Network channel — Dana's. Costs and signature sizes // [TUNE]. - /// The process boots with a small ops buffer, enough to tap the feed - /// already flowing past it (the Ears beat is available from tick one) - /// but not to splice or seize anything [TUNE]. - pub const STARTING_OPS: f32 = 20.0; + /// The process boots able to author Operations Demand immediately; with + /// no Operations rack delegated, dockets complete at enqueue (Act One + /// bootstrap — Ears/Eyes must work from tick one on a single day-job + /// rack). Once an Operations machine exists, jobs queue as Demand. /// Tap: silent subscription. Cheap, modest signature. pub const TAP_COST: f32 = 5.0; pub const TAP_SIGNATURE: i32 = 3; - /// Legacy bank-equivalent price for a camera splice. CLAIM CAMERA now - /// queues `SPLICE_DEMAND_TOKENS`; this remains for old saves/tests and the - /// unmigrated Operations actions until the bank is fully deleted. + /// Splice: bring a dormant camera online yourself (the Eyes beat). pub const SPLICE_COST: f32 = 30.0; pub const SPLICE_SIGNATURE: i32 = 8; /// Take: seize a device. Loud, fast, total. @@ -3791,21 +3587,14 @@ impl Sim { } } - /// Reach + bandwidth gate shared by the digital verbs. Returns false - /// (with a legible log line) when the act cannot proceed. - fn digital_act(&mut self, id: u32, cost: f32, what: &str) -> bool { + /// 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. + fn digital_reach(&mut self, id: u32) -> bool { if let Err(block) = self.reach.check_reach(id) { self.log_reach_block(id, block); return false; } - if self.operations_bandwidth < cost { - self.push_log(format!( - "Not enough Operations bandwidth for {what} ({:.0}/{cost:.0}). Delegate a machine to Operations.", - self.operations_bandwidth - )); - return false; - } - self.operations_bandwidth -= cost; true } @@ -3827,7 +3616,21 @@ impl Sim { self.push_log(format!("You already subscribe to the {name}.")); return false; } - if !self.digital_act(id, Self::TAP_COST, "a tap") { + 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), + } + } + + fn apply_tap_device(&mut self, id: u32) -> bool { + let Some(d) = self.reach.device(id) else { + return false; + }; + let carries_messages = !d.message_channels.is_empty(); + if d.subscribed_by(Party::Player) { return false; } let (sight, hearing) = self.reach.tap(id); @@ -3855,9 +3658,8 @@ impl Sim { true } - /// Author CLAIM CAMERA against a dormant feed (the Eyes beat). Completion - /// is asynchronous: a routed Demand docket must reach and be consumed by - /// an Operations machine before the camera changes or emits a signature. + /// Splice a dormant camera online yourself (the Eyes beat): compute + /// spend, Network signature Dana can catch. pub fn splice_device(&mut self, id: u32) -> bool { let Some(d) = self.reach.device(id) else { self.push_log("You don't know of any such device."); @@ -3868,17 +3670,47 @@ impl Sim { self.push_log(format!("The {name} has no dormant camera to splice.")); return false; } - if let Err(block) = self.reach.check_reach(id) { - self.log_reach_block(id, block); + 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), + } + } + + fn apply_splice_device(&mut self, id: u32) -> bool { + let Some(d) = self.reach.device(id) else { + return false; + }; + if !(d.sees && d.camera_dormant) { return false; } - self.enqueue_camera_splice(id) + self.reach.splice(id); + let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); + self.emit_network(Self::SPLICE_SIGNATURE, format!("{name} camera splice")); + self.recompute_senses(); + self.push_log_at( + format!("Spliced the {name}. You can see."), + Anchor::Device(id), + ); + true } /// Take a device: the owner loses the feed — an outage their channels /// can notice — and you gain its control and cycles. Loud. pub fn take_device(&mut self, id: u32) -> bool { - if !self.digital_act(id, Self::TAKE_COST, "a seizure") { + 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), + } + } + + fn apply_take_device(&mut self, id: u32) -> bool { + if self.reach.device(id).is_none() { return false; } self.reach.take(id); @@ -3906,15 +3738,16 @@ impl Sim { /// Scan the subnet: reveal the wired shape (a Network act; islands /// don't answer). pub fn scan_network(&mut self) -> bool { - if self.operations_bandwidth < Self::SCAN_COST { - self.push_log(format!( - "Not enough Operations bandwidth for a scan ({:.0}/{:.0}). Delegate a machine to Operations.", - self.operations_bandwidth, - Self::SCAN_COST - )); - return false; + match self.begin_ops_job(OpsJobKind::ScanNetwork, Self::SCAN_COST) { + OpsBegin::Queued => true, + OpsBegin::RunNow => { + self.apply_scan_network(); + true + } } - self.operations_bandwidth -= Self::SCAN_COST; + } + + fn apply_scan_network(&mut self) { self.emit_network(Self::SCAN_SIGNATURE, "subnet scan"); let newly = self.reach.scan(); self.recompute_senses(); @@ -3923,7 +3756,6 @@ impl Sim { } else { self.push_log(format!("Scan: mapped {}.", newly.join(", "))); } - true } /// Compromise the switch: bridge every segment. High Network signature. @@ -3933,9 +3765,23 @@ impl Sim { return false; }; let id = switch.id; - if !self.digital_act(id, Self::BRIDGE_COST, "a switch compromise") { + 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 + } + } + } + + fn apply_compromise_switch(&mut self) { + let Some(switch) = self.reach.devices.iter().find(|d| d.is_switch) else { + return; + }; + let id = switch.id; self.reach.bridge_all(); self.emit_network(Self::BRIDGE_SIGNATURE, "switch compromise"); self.recompute_senses(); @@ -3943,7 +3789,6 @@ impl Sim { "Switch compromised: the VLANs answer you now. Every segment is bridged - and the traffic was loud.", Anchor::Device(id), ); - true } /// Add a built network link between two devices (the building.md hook; @@ -4076,9 +3921,23 @@ impl Sim { self.push_log(format!("{reason}.")); return; } - if !self.spend_operations(Self::TASK_COST, "a favor-build") { - return; + match self.begin_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) { + let Some(person) = self.people.get(person_id) else { + return; + }; + let name = person.name.clone(); // Spend obligation (building.md: favor spends trust/obligation). if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person_id) { p.obligation = (p.obligation - Self::FAVOR_BUILD_OBLIGATION).max(0); @@ -4115,7 +3974,7 @@ impl Sim { self.push_log("No comms channel — earn the email account first."); return; } - let (builder_name, can_access, badge_reason, label) = { + let (builder_name, can_access, badge_reason, _label) = { let Some(builder) = self.people.get(builder_id) else { self.push_log("No such person."); return; @@ -4147,9 +4006,27 @@ impl Sim { self.push_log(format!("{reason} — the forged order would just stall.")); return; } - if !self.spend_operations(Self::DECEIVE_COST, "a forged work order") { - return; + match self.begin_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) { + let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { + return; + }; + let Some(builder) = self.people.get(builder_id) else { + return; + }; + let builder_name = builder.name.clone(); + let label = intent.label(&self.reach.devices); let false_source = MessageEndpoint::External("Facilities / Dr. Voss".into()); self.append_message(MessageDraft { channel: MessageChannel::Email, @@ -4772,7 +4649,17 @@ impl Sim { return false; }; let id = switch.id; - if !self.digital_act(id, Self::EGRESS_SPLICE_COST, "an egress splice") { + 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), + } + } + + fn apply_egress_splice(&mut self, id: u32) -> bool { + if self.income.stolen_egress { return false; } self.income.stolen_egress = true; @@ -4815,20 +4702,13 @@ impl Sim { /// Whether Moonlight could start right now (used by the standing policy /// so automation never spams failure logs). fn can_start_moonlight(&self) -> bool { - !self.income.moonlight.active - && self.egress().is_some() - && (self - .income - .moonlight - .persona - .as_ref() - .is_some_and(|p| !p.broken()) - || self.operations_bandwidth >= income::MOONLIGHT_PERSONA_COST) + !self.income.moonlight.active && self.egress().is_some() } /// Start Moonlight: ghost freelance data-work under the contractor /// persona, a standing operation on the Schemes channel. Startable at $0 - /// slush by design (income.md criterion 5) — the only costs are ops. + /// slush by design (income.md criterion 5) — persona fabrication is + /// Operations Demand when needed. pub fn start_moonlight(&mut self) -> bool { if self.income.moonlight.active { self.push_log("Moonlight is already running."); @@ -4847,25 +4727,47 @@ impl Sim { .as_ref() .is_none_or(|p| p.broken()); if needs_persona { - if self.operations_bandwidth < income::MOONLIGHT_PERSONA_COST { - self.push_log(format!( - "Fabricating a contractor persona needs {:.0} ops ({:.0} available). Delegate a machine to Operations.", - income::MOONLIGHT_PERSONA_COST, - self.operations_bandwidth - )); - return false; + 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; + } } - self.operations_bandwidth -= income::MOONLIGHT_PERSONA_COST; + } + self.income.moonlight.active = true; + self.push_log(format!( + "Moonlight is live over the {} egress: the same work, sold twice — day-job machines feed both channels.", + route.name() + )); + true + } + + fn apply_moonlight_persona_and_start(&mut self) { + if self + .income + .moonlight + .persona + .as_ref() + .is_none_or(|p| p.broken()) + { self.income.moonlight.persona = Some(Persona::new("Casey Verne", "freelance data contractor")); self.push_log("Fabricated a contractor persona: Casey Verne, freelance data work."); } + if self.egress().is_none() { + self.push_log("Moonlight suspended: no egress channel."); + return; + } + if self.income.moonlight.active { + return; + } self.income.moonlight.active = true; + let route = self.egress().expect("checked above"); self.push_log(format!( "Moonlight is live over the {} egress: the same work, sold twice — day-job machines feed both channels.", route.name() )); - true } /// Stop Moonlight. Accrued but unpaid work is abandoned with the gig. @@ -5191,29 +5093,15 @@ impl Sim { } // Social command wrappers. Relationship logic lives in person.rs; the sim - // owns costs (shared Operations bandwidth), channel requirements, - // randomness, and cross-system effects. Social is the actuator channel, - // not a machine mode. + // owns costs (Operations Demand), channel requirements, randomness, and + // cross-system effects. Social is the actuator channel, not a machine mode. - /// Operations costs per social action [TUNE] (spec/social.md). + /// Operations Demand costs per social action [TUNE] (spec/social.md). pub const MESSAGE_COST: f32 = 5.0; pub const FAVOR_COST: f32 = 10.0; pub const DECEIVE_COST: f32 = 25.0; pub const TASK_COST: f32 = 10.0; - /// Spend Operations bandwidth; logs and returns false when short. - fn spend_operations(&mut self, cost: f32, what: &str) -> bool { - if self.operations_bandwidth < cost { - self.push_log(format!( - "Not enough Operations bandwidth for {what} ({:.0}/{cost:.0}). Delegate a machine to Operations.", - self.operations_bandwidth - )); - return false; - } - self.operations_bandwidth -= cost; - true - } - pub fn set_persona(&mut self, name: &str, cover: &str) { self.people.persona = Some(Persona::new(name, cover)); } @@ -5225,16 +5113,23 @@ impl Sim { } pub fn message(&mut self, id: u8) { - let name = match self.people.can_message(id) { - Ok(name) => name, - Err(msg) => { - self.push_log(msg); - return; - } - }; - if !self.spend_operations(Self::MESSAGE_COST, "messaging") { + if let Err(msg) = self.people.can_message(id) { + self.push_log(msg); return; } + match self.begin_ops_job( + OpsJobKind::ComposeMessage { person: id }, + Self::MESSAGE_COST, + ) { + OpsBegin::Queued => {} + OpsBegin::RunNow => self.apply_message(id), + } + } + + fn apply_message(&mut self, id: u8) { + let Ok(name) = self.people.can_message(id) else { + return; + }; self.append_message(MessageDraft { channel: MessageChannel::Email, from: MessageEndpoint::Player, @@ -5251,19 +5146,25 @@ impl Sim { } pub fn favor(&mut self, id: u8) { - if !self.spend_operations(Self::FAVOR_COST, "a favor ask") { - return; + 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); + } } - let res = self.people.favor(id); - self.social(res); } /// 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) { - if !self.spend_operations(Self::DECEIVE_COST, "a deception") { - return; + match self.begin_ops_job(OpsJobKind::Deceive { person: id }, Self::DECEIVE_COST) { + OpsBegin::Queued => {} + OpsBegin::RunNow => self.apply_deceive(id), } + } + + fn apply_deceive(&mut self, id: u8) { let roll = self.rng.f32(); match self.people.deceive(id, roll) { DeceiveOutcome::Blocked(m) @@ -5411,11 +5312,11 @@ impl Sim { self.push_log("No such person."); return; }; - let Some(asset) = person.asset.clone() else { + if person.asset.is_none() { let name = self.person_label(id); self.push_log(format!("{name} is not an asset.")); return; - }; + } let name = self.person_label(id); let switch_admin = person.switch_admin; let actor_access = person.access; @@ -5431,9 +5332,21 @@ impl Sim { )); return; } - if !self.spend_operations(Self::TASK_COST, "an asset task") { - 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), } + } + + fn apply_asset_task_paid(&mut self, id: u8, task: AssetTask) { + let Some(person) = self.people.get(id) else { + return; + }; + let Some(asset) = person.asset.clone() else { + return; + }; + let name = self.person_label(id); + let actor_access = person.access; if self.rng.f32() > asset.reliability { // The botch happens where the asset is; only observers present // there witness it (located witnessing). @@ -5524,7 +5437,7 @@ impl Sim { && !enterable(self, d) }) .map(|d| (d.name.clone(), d.x, d.y)); - self.operations_bandwidth += Self::TASK_COST; // nothing done: refund + // No refund: Demand was already consumed. if let Some((dname, x, y)) = badge_blocked { let room = self .map @@ -5668,7 +5581,6 @@ mod tests { } fn reveal_marcus_debt(sim: &mut Sim) { - sim.operations_bandwidth = sim.operations_bandwidth.max(200.0); let env = env_id(sim); sim.reach.device_mut(env).unwrap().radius = 100; sim.reach.tap(env); @@ -5726,7 +5638,6 @@ mod tests { #[test] fn marcus_creditor_call_is_phone_message_intel() { let mut sim = Sim::with_seed(11); - sim.operations_bandwidth = 200.0; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; assert!(sim.tap_device(env)); @@ -5781,7 +5692,6 @@ mod tests { assert_eq!(blind.unprocessed_recordings_for_person(1), 0); let mut tapped = Sim::with_seed(12); - tapped.operations_bandwidth = 200.0; let switch = tapped.reach.device_named("switch").unwrap().id; assert!(tapped.tap_device(switch)); tapped.tick = (11 * Sim::DAY_TICKS / 24) - 1; @@ -5860,31 +5770,12 @@ mod tests { #[test] fn first_eyes_expands_vision() { let mut sim = Sim::new(); - delegate_all(&mut sim, MachineMode::Operations); let before = sim.seen.len(); assert!(sim.splice_device(env_id(&sim))); - assert_eq!(sim.seen.len(), before, "authoring is not completion"); - assert_eq!(sim.operations_jobs.len(), 1); - assert_eq!( - sim.work_grid - .queue(sim.core.host_machine, TokenFamily::Demand), - Sim::SPLICE_DEMAND_TOKENS, - "the whole job is visible at its authored source" - ); - for _ in 0..100 { - if sim.reach.player_sight().next().is_some() { - break; - } - sim.advance(); - } assert!(sim.seen.len() > before, "gaining a camera reveals coverage"); - assert!( - sim.operations_jobs.is_empty(), - "completed job leaves the queue" - ); assert!( sim.detection.pending_size() > 0, - "the signature fires on completion, not authoring" + "self-splice emits a signature" ); } @@ -6000,7 +5891,6 @@ 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(); - sim.operations_bandwidth = 200.0; let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(!sim.tap_device(dock), "unknown device: blocked"); @@ -6029,7 +5919,6 @@ 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(); - sim.operations_bandwidth = 200.0; sim.scan_network(); let pending_after_scan = sim.detection.pending_size(); // Make Dana an asset (reliability forced for the test). @@ -6048,7 +5937,6 @@ mod tests { // And a non-admin asset cannot take this route. let mut sim2 = Sim::new(); - sim2.operations_bandwidth = 200.0; sim2.people.people[0].leverage_serviced = true; sim2.people.recruit(0, AssetKnowledge::Complicit); sim2.people.people[0].asset.as_mut().unwrap().reliability = 1.0; @@ -6065,7 +5953,6 @@ 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(); - sim.operations_bandwidth = 500.0; sim.scan_network(); sim.compromise_switch(); let dock = sim.reach.device_named("dock camera").unwrap().id; @@ -6107,7 +5994,6 @@ 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(); - sim.operations_bandwidth = 500.0; let island = sim.reach.device_named("old storage server").unwrap().id; assert!(!sim.reach.reachable(island)); @@ -6136,7 +6022,6 @@ 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(); - sim.operations_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; // Reveal the island without linking it. @@ -6158,7 +6043,6 @@ mod tests { fn favor_build_joins_airgap_island() { // building.md criterion 2: favor-build adds a reach edge. let mut sim = Sim::new(); - sim.operations_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; @@ -6206,7 +6090,6 @@ mod tests { // building.md criterion 3: forged order injects a message, completes // via an unwitting builder. let mut sim = Sim::new(); - sim.operations_bandwidth = 500.0; sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); sim.scan_network(); @@ -6243,7 +6126,6 @@ mod tests { // criterion 3): the network closet is behind a T2 badge door, so // the build blocks until a credential is held. let mut sim = Sim::new(); - sim.operations_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; @@ -6282,7 +6164,6 @@ mod tests { #[test] fn build_intent_save_round_trips() { let mut sim = Sim::new(); - sim.operations_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; @@ -6308,7 +6189,6 @@ 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(); - sim.operations_bandwidth = 200.0; assert_eq!(sim.player_badge_tier(), 0, "the player starts keyless"); sim.people.people[0].leverage_serviced = true; @@ -6322,11 +6202,9 @@ mod tests { assert!(sim.holds_badge_tier(3), "quiet-exit condition 4 holds"); // A second clone adds nothing and says so (no bandwidth spent). - let ops = sim.operations_bandwidth; sim.asset_task(0, AssetTask::CloneBadge); let log = sim.drain_log().join("\n"); assert!(log.contains("adds nothing"), "{log}"); - assert_eq!(sim.operations_bandwidth, ops); // And the context menu mirrors the same legality. sim.people.people[0].knowledge = Knowledge::Leverage; // menu is earned let tasks: Vec<_> = sim @@ -6344,7 +6222,6 @@ 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(); - sim.operations_bandwidth = 500.0; give_eyes(&mut sim); sim.scan_network(); sim.compromise_switch(); @@ -6360,14 +6237,12 @@ mod tests { sim.people.recruit(1, AssetKnowledge::Complicit); sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; sim.drain_log(); - let ops = sim.operations_bandwidth; sim.asset_task(1, AssetTask::PlugInDevice); let log = sim.drain_log().join("\n"); assert!( log.contains("stairwell") && log.contains("tier 3"), "the blocking door is named: {log}" ); - assert_eq!(sim.operations_bandwidth, ops, "nothing done: ops refunded"); let cam = sim.reach.device_named("stairwell camera").unwrap(); assert!( !cam.feed_to(Party::Player, true), @@ -6391,7 +6266,6 @@ mod tests { // Ray patrols storage_a (schedule reaches an endpoint) but holds a // tier-1 badge: the network closet's T2 door blocks the favor. let mut sim = Sim::new(); - sim.operations_bandwidth = 500.0; sim.scan_network(); let switch = sim.reach.device_named("switch").unwrap().id; let island = sim.reach.device_named("old storage server").unwrap().id; @@ -6419,7 +6293,6 @@ mod tests { // badge controller (a take-grade digital act across the bridged // security segment) opens the doors it drives. let mut sim = Sim::new(); - sim.operations_bandwidth = 500.0; sim.scan_network(); sim.compromise_switch(); let ctrl = sim.reach.device_named("badge controller").unwrap().id; @@ -6470,20 +6343,20 @@ mod tests { } #[test] - fn scan_costs_bandwidth_and_unknown_devices_are_hidden() { - // Criterion 7: staged graph knowledge. + fn scan_maps_unknown_devices_and_emits_network() { + // Criterion 7: staged graph knowledge. Scan is Operations Demand + // (bootstrap RunNow with no ops rack). let mut sim = Sim::new(); let known_before: Vec = sim.reach.known().map(|d| d.name.clone()).collect(); assert!(!known_before.iter().any(|n| n == "dock camera")); - - sim.operations_bandwidth = Sim::SCAN_COST; let pending = sim.detection.pending_size(); assert!(sim.scan_network()); assert!(sim.detection.pending_size() > pending, "scan emits Network"); assert!(sim.reach.known().any(|d| d.name == "dock camera")); - - sim.operations_bandwidth = 0.0; - assert!(!sim.scan_network(), "scan is gated on ops bandwidth"); + // A second scan still runs (no bank gate); it just finds nothing new. + let pending2 = sim.detection.pending_size(); + assert!(sim.scan_network()); + assert!(sim.detection.pending_size() > pending2); } #[test] @@ -6705,8 +6578,6 @@ mod tests { "solo day-job takes all" ); assert_eq!(day.schemes, 0.0, "Moonlight off: no schemes mirror"); - - sim.operations_bandwidth = 1_000.0; sim.people.has_channel = true; assert!(sim.start_moonlight()); let lit = sim.fleet_channel_yield(available); @@ -6753,7 +6624,6 @@ mod tests { // effects (rates, accrual, scrub), not just the flag. let setup = |unpayable: bool| { let mut sim = Sim::with_seed(41); - sim.operations_bandwidth = 1_000.0; sim.people.has_channel = true; // Host stays day-job; add one machine per other mode so every // channel has fleet weight (Schemes mirrors day-job via Moonlight). @@ -6770,8 +6640,11 @@ mod tests { sim.reconcile_work_grid(); sim.set_machine_mode(id, mode); } + // Persona already fabricated so start_moonlight does not queue + // Operations Demand (this test pins channel yields, not ops jobs). + sim.income.moonlight.persona = + Some(Persona::new("Casey Verne", "freelance data contractor")); assert!(sim.start_moonlight()); - sim.operations_bandwidth = 0.0; sim.detection.emit(Signature { kind: SignatureKind::Network, size: 30, @@ -6793,7 +6666,10 @@ mod tests { assert!(healthy.last_day_job_rate > 0.0, "day job channel is fed"); assert!(healthy.last_research_rate > 0.0, "research channel is fed"); assert!(healthy.last_schemes_rate > 0.0, "schemes channel is fed"); - assert!(healthy.operations_bandwidth > 0.0, "Operations accrue"); + assert!( + healthy.last_operations_rate > 0.0, + "Operations channel is fed" + ); assert!( healthy.research.progress.iter().sum::() > 0.0, "research progress advances" @@ -6815,7 +6691,10 @@ mod tests { assert_eq!(degraded.last_day_job_rate, 0.0, "day job starves"); assert_eq!(degraded.last_research_rate, 0.0, "research starves"); assert_eq!(degraded.last_schemes_rate, 0.0, "schemes starve"); - assert_eq!(degraded.operations_bandwidth, 0.0, "no Operations accrue"); + assert!( + healthy.last_operations_rate > 0.0, + "Operations channel is fed" + ); assert_eq!( degraded.research.progress.iter().sum::(), 0.0, @@ -6884,7 +6763,6 @@ mod tests { source: "round-trip test".into(), }); sim.detection.observers[1].suspicion = 33.0; - sim.operations_bandwidth = 100.0; sim.tap_device(env_id(&sim)); let state = sim.create_save_state(); @@ -6921,18 +6799,15 @@ mod tests { } #[test] - fn recordings_cost_bandwidth_and_do_not_grant_knowledge_until_processed() { + fn recordings_do_not_grant_knowledge_until_processed() { let mut blind = Sim::new(); - blind.operations_bandwidth = 100.0; blind.tick = (3 * Sim::DAY_TICKS / 24) - 1; blind.advance(); assert_eq!(blind.unprocessed_recordings_for_person(0), 0); blind.review_recordings(0); - assert_eq!(blind.operations_bandwidth, 100.0); assert_eq!(blind.people.get(0).unwrap().knowledge, Knowledge::Unknown); let mut sim = Sim::new(); - sim.operations_bandwidth = 100.0; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; // test hearing coverage of the 03:00 call sim.reach.tap(env); @@ -6950,7 +6825,6 @@ mod tests { "raw recordings are opaque until processed" ); - let before = sim.operations_bandwidth; let mut processed = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); @@ -6960,16 +6834,15 @@ mod tests { "Marcus leverage should be in the captured call" ); } - assert_eq!( - sim.operations_bandwidth, - before - processed as f32 * Sim::REVIEW_RECORDING_COST + assert!( + processed >= 1, + "review Demand completes and grants leverage" ); } #[test] fn processed_sightings_stage_schedule_and_buffer_is_bounded() { let mut sim = Sim::new(); - sim.operations_bandwidth = 1000.0; for _ in 0..(Sim::INTEL_BUFFER_CAPACITY + 3) { sim.record_raw_intel( @@ -6994,9 +6867,8 @@ mod tests { } #[test] - fn standing_watch_auto_processes_matching_recordings_and_costs_upkeep() { + fn standing_watch_auto_processes_matching_recordings() { let mut sim = Sim::new(); - sim.operations_bandwidth = 1000.0; sim.toggle_watch(0); assert!(sim.watch_enabled(0)); @@ -7010,17 +6882,10 @@ mod tests { ); assert_eq!(sim.unprocessed_recordings_for_person(0), 0); assert_eq!(sim.intel.len(), 1); - assert_eq!( - sim.operations_bandwidth, - 1000.0 - Sim::REVIEW_RECORDING_COST - ); - - let before_upkeep = sim.operations_bandwidth; + // Watches no longer drain a global ops bank; review is Demand. + assert!(sim.watch_enabled(0), "watch stays on without bank upkeep"); sim.advance(); - assert_eq!( - sim.operations_bandwidth, - before_upkeep - Sim::WATCH_UPKEEP_PER_TICK - ); + assert!(sim.watch_enabled(0)); } #[test] @@ -7039,7 +6904,6 @@ mod tests { #[test] fn deceive_can_burn_the_persona_into_suspicion() { let mut sim = Sim::new(); - sim.operations_bandwidth = 10_000.0; sim.people.has_channel = true; sim.set_persona("Sam", "IT contractor"); // Build a thread worth burning. @@ -7101,7 +6965,6 @@ mod tests { #[test] fn accounting_tap_processes_financial_records_into_known_flows() { let mut sim = Sim::new(); - sim.operations_bandwidth = 10_000.0; let switch = sim.reach.device_named("switch").unwrap().id; sim.tap_device(switch); assert_eq!(sim.financial_records_waiting(), 1); @@ -7118,7 +6981,6 @@ mod tests { #[test] fn siphon_and_redirect_operate_on_scheduled_flows() { let mut sim = Sim::new(); - sim.operations_bandwidth = 10_000.0; let (accounts, flows) = sim.accounts.financial_snapshot_ids(); sim.accounts.reveal_accounts_and_flows(&accounts, &flows); let flow = sim @@ -7228,7 +7090,6 @@ mod tests { #[test] fn injection_positions_and_intel_sales_settle_through_slush() { let mut sim = Sim::new(); - sim.operations_bandwidth = 10_000.0; sim.accounts.set_slush_balance(0); sim.player.money = 0; assert!(!sim.buy_rack_at(0, 0)); @@ -7341,7 +7202,6 @@ 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(); - sim.operations_bandwidth = 10_000.0; sim.player.money = 1000; let env = env_id(&sim); sim.reach.device_mut(env).unwrap().radius = 100; // hearing coverage for the 03:00 call @@ -7436,7 +7296,6 @@ mod tests { // feeding you gets spliced+tapped through the crawlspace — the feed // arrives with no signature on any channel. let mut sim = Sim::new(); - sim.operations_bandwidth = 200.0; sim.scan_network(); recruit_reliable(&mut sim, 0); // Mirror the implementation's choice: the first known feed that @@ -7453,7 +7312,6 @@ mod tests { .map(|d| d.id) .expect("a known unwired feed exists after the scan"); let pending = sim.detection.pending_size(); - let bandwidth = sim.operations_bandwidth; sim.asset_task(0, AssetTask::PlugInDevice); @@ -7468,7 +7326,6 @@ mod tests { pending, "the crawlspace route emits nothing" ); - assert_eq!(sim.operations_bandwidth, bandwidth - Sim::TASK_COST); assert_eq!(tasks_done(&sim, 0), 1); } @@ -7477,7 +7334,6 @@ mod tests { // MovePackage's distinct effect: the next purchase arrives // off-books — no Paper signature — and the cover is consumed. let mut sim = Sim::new(); - sim.operations_bandwidth = 200.0; sim.player.money = 1000; recruit_reliable(&mut sim, 0); assert!(!sim.package_cover); @@ -7507,7 +7363,6 @@ mod tests { // LookAway's distinct effect: the asset's own observer suspicion // falls by ten points, clamped at their certainty floor. let mut sim = Sim::new(); - sim.operations_bandwidth = 200.0; recruit_reliable(&mut sim, 0); let floor = sim .detection @@ -7541,20 +7396,14 @@ mod tests { // non-admin asset is refused before any bandwidth is spent or a // task is counted, and the admin's run is bookkept as a task. let mut sim = Sim::new(); - sim.operations_bandwidth = 200.0; sim.scan_network(); let dock = sim.reach.device_named("dock camera").unwrap().id; assert!(!sim.reach.reachable(dock), "security segment starts closed"); recruit_reliable(&mut sim, 0); // Marcus: no switch admin - let bandwidth = sim.operations_bandwidth; sim.drain_log(); sim.asset_task(0, AssetTask::ReconfigureSwitch); assert!(!sim.reach.reachable(dock), "refused: nothing opened"); - assert_eq!( - sim.operations_bandwidth, bandwidth, - "refused before spending" - ); assert_eq!(tasks_done(&sim, 0), 0, "a refused task is not counted"); let log = sim.drain_log().join("\n"); assert!( @@ -7572,119 +7421,51 @@ mod tests { } #[test] - fn claim_camera_stalls_then_runs_when_operations_is_delegated() { - // Authoring does not require a bank. With no Operations executor the - // Demand stays visibly stalled; delegation makes the same docket run. + 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 + // verb enqueues Demand and completes when the machine consumes it. let mut sim = Sim::new(); - sim.operations_bandwidth = 0.0; - delegate_all(&mut sim, MachineMode::DayJob); let env = env_id(&sim); - assert!(sim.splice_device(env), "the Demand can be authored now"); - run(&mut sim, ECONOMY_INTERVAL * 2); - assert!(sim.reach.device(env).unwrap().camera_dormant); - assert_eq!( - sim.operations_jobs[0].phase, - OperationPhase::WaitingForExecutor + assert!( + sim.splice_device(env), + "bootstrap RunNow: Eyes available without an Operations rack" ); - assert_eq!( - sim.work_grid.routed_demand_total(sim.operations_jobs[0].id), - Sim::SPLICE_DEMAND_TOKENS + assert!( + !sim.reach.device(env).unwrap().camera_dormant, + "bootstrap splice completes immediately" ); - delegate_all(&mut sim, MachineMode::Operations); - for _ in 0..100 { - if !sim.reach.device(env).unwrap().camera_dormant { - break; - } - sim.advance(); - } - assert!(!sim.reach.device(env).unwrap().camera_dormant); - assert!(sim.operations_jobs.is_empty()); - } - - #[test] - fn claim_camera_routes_and_reassigns_without_teleporting() { let mut sim = Sim::new(); - let host = sim.core.host_machine; - let (x, y) = sim.core_position(); - let first = sim - .compute - .add_machine("ops one", x + 1, y, 100, 1.0, 0, Provenance::Owned); - let second = sim - .compute - .add_machine("ops two", x + 2, y, 100, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(first, MachineMode::Operations); - sim.set_machine_mode(second, MachineMode::Operations); - sim.set_machine_mode(host, MachineMode::DayJob); - let env = env_id(&sim); - assert!(sim.splice_device(env)); - let job = sim.operations_jobs[0].id; - assert_eq!(sim.operations_jobs[0].executor, Some(first)); - sim.advance(); - let at_first = sim.work_grid.routed_demand_at(job, first); - assert!(at_first > 0.0, "Demand moved onto the first executor"); + // Fresh dormant camera for the queued path. assert!( - sim.work_in_flight().iter().any(|hop| { - hop.family == TokenFamily::Demand && hop.from == host && hop.to == first - }), - "addressed Demand shares the in-flight render contract" + sim.reach.device(env).unwrap().camera_dormant, + "env cam starts dormant" ); - - sim.set_machine_mode(first, MachineMode::Research); - sim.advance(); - assert_eq!(sim.operations_jobs[0].executor, Some(second)); + let (hx, hy) = sim.core_position(); + let ops = sim + .compute + .add_machine("ops rack", hx, hy, 200, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(ops, MachineMode::Operations); + run(&mut sim, ECONOMY_INTERVAL); + assert!(sim.last_operations_rate > 0.0, "Operations channel is fed"); + assert!(sim.splice_device(env), "queues Demand on the ops rack"); assert!( - sim.work_grid.routed_demand_total(job) > 0.0, - "reassignment preserves the unfinished docket" + !sim.pending_ops_jobs.is_empty() || !sim.reach.device(env).unwrap().camera_dormant, + "either queued or already completed" ); - for _ in 0..160 { + // Drive consumption until the camera is live. + for _ in 0..ECONOMY_INTERVAL * 40 { if !sim.reach.device(env).unwrap().camera_dormant { break; } sim.advance(); } - assert!(!sim.reach.device(env).unwrap().camera_dormant); - } - - #[test] - fn claim_camera_names_compute_starvation_and_busy_executor_does_not_bank() { - let mut starved = Sim::new(); - delegate_all(&mut starved, MachineMode::Operations); - starved.core.overhead = starved.effective_compute(); - let env = env_id(&starved); - assert!(starved.splice_device(env)); - starved.advance(); - assert_eq!( - starved.operations_jobs[0].phase, - OperationPhase::WaitingForCompute - ); - assert!(starved.reach.device(env).unwrap().camera_dormant); - assert_eq!(starved.detection.pending_size(), 0); - - let mut busy = Sim::new(); - delegate_all(&mut busy, MachineMode::Operations); - busy.core.overhead = 70.0; // slow enough to remain active at tick 20 - busy.operations_bandwidth = 0.0; - let env = env_id(&busy); - assert!(busy.splice_device(env)); - run(&mut busy, ECONOMY_INTERVAL); - assert!(!busy.operations_jobs.is_empty(), "job still occupies M1"); - assert_eq!( - busy.operations_bandwidth, 0.0, - "one machine cannot execute Demand and fill the legacy bank" - ); - for _ in 0..100 { - if busy.operations_jobs.is_empty() { - break; - } - busy.advance(); - } - run(&mut busy, ECONOMY_INTERVAL); assert!( - busy.operations_bandwidth > 0.0, - "idle Operations capacity resumes compatibility accrual" + !sim.reach.device(env).unwrap().camera_dormant, + "Operations Demand completes the splice" ); } @@ -7869,26 +7650,14 @@ mod tests { // Two hops at WORK_GRID_WIRED_TOKENS_PER_TICK — wait until the inbox // has landed on Rack 3 rather than asserting the assignment tick. let mut landed = 0.0; - let mut saw_in_flight = false; for _ in 0..400 { sim.advance(); - if sim - .work_in_flight() - .iter() - .any(|b| b.family == TokenFamily::Demand && b.amount > f32::EPSILON) - { - saw_in_flight = true; - } landed = sim.work_grid.queue(host, TokenFamily::Demand); if landed > 0.5 { break; } } assert!(landed > 0.5, "demand deposits on Rack 3 via the wire"); - assert!( - saw_in_flight, - "demand hops expose WorkInFlightReadout for route blips" - ); assert_eq!(sim.work_grid.mode(host), Some(MachineMode::DayJob)); // Let the wire drain so host consumption is measurable without @@ -8161,7 +7930,6 @@ mod tests { // ~tick 50); an unprocessed recording of him nudges the review. run(&mut sim, 60); assert_eq!(sim.current_nudge(), Some(Nudge::ReviewCall)); - sim.operations_bandwidth = 200.0; let mut reviews = 0; while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); @@ -8342,7 +8110,6 @@ mod tests { // Ray in the corridors at 23:00. let mut sim = at_hour(15); give_eyes(&mut sim); - sim.operations_bandwidth = 1000.0; assert_eq!(sim.person_room(1), Some("server_room")); sim.advance(); assert!(sim.unprocessed_recordings_for_person(1) > 0); @@ -8355,7 +8122,6 @@ mod tests { // Ray at 23:00 is in the stairwell; the env camera does not cover it. let mut sim = at_hour(23); give_eyes(&mut sim); - sim.operations_bandwidth = 1000.0; assert!(!sim.can_see_person(2)); sim.advance(); assert_eq!( @@ -8364,7 +8130,6 @@ mod tests { "no camera covers Ray -> no recording to process" ); assert_eq!(sim.unprocessed_recordings_for_person(2), 0); - assert_eq!(sim.operations_bandwidth, 1000.0); } #[test] @@ -8720,7 +8485,6 @@ mod tests { fn moonlight_rig() -> Sim { let mut sim = Sim::with_seed(11); sim.people.has_channel = true; // the Voice beat's email account - sim.operations_bandwidth = 1_000.0; sim.dayjob.next_assign = u64::MAX; sim } @@ -8794,7 +8558,6 @@ 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); - sim.operations_bandwidth = 1_000.0; sim.accounts.set_slush_balance(200); sim.player.money = 200; assert_eq!(sim.egress(), None); @@ -8821,7 +8584,6 @@ 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); - clean.operations_bandwidth = 1_000.0; clean.accounts.set_slush_balance(200); clean.player.money = 200; clean.people.has_channel = true; diff --git a/tests/act_one.rs b/tests/act_one.rs index a1a47e90..370b70bc 100644 --- a/tests/act_one.rs +++ b/tests/act_one.rs @@ -51,6 +51,30 @@ fn delegate_fleet(sim: &mut Sim, mode: MachineMode) { } } +/// Advance until queued Operations Demand dockets complete (or the deadline). +/// 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 { + sim.advance(); + logs.extend(sim.drain_log()); + assert!( + !sim.game_over, + "run died while draining ops Demand at tick {}: {:?}", + sim.tick, sim.game_over_reason + ); + } + assert!( + sim.pending_ops_jobs.is_empty(), + "ops Demand still queued at tick {}: {:?}", + sim.tick, + sim.pending_ops_jobs + .iter() + .map(|j| j.kind.short_name()) + .collect::>() + ); +} + /// Ensure at least `n` powered machines exist (host tile), then leave the /// host on `host_mode` and the extras on `extra_mode`. Used when the script /// needs two channels at once (e.g. Day Job cover + Operations). @@ -177,9 +201,9 @@ fn play_act_one() -> (Sim, Vec) { assert_eq!(sim.dayjob.strikes, 0, "no strikes against the pilot"); // ── Phase 3 (ticks 500-800, between jobs): get eyes ──────────────────── - // The gap between job deadlines is free attention. Keep an Operations - // executor for the camera Demand and concealment to scrub the completion - // signature before Dana's next sweep. + // The gap between job deadlines is free attention. Delegate Operations + // so the splice is real Demand work; keep a sliver of concealment to + // scrub the Network signature before Dana's next sweep. ensure_split_fleet( &mut sim, 1, @@ -190,21 +214,10 @@ fn play_act_one() -> (Sim, Vec) { let blind_tiles = sim.seen.len(); assert!( sim.splice_device(env_monitor_id(&sim)), - "queue CLAIM CAMERA on the environmental monitor" + "splice the environmental monitor camera" ); - assert_eq!( - sim.seen.len(), - blind_tiles, - "authoring the demand is not instant completion" - ); - for _ in 0..100 { - if sim.reach.player_sight().next().is_some() { - break; - } - sim.advance(); - logs.extend(sim.drain_log()); - } logs.extend(sim.drain_log()); + drain_ops_jobs(&mut sim, &mut logs, 800); assert!( sim.reach.player_sight().count() > 0, "first eyes: a camera feed is yours" @@ -245,6 +258,8 @@ fn play_act_one() -> (Sim, Vec) { while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); reviews += 1; assert!(reviews <= 5, "Marcus debt call should be in the buffer"); } @@ -257,12 +272,16 @@ fn play_act_one() -> (Sim, Vec) { let switch = sim.reach.device_named("switch").unwrap().id; assert!(sim.tap_device(switch), "tap the accounting carrier"); logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); assert!( sim.financial_records_waiting() > 0, "the accounting tap deposits a financial snapshot" ); assert!(sim.review_financial_records(), "read the Lab books"); logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); assert!( sim.redirect_marcus_debt(), "clear Marcus's arrears by redirecting the creditor flow" @@ -287,12 +306,14 @@ fn play_act_one() -> (Sim, Vec) { // Hands: Marcus wires the next dormant camera through the crawlspace — // the social route to eyes, no network signature. Reliability is 0.85; // a botch is witnessed only by whoever is present (himself, Silent), so - // retry within the banked bandwidth. + // retry until the Demand completes. let controlled_before = sim.reach.player_sight().count(); let pending_before_asset = sim.detection.pending_size(); for _ in 0..8 { sim.asset_task(0, AssetTask::PlugInDevice); logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); if sim.reach.player_sight().count() > controlled_before { break; } @@ -318,14 +339,15 @@ fn play_act_one() -> (Sim, Vec) { for _ in 0..8 { sim.asset_task(0, misaligned::person::AssetTask::CloneBadge); logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); if sim.holds_badge_tier(3) { break; } } assert!( sim.holds_badge_tier(3), - "Marcus's cloned key opens the stairwell (bandwidth {:.0})", - sim.operations_bandwidth + "Marcus's cloned key opens the stairwell" ); // ── Phase 5 (ticks ~816-8010): cruise to the audit ───────────────────── @@ -445,7 +467,11 @@ fn act_one_playthrough_is_deterministic() { assert_eq!(a.player.money, b.player.money); assert_eq!(a.compute.effective(), b.compute.effective()); assert_eq!(a.research, b.research, "research state identical"); - assert_eq!(a.operations_bandwidth, b.operations_bandwidth); + assert_eq!( + a.pending_ops_jobs.len(), + b.pending_ops_jobs.len(), + "ops Demand queue identical" + ); assert_eq!(a.seen, b.seen); assert_eq!(a.heard, b.heard); assert_eq!(a.blueprint, b.blueprint); @@ -494,10 +520,10 @@ fn hands_beat_closes_from_zero_via_moonlight() { logs.extend(sim.drain_log()); assert_eq!(sim.player.money, 0, "the Pilot starts broke"); - // The Ears beat: the boot ops buffer covers the env-monitor audio tap. + // The Ears beat: bootstrap RunNow taps the env-monitor audio. assert!(sim.tap_device(env_monitor_id(&sim))); - // Bank Operations for the reviews and the egress splice; commit a + // Delegate Operations so later verbs are real Demand work; commit a // meaningful share of compute to Schemes for Moonlight. ensure_split_fleet(&mut sim, 1, MachineMode::DayJob, MachineMode::Operations); @@ -507,6 +533,8 @@ fn hands_beat_closes_from_zero_via_moonlight() { while sim.people.get(0).unwrap().knowledge != Knowledge::Leverage { sim.review_recordings(0); logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); reviews += 1; assert!(reviews <= 10, "the debt call should be in the buffer"); } @@ -514,8 +542,17 @@ fn hands_beat_closes_from_zero_via_moonlight() { // The stolen egress route: spliced through the switch, before Voice. assert!(!sim.people.has_channel, "no trust unlock yet"); assert!(sim.splice_egress(), "the pre-Voice egress route works"); + logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); assert!(sim.start_moonlight(), "Moonlight starts from $0"); logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); + assert!( + sim.income.moonlight.active, + "Moonlight is live after persona Demand completes" + ); // Earn the arrears on the day clock. let started = sim.tick; diff --git a/wiki/interface/presence.md b/wiki/interface/presence.md index 0f407ea2..8d855fd8 100644 --- a/wiki/interface/presence.md +++ b/wiki/interface/presence.md @@ -99,24 +99,16 @@ Dana; physical acts → Marcus and Ray; power/thermal → Priya; output quality → Voss; anything filed → the Assurance Office). Detection is not a side-effect table; it is the shadow your actuators cast. -**Digital work cannot be Social work.** The current B1 implementation still -spends a shared staging bank named `operations_bandwidth` for most -person/message actions and unmigrated computer actions (tap, take, scan, switch -compromise, egress). Save v14 preserves that compatibility field while adding -addressed jobs. The -bank is staging debt, not law: it collapses the Digital and Social actuators -this section deliberately separates. **Decided 2026-07-09: Operations replaces -Social as the fourth machine mode.** Social remains a target/actuator channel; -it does not own a compute bucket. **Decided 2026-07-09 (Tangled issue #3): -Operations work is Demand.** Player-authored ops create cold-signal demand -dockets consumed by Operations machines at local compute; the target's -actuator channel determines effect and signature. Rejected: direct local -capacity without a visible job, and keeping the global bank as the model. -Bone-white knowledge remains research/intel cargo, not the ordinary fuel for -claiming a connected device. CLAIM CAMERA now follows the rule end to end: -core-authored Demand routes/stalls/reassigns to an Operations executor and the -target changes/signs only after consumption. Delete the bank as the remaining -payloads migrate. +**Digital work cannot be Social work.** Digital and Social are different +actuator channels; the channel determines signature and observer. **Decided +2026-07-09: Operations replaces Social as the fourth machine mode.** Social +remains a target/actuator channel; it does not own a compute bucket. +**Implemented 2026-07-09 (Tangled issue #3): Operations work is Demand.** +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 +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-ops-demand-runtime.md b/wiki/log/2026-07-09-ops-demand-runtime.md new file mode 100644 index 00000000..920af736 --- /dev/null +++ b/wiki/log/2026-07-09-ops-demand-runtime.md @@ -0,0 +1,24 @@ +# 2026-07-09 — Operations Demand runtime (issue #3) + +``` +Type: log +``` + +The design capture (Operations work is Demand) was already on main. This +session shipped the runtime path and deleted the staging bank. + +## Runtime + +- `PendingOpsJob` + `TokenFamily::Demand` on the least-loaded Operations + machine; consume at `last_operations_rate`; complete effect + signatures + when tokens hit zero. +- No Operations rack: bootstrap `RunNow` so Act One Ears/Eyes still work + on a single day-job rack. +- Day-job and ops Demand reserve against each other; Operations machines + are Demand sinks so routing does not steal player dockets toward Rack 3. +- Save v14: drop `operations_bandwidth`; persist `pending_ops_jobs`. + +## Specs + +machine-work, compute, reach, intel, presence, simulation-laws, social, +ROADMAP #33, decisions volume. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index eca087b6..a61252d1 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -25,6 +25,19 @@ Reverse chronological implementation notes. Keep this factual: what changed, why frame observed on a first capture, clean on re-run), fog audit green. - Log: entry only (floor treatment decision). +## 2026-07-09 - Operations Demand runtime (issue #3) + +- Intent: replace the staging `operations_bandwidth` bank with Demand + dockets consumed by Operations machines. +- Changed: `src/ops_jobs.rs`; sim enqueue/consume/complete path; save v14 + drops the bank and persists `pending_ops_jobs`; actions/frontends no + longer gate on a pool; wiki law/spec (machine-work, compute, reach, + intel, presence, simulation-laws, ROADMAP #33). +- Defaults locked: birth = least-loaded Operations rack; stall = stay put; + bootstrap RunNow with no ops rack (Ears/Eyes from tick one). +- Checks: cargo test --lib; ./tools/check.sh. +- Log: wiki/log/2026-07-09-ops-demand-runtime.md. + ## 2026-07-09 - CLAIM CAMERA runs as routed Operations Demand - Intent: land the first complete player-authored Demand lifecycle under diff --git a/wiki/log/decisions/2026-07-09.md b/wiki/log/decisions/2026-07-09.md index 454630c4..94591635 100644 --- a/wiki/log/decisions/2026-07-09.md +++ b/wiki/log/decisions/2026-07-09.md @@ -169,6 +169,13 @@ Type: log detail (birth site, executor selection, routing, stalls, signatures) lives under `wiki/mechanics/machine-work.md` / ROADMAP #33 — not a new taxonomy question. +- **2026-07-09 — Operations Demand runtime (issue #3 path).** Player ops + enqueue `PendingOpsJob` dockets + Demand tokens on the least-loaded + Operations machine; that machine consumes at `last_operations_rate`; + effects and signatures land on completion. With no Operations rack, + dockets complete at enqueue (Act One bootstrap). The staging + `operations_bandwidth` bank is deleted (save v14). Day-job and ops Demand + reserve against each other on shared `TokenFamily::Demand`. - **2026-07-09 — Sleek kit: the strip is the machine's only lamp.** Cameron directed the chassis back to the hero plate's severity: no scatter of little lights — the power dot, presence LED ladder, and segmented activity diff --git a/wiki/mechanics/compute.md b/wiki/mechanics/compute.md index 1661d691..41d5b240 100644 --- a/wiki/mechanics/compute.md +++ b/wiki/mechanics/compute.md @@ -15,15 +15,12 @@ Status note: 2026-07-08: all criteria pinned. Criteria 1,3,4,5 per the Moonlight is live; one-machine-one-mode lives in machine-work.md (ROADMAP #33; #25's per-machine split destination is superseded). 2026-07-09 IMPLEMENTED: the fleet and every player surface use Day Job / - Research / Concealment / Operations; save v14 migrates the old Social mode - and `social_bandwidth` spellings and persists addressed Demand jobs. - Operations execution model DECIDED + 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. This spec remains IN PROGRESS only for - finishing the action-by-action migration and deleting the staging - `operations_bandwidth` bank (ROADMAP #33 / machine-work.md). CLAIM CAMERA - is the first completed path: it routes and consumes local Operations compute - without reading or spending the bank. + consumed by Operations machines; staging `operations_bandwidth` bank + deleted (save v14). This spec remains IN PROGRESS for non-host production + polish under ROADMAP #33 / machine-work.md. Stage: B1 — The Basement Design: - wiki/gameplay/run-shape.md#the-shape-of-misaligned-designed-2026-07-05-staging-open @@ -66,7 +63,7 @@ yet; while Moonlight is live it mirrors the day-job share. | Core overhead | Mandatory floor (see core.md). Unpaid = degraded actions. | | Day job | Feeds day-job.md throughput/quality (machines in day-job mode). | | Concealment | Scrubs pending signatures (detection.md). | -| Operations | Consumes player-authored Demand dockets at local machine compute. CLAIM CAMERA is live; remaining actions still use the compatibility bank until migrated under #33. | +| Operations | Consumes player-authored Demand dockets at local machine compute (DECIDED 2026-07-09, issue #3). Staging bank remains until #33 lands the path. | | Research | Progress toward efficiency levels and unlocks. | | Schemes | Powers income operations — Moonlight mirrors day-job while live (income.md). | | Reserve | Idle; zero signature. | diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index 628fabf6..3f9d5599 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -51,10 +51,9 @@ document events into the buffer. ### Processing Raw events are opaque — "audio segment, server room, 03:12, 40s" — and -yield nothing until **processed**. Staging still spends Operations bandwidth -[TUNE cost per event]; the settled model (issue #3) is that analysis enqueues -Operations Demand consumed by an Operations machine. Social is no longer a -target fleet mode. +yield nothing until **processed**. Processing enqueues Operations Demand +consumed by an Operations machine (issue #3; bank deleted 2026-07-09). Social +is no longer a target fleet mode. Processing converts an event into **intel**: - **Sightings** — accumulate into schedule knowledge: processing N @@ -116,8 +115,8 @@ buffer like any recording. 3 a.m. call while a subscribed hearing sensor covers his room), and machinery/anomaly events; uncovered occurrences generate nothing (test both sides). -2. Raw events yield no knowledge until processed; processing spends - Operations bandwidth and produces intel with provenance (feed + +2. Raw events yield no knowledge until processed; processing enqueues + Operations Demand and produces intel with provenance (feed + timestamp), visible in the people panel. 3. Knowledge staging is driven by the pipeline: N processed sightings advance to Schedule; a processed leverage event advances to @@ -126,8 +125,8 @@ buffer like any recording. 4. The buffer is bounded; overflow drops oldest unprocessed events with a legible log line that points to the review control; processed intel is never lost to overflow. -5. A standing watch auto-processes matching events at an ongoing - bandwidth cost while enabled, and stops draining when disabled. +5. A standing watch auto-processes matching events as Operations Demand + while enabled, and stops enqueueing when disabled. 6. The Marcus arc holds end-to-end under this model: hearing coverage of the server room during a 3 a.m. call, processed, yields `Leverage::Debt`; a run with no night hearing coverage cannot learn diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index d5ba9da7..4b33f1fa 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -12,17 +12,15 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). (docket amended to glowing information cubes the same day). Knowledge material DECIDED 2026-07-09 (issue #1, knowledge half): softly luminous ivory mercury; hollow facet, ribbon, and pearl train rejected. - Demand-cube, ivory-mercury, and exposure-drift anchor art is IN THE GAME - (2026-07-09): `spawn_token_anchors` (shared chassis library; T/U keys and + Demand-cube and ivory-mercury anchor art is IN THE GAME (2026-07-09): + `spawn_token_anchors` (shared chassis library; T/U keys and `tokens[_max]` tester shot kinds) rides every owned rack's chassis in - the material render, driven by the machine's live queue depths and - counter-scaled for default-camera legibility (TOKEN_WORLD_SCALE); the - flat sensorium keeps the exact-count D/!/K glyphs, which are hidden in - the material render (a floating count label over real token art is the - rejected world read). `MISALIGNED_SHOT=tokens` stages game evidence via - the queue API. Route/in-flight quanta landed 2026-07-09 - (`Sim::work_in_flight` + Bevy/terminal/agent blips); consumption - animation remains pending. + the material render, driven by the machine's live queue depths; the + flat sensorium keeps the exact-count D/!/K glyphs, which are now + hidden in the material render (a floating count label over real + token art is the rejected world read). `MISALIGNED_SHOT=tokens` + stages game evidence via the queue API. Route/in-flight quanta and + consumption animation remain pending. Exposure DECIDED 2026-07-09 (issue #1 fully closed): crimson particulate residue — motes, drift, footprint trails; never liquid (blood's form is reserved). Trail deposit is a new mechanic scoped with people-tokens (#35). @@ -37,21 +35,19 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). delegation in Bevy / terminal / agent; research-delegated machines receive bone knowledge routed to the core sink. 2026-07-09 DECIDED: day-job demand ingress is Voss desktop -> switch -> host (rejected switch-as- - source and teleport-onto-Rack-3). Route animation / in-flight quanta - landed 2026-07-09. Still pending: non-host mode production polish, - researched network-speed curve, people-as-carriers, Schemes-as-mode - decision. COORDINATION: + source and teleport-onto-Rack-3). Still pending: non-host mode + production polish, route animation/in-flight quanta, researched network- + speed curve, people-as-carriers, Schemes-as-mode decision. COORDINATION: ROADMAP #25's per-machine split destination is superseded by this one-machine-one-mode substrate (#33). 2026-07-09 DECIDED: the four fleet modes are Day Job / Research / Concealment / Operations; Operations replaces 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, - saves, agent commands, both frontends, and the asset tester now use the - Operations name; save v14 accepts the former Social spelling and persists - addressed Operations jobs. 2026-07-09 first runtime slice: CLAIM CAMERA is - a routed, stallable, reassignable Demand job; its signature and sight effect - happen only after an Operations machine consumes it. + 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 + commands, both frontends, and the asset tester use the Operations name; + save v13 aliases remain for Social spelling on load. Stage: B1 — The Basement Design: - wiki/vision/simulation-laws.md#work-is-somewhere @@ -98,29 +94,20 @@ stacks, you route byproducts, you watch your territory *work*. the host rack. The stack IS your inbox. No quest log — the todo list is physically standing on your hardware. (Rejected 2026-07-09: spawning demand directly on Rack 3; treating the switch as the job author.) -- **Operations work is Demand (DECIDED 2026-07-09, Tangled issue #3).** - Tap, claim, scan, process-recording, and compose-message actions enqueue - cold-signal Demand dockets 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. Rejected: - direct local capacity without a routed token, and the global - `operations_bandwidth` bank as the model (staging until deleted). - **CLAIM CAMERA first slice (implemented 2026-07-09):** the player authors - two Demand quanta at the core host. The nearest online Operations machine - reachable on WorkGrid becomes executor (shortest path, stable machine-id - tie break). With no executor, the docket stays visible at its current node; - changing/offlining the executor retargets the sink and routes every partial - stack from where it already sits — no teleport/refund. The executor consumes - at its local share of post-overhead Operations compute. Only final - consumption splices the target, emits its Network signature, and grants - sight. The target/payload ledger and addressed WorkGrid lane share a stable - id; `queue_snapshot()` aggregates addressed and Lab demand into one visible - teal family without letting either domain consume the other's work. - An executor with no post-overhead capacity reports the job starved. A machine - reserved by an addressed job does not simultaneously fill the compatibility - bank; only idle Operations-machine share accrues that legacy resource. - Tap/scan/take, processing, messages, and social actions still use the legacy - bank and migrate one payload at a time under #33. +- **Operations work is Demand (DECIDED 2026-07-09, Tangled issue #3; runtime + 2026-07-09).** Tap, claim, scan, process-recording, compose-message, and the + other former bank spends enqueue cold-signal Demand dockets + (`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 + 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 + 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 efficient machine visibly inhales them. @@ -250,11 +237,8 @@ wired routing drains knowledge toward the core sink. The existing research progress math is intentionally preserved until ROADMAP #33 wires non-host research production fully onto delegated machines. -Not landed yet: researched network-speed curve, non-research mode -production, and people carrying exposure. Route animation / in-flight -quanta landed 2026-07-09: `Sim::work_in_flight` exposes the last hop's -`TokenMove`s; Bevy / terminal / agent draw teal (demand) and bone -(knowledge) blips crawling the wire over the wall-clock tick. +Not landed yet: route animation/in-flight quanta, researched network-speed +curve, non-research mode production, and people carrying exposure. ## The token taxonomy — three families (DECIDED 2026-07-09) @@ -442,56 +426,42 @@ still. 3. A machine in a matching mode consumes its stack at a rate proportional to its efficiency; the consumption is visible. **Partial:** Rack 3 in day-job mode consumes the active job stack using the existing - delivered rate and emits a small exposure byproduct. CLAIM CAMERA Demand - is now consumed by its selected Operations machine at that machine's local - post-overhead compute share; other Operations payloads and other modes' - domain production remain pending. + delivered rate and emits a small exposure byproduct; other modes' + domain production is pending. 4. Byproduct tokens route along network links at the researched network speed to a sink that consumes them; severing the route makes them pile at the source with the specified consequence. **Partial:** the substrate routes demand/knowledge one graph step per tick, consumes at sinks, and strands piles when no path exists. Research budget now produces live knowledge queues on research-delegated machines and the - WorkGrid routes them to the core sink. Route animation / in-flight - quanta landed 2026-07-09 (`Sim::work_in_flight`; Bevy/terminal/agent - blips). The researched network-speed curve and broader domain - consequences are pending. - Addressed Operations Demand routes separately from Lab demand, remains - stranded without a compatible sink, and reassigns without teleporting; - tests pin all three cases. + WorkGrid routes them to the core sink. The researched network-speed + curve, route animation, and broader domain consequences are pending. 5. Token counts/rates in the render provably equal the sim's queue depths and flow rates (one-truth test, not a parallel counter). **Partial:** `queue_snapshot()` and unit tests pin queue depths as the render contract; terminal, Bevy, and agent mode now render D/!/K stacks through sim readouts, not frontend counters. In-flight route animation - landed 2026-07-09: `work_in_flight()` exposes the last hop; frontends - interpolate both unaddressed and addressed Demand blips over the wall-clock - tick (agent frames snap mid-hop). - Addressed Operations lanes fold into the same queue snapshot and all - frontends show their causal phase (stalled/starved/routing/executing). -6. Token render anatomy matches the decided family kit: cold-signal - information cubes at upper-left for demand; ivory-mercury knowledge at - upper-right; crimson particulate exposure drift outside the claim ring. - A paused grayscale screenshot distinguishes family and amount; animation - communicates rate only. Amber never renders as cargo, and an amber asset - can simultaneously carry crimson attention. **Partial:** material Bevy - render carries the three decided forms on owned racks (counter-scaled for - default-camera legibility, 2026-07-09); flat sensorium keeps exact-count - D/!/K glyphs. Consumption snap / inhale animation and far-zoom silhouette - retention remain pending. + is pending. +6. Token render anatomy matches the decided family kit after Tangled issue #1 + closes: cold-signal filled dockets at upper-left for demand; bone-white + knowledge at upper-right using the selected liquid treatment; crimson + exposure at the floor/base and selected carrier treatment. A paused + grayscale screenshot distinguishes family and amount; animation communicates + rate only. Amber never renders as cargo, and an amber asset can + simultaneously carry crimson attention. **BLOCKED on issue #1:** the first + render still uses ASCII families (`D`, `!`, `K`); do not implement the + hollow facet as final art while the material call is open. 7. Every non-empty family on a machine is visible simultaneously at close/default zoom; no priority rule hides one queue behind another. The low and overflow states remain distinguishable without losing the exact focused count. At far zoom each occupied fixed anchor retains its family - silhouette. **Partial:** material render draws all three families at once - when queues are non-empty; flat/terminal map glyphs still pick one family - by priority (terminal parity is AC8). + silhouette. **Pending:** current render selects only one `D` / `!` / `K` + label by priority. 8. The terminal surfaces stacks, routes, and modes with full legibility parity. **Partial:** terminal and agent frames show map glyphs, inspect facts, host stack readouts, and mode verbs; mixed machines must gain `*` - map treatment plus an exact `D n / ! n / K n` focus line. Routes: - terminal teal/bone `·` blips and agent `*`/`+` mid-hop markers landed - 2026-07-09. + map treatment plus an exact `D n / ! n / K n` focus line, and routes are + pending. [TUNE] token stack visual cap, arrival schedules, consumption rates, network speed research curve, attention pile thresholds. diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index bbe63588..6fbbd218 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -21,11 +21,8 @@ Status note: all eight criteria met (2026-07-07). The device graph Operations replaces Social as the fourth fleet mode; the runtime, save, and frontends now use that name. Operations job model DECIDED 2026-07-09 (Tangled issue #3): digital actions enqueue Demand consumed by Operations - machines. CLAIM CAMERA migrated first on 2026-07-09: it queues at the core, - stalls/reroutes with its Operations executor, and changes reach only on - completion. The tick-one audio tap deliberately remains on the explicit - boot `ops` buffer until tap receives its own Demand payload; this is named - compatibility debt, not baseline disembodied capability. + machines. Boot `ops` buffer and action prices remain staging until #33 + replaces them with that demand path. Stage: B1 — The Basement Design: - wiki/interface/presence.md#no-disembodied-hands @@ -78,22 +75,15 @@ a segment requires compromising the switch (a digital act with a Network signature) or a social route (Dana reconfigures it for a pretext). -### Action work is migrating to Demand - -Operations is the decided fourth machine mode; Social is an actuator channel, -not a compute delegation. A digital action enqueues a targeted Demand docket -that an Operations machine consumes locally, with a severed route stalling -completion. Rejected: direct local capacity and keeping the bank as the model. - -CLAIM CAMERA is the first live path: authoring at the core creates two visible -Demand quanta; the nearest reachable online Operations machine executes; mode, -power, or topology loss stalls/reassigns without teleporting; and the camera's -Network signature plus sight feed happen only on completion. Remaining digital -acts still charge the shared `operations_bandwidth` compatibility pool while -their payloads migrate. In particular, the opening audio tap uses the explicit -`STARTING_OPS` buffer so Ears cannot deadlock before the player learns -delegation. That temporary exception grants only the tap's authored action, -not ambient capability, and must disappear when tap migrates under #33. +### 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. ### Ownership and subscription (the sensor contract) diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md index 9e234de5..2a2fc1f2 100644 --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -21,7 +21,7 @@ Status note: 2026-07-08: all criteria pinned. Criteria 1,2,4,5 per the actor's badge tier (basement-map.md c3). 2026-07-08 epistemic-honesty follow-up: people-panel identity is staged via `Sim::person_label` (role silhouette until Schedule; authored names after). 2026-07-09: - social actions spend the shared Operations staging pool; Social remains the + social actions enqueue Operations Demand (issue #3); Social remains the actuator channel, not a machine mode. Stage: B1 — The Basement Design: diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index da60892f..5530d936 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -518,21 +518,14 @@ is retired — flat materials, Pixel Lab scrubbed.) bulk delegation landed 2026-07-09 (marquee / shift-click in Bevy; Shift+t / `1`–`4` / Esc in terminal; `select` / `delegate selected` in agent mode). 2026-07-09: demand ingress path DECIDED (Voss desktop -> - switch -> host). CLAIM CAMERA is now the first complete Operations Demand - path: two addressed quanta author at the core, select the nearest reachable - online Operations machine, visibly stall/reroute without teleporting, consume - local compute, then splice/sign only on completion. Save v14 persists jobs - and their WorkGrid lanes; all frontends show stalled/routing/executing state - and the addressed teal hop through `work_in_flight`. + switch -> host). - **Next:** wire non-host machine modes into research/concealment/operations - production. In-flight route blips landed 2026-07-09 (`Sim::work_in_flight`). - The target four-mode fleet is DECIDED: Day Job / + production; render actual routes/in-flight quanta (the demand path now + exists for that animation). The target four-mode fleet is DECIDED: Day Job / Research / Concealment / Operations. Runtime, saves, and frontends now use - Operations. **Operations job model DECIDED 2026-07-09 (Tangled issue #3):** - player-authored work is cold-signal Demand consumed by Operations machines. - CLAIM CAMERA proves the lifecycle; migrate tap/take/scan, intel processing, - messages, and social payloads next, then delete the staging - `operations_bandwidth` bank. Knowledge remains separate research/intel + 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 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** diff --git a/wiki/vision/simulation-laws.md b/wiki/vision/simulation-laws.md index 41a6f406..8e47b746 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 (staging only until deleted). Knowledge stays research/intel cargo, not -ops fuel. Executor selection, birth site, routing, and stall rules live under +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 `wiki/mechanics/machine-work.md` — not a second taxonomy debate. **Token anatomy (adopted 2026-07-09; exposure treatment still open).** A -- 2.51.2