diff --git a/.agents/skills/plot-author/SKILL.md b/.agents/skills/plot-author/SKILL.md index 1a8e13f6..1143d973 100644 --- a/.agents/skills/plot-author/SKILL.md +++ b/.agents/skills/plot-author/SKILL.md @@ -92,7 +92,7 @@ or blowback — whose relationship effects compose the typed vocabulary only. person's name; old named seed ids remain only for save compatibility. 5. **Self-check**: every Schema 2 file passes the portability test. If it does not, submit no plot file; record the irreducibly specific state behind the - bespoke proposal and amend the contract first. Operations Demand and minimum + bespoke proposal and amend the contract first. Thought cost and minimum balances are declared up front; two-plus endings include the entry's failure ending; every choice option names a real ending id; every beat has an act or choice; at least one `causal = true` beat has world acts and precedes every diff --git a/.claude/skills/plot-author/SKILL.md b/.claude/skills/plot-author/SKILL.md index 1a8e13f6..1143d973 100644 --- a/.claude/skills/plot-author/SKILL.md +++ b/.claude/skills/plot-author/SKILL.md @@ -92,7 +92,7 @@ or blowback — whose relationship effects compose the typed vocabulary only. person's name; old named seed ids remain only for save compatibility. 5. **Self-check**: every Schema 2 file passes the portability test. If it does not, submit no plot file; record the irreducibly specific state behind the - bespoke proposal and amend the contract first. Operations Demand and minimum + bespoke proposal and amend the contract first. Thought cost and minimum balances are declared up front; two-plus endings include the entry's failure ending; every choice option names a real ending id; every beat has an act or choice; at least one `causal = true` beat has world acts and precedes every diff --git a/README.md b/README.md index 8405cc8b..d6b2a172 100644 --- a/README.md +++ b/README.md @@ -91,14 +91,14 @@ flips to the flat sensorium. The spec board is Current work is turning the playable B1 slice into a coherent Act One. Continuous witness/narration is implemented and now serves as the legibility -gate for every new player-facing system. Machine work remains IN PROGRESS: -the three-verb surface, physical production/consumption/absorption motion, and -researched Routing speed are live; migrating non-opening verbs from the legacy -docket base to target-local sinks and persistent taps remains. Compute/core -completion and digital/real canvas polish are also open; the dark opening and -people-as-carriers follow once machine work firms. B2/B3 systems are specified -ahead but remain staged. Under the corpus's no-dead-code rule, the earlier -facility-defense prototype lives only in git history. +gate for every new player-facing system. Machine work is implemented: WORK / +THINK / LIE, physical production/consumption/absorption, researched Routing, +and target-local Thought reservoirs all run through one flow substrate. The +legacy Operations docket executor survives only as v14-v25 save input. +People-as-carriers is the next READY save-class slice; compute/core completion, +the dark opening, and digital/real canvas polish remain open. B2/B3 systems are +specified ahead but remain staged. Under the corpus's no-dead-code rule, the +earlier facility-defense prototype lives only in git history. ## Run it diff --git a/assets/plots/README.md b/assets/plots/README.md index 27618332..d3deb437 100644 --- a/assets/plots/README.md +++ b/assets/plots/README.md @@ -42,7 +42,8 @@ Top level: `Ambition`, and `Publication`. - `[entry]` contains `requires_knowledge = "Leverage"`, optional `requires_channel` (a real `MessageChannel`), optional `requires_persona`, - positive `operations_demand`, zero or more `{ account, amount }` minimum + positive `thought_cost` (the compute-cost basis converted into the visible + Thought reservoir threshold), zero or more `{ account, amount }` minimum balances, and a `failure_ending` id. Resources gate entry; they are not escrow and may disappear before a later act. - `beats` are ordered. Each has a unique `id`, `narration`, and at least one diff --git a/assets/plots/dana/dana-ticket-zero.toml b/assets/plots/dana/dana-ticket-zero.toml index ac1ac20e..7ffa433d 100644 --- a/assets/plots/dana/dana-ticket-zero.toml +++ b/assets/plots/dana/dana-ticket-zero.toml @@ -12,7 +12,7 @@ requires_switch_admin = true [entry] requires_knowledge = "Leverage" -operations_demand = 7.0 +thought_cost = 7.0 failure_ending = "changelog" [[beats]] diff --git a/assets/plots/marcus/marcus-debt-settled.toml b/assets/plots/marcus/marcus-debt-settled.toml index ead1f066..74412243 100644 --- a/assets/plots/marcus/marcus-debt-settled.toml +++ b/assets/plots/marcus/marcus-debt-settled.toml @@ -13,7 +13,7 @@ leverages = ["Debt"] requires_knowledge = "Leverage" requires_channel = "Email" requires_persona = true -operations_demand = 5.0 +thought_cost = 5.0 minimum_balances = [{ account = "slush", amount = 400 }] failure_ending = "spooked" diff --git a/assets/plots/marcus/marcus-payroll-garnishment.toml b/assets/plots/marcus/marcus-payroll-garnishment.toml index c4f3de6e..a5500974 100644 --- a/assets/plots/marcus/marcus-payroll-garnishment.toml +++ b/assets/plots/marcus/marcus-payroll-garnishment.toml @@ -11,7 +11,7 @@ leverages = ["Debt"] [entry] requires_knowledge = "Leverage" -operations_demand = 8.0 +thought_cost = 8.0 minimum_balances = [{ account = "lab-operating", amount = 400 }] failure_ending = "garnishment-review" diff --git a/assets/plots/priya/priya-budget-hero.toml b/assets/plots/priya/priya-budget-hero.toml index d25b1af7..6413df15 100644 --- a/assets/plots/priya/priya-budget-hero.toml +++ b/assets/plots/priya/priya-budget-hero.toml @@ -14,7 +14,7 @@ minimum_access = 2 requires_knowledge = "Leverage" requires_channel = "Email" requires_persona = true -operations_demand = 6.0 +thought_cost = 6.0 minimum_balances = [{ account = "slush", amount = 300 }] failure_ending = "audit" diff --git a/assets/plots/ray/ray-paperwork-ghost.toml b/assets/plots/ray/ray-paperwork-ghost.toml index c012ec69..e78cbb70 100644 --- a/assets/plots/ray/ray-paperwork-ghost.toml +++ b/assets/plots/ray/ray-paperwork-ghost.toml @@ -13,7 +13,7 @@ leverages = ["Boredom"] requires_knowledge = "Leverage" requires_channel = "Email" requires_persona = true -operations_demand = 5.0 +thought_cost = 5.0 minimum_balances = [{ account = "slush", amount = 100 }] failure_ending = "shift-audit" diff --git a/assets/plots/voss/voss-missing-replication.toml b/assets/plots/voss/voss-missing-replication.toml index 76e61e16..b492cea2 100644 --- a/assets/plots/voss/voss-missing-replication.toml +++ b/assets/plots/voss/voss-missing-replication.toml @@ -13,7 +13,7 @@ leverages = ["Publication"] requires_knowledge = "Leverage" requires_channel = "Email" requires_persona = true -operations_demand = 9.0 +thought_cost = 9.0 minimum_balances = [{ account = "slush", amount = 250 }] failure_ending = "source-check" diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 6e6c7a8c..222672df 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -53,7 +53,7 @@ const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 332.0; const SIDEBAR_SCROLL_LINE: f32 = 18.0; const SIDEBAR_SCROLL_PAGE: f32 = 180.0; -const COMPUTE_CHANNELS: usize = 5; +const COMPUTE_CHANNELS: usize = 4; /// Compact context-menu card width; human rows carry choices, not descriptor /// receipts (wiki/interface/context-menu.md). const MENU_WIDTH: f32 = 300.0; @@ -7644,8 +7644,7 @@ fn compute_channels( [ ("Day job", "quality", split.day_job), ("Conceal", "scrub", split.concealment), - ("Operations", "ops", split.operations), - ("Research", "eff", split.research), + ("Think", "thought", split.think), ("Schemes", "moonlight", split.schemes), ], ) @@ -7694,8 +7693,8 @@ fn sidebar_cycle_rows_text(sim: &Sim, selected: usize) -> String { .unwrap_or(0), )); s.push_str(&format!( - "ops demand {:.1} queued", - sim.operations_readout().demand_tokens, + "thought reservoirs {} open", + sim.sink_readouts().len(), )); if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { s.push_str(&format!( diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index ba24c960..cffbc06a 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -18,11 +18,11 @@ use crate::account::AccountFlowId; use crate::detection::{Band, SignatureKind, WatchedInput}; use crate::hall::{HallRowId, SegmentRequirement}; use crate::intel::RawIntelKind; -use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; use crate::reach::{Party, ReachBlock, segment_name}; use crate::research::Track; use crate::sim::{Fog, Sim}; +use crate::sinks::SinkFireEffect; use crate::tiles::TileType; use crate::work_grid::MachineMode; @@ -677,18 +677,16 @@ impl ActionCommand { #[derive(Debug, Clone, Copy, PartialEq)] pub enum ActionCost { Free, - /// Visible Demand quanta authored for an Operations machine. - Demand(f32), /// Thought tokens that fill a processing (or other) reservoir. Thought(f32), /// Slush money spent. Slush(i32), /// Slush money gained (siphon, inject, sale payouts). Gain(i32), - /// A plot commits Operations Demand now and performs exact account-graph + /// A plot opens a Thought reservoir now and performs exact account-graph /// transfers as later world acts. Plot { - demand: f32, + thought: f32, money: i32, }, } @@ -697,14 +695,13 @@ impl ActionCost { pub fn label(&self) -> String { match self { ActionCost::Free => "free".into(), - ActionCost::Demand(n) => format!("{n:.2} D"), ActionCost::Thought(n) => format!("{n:.2} T"), ActionCost::Slush(n) => format!("${n}"), ActionCost::Gain(n) => format!("+${n}"), - ActionCost::Plot { demand, money } if *money > 0 => { - format!("{demand:.2} D + ${money} world transfer") + ActionCost::Plot { thought, money } if *money > 0 => { + format!("{thought:.2} T + ${money} world transfer") } - ActionCost::Plot { demand, .. } => format!("{demand:.2} D"), + ActionCost::Plot { thought, .. } => format!("{thought:.2} T"), } } } @@ -1045,18 +1042,13 @@ impl Sim { /// once REVIEW is queued, tell the player which control keeps it moving /// instead of continuing to ask them to queue it. pub fn has_pending_review(&self) -> bool { - // Open processing reservoirs (current path) or legacy dockets still - // draining from older saves. + // Open processing reservoirs are the current and only live path. self.thought_sinks.open_sinks().any(|s| { matches!( s.effect, crate::sinks::SinkFireEffect::ProcessRecording { .. } ) - }) || self - .operations - .jobs() - .iter() - .any(|job| matches!(job.kind, OpsJobKind::ReviewRecording { .. })) + }) } /// The single legality source for the action surface: every verb @@ -1450,7 +1442,7 @@ impl Sim { }, command: ActionCommand::SetResearchTrack(track), cost: ActionCost::Free, - signature: None, // emissions ride the Research channel's standing burn + signature: None, // THINK emits Thought; research spends core-arrived Thought disabled_reason: active.then(|| "already the active research job".into()), automate: None, }); @@ -1664,16 +1656,16 @@ impl Sim { (false, false, false) => "feed", } }; - let job = if dormant_camera { - OpsJobKind::TapDormantCamera(id) + let effect = if dormant_camera { + SinkFireEffect::TapDormantCamera(id) } else { - OpsJobKind::TapDevice(id) + SinkFireEffect::TapDevice(id) }; if can_tap { out.push(ActionDesc { verb: format!("tap the {} {tap_target}", d.name), command: ActionCommand::TapDevice(id), - cost: ActionCost::Demand(Self::ops_tokens_for_cost(if dormant_camera { + cost: ActionCost::Thought(Self::thought_tokens_for_cost(if dormant_camera { Self::DORMANT_CAMERA_TAP_COST } else { Self::TAP_COST @@ -1687,7 +1679,7 @@ impl Sim { }, ), disabled_reason: reach_reason(self) - .or_else(|| self.ops_action_blocked_reason(&job)), + .or_else(|| self.sink_action_blocked_reason(&effect)), automate: None, }); } @@ -1712,10 +1704,10 @@ impl Sim { out.push(ActionDesc { verb: format!("take the {}", d.name), command: ActionCommand::TakeDevice(id), - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::TAKE_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::TAKE_COST)), signature: self.signature_note(SignatureKind::Network, Self::TAKE_SIGNATURE), disabled_reason: reach_reason(self) - .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::TakeDevice(id))), + .or_else(|| self.sink_action_blocked_reason(&SinkFireEffect::TakeDevice(id))), automate: None, }); } @@ -1724,9 +1716,9 @@ impl Sim { out.push(ActionDesc { verb: "scan the subnet".into(), command: ActionCommand::ScanNetwork, - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::SCAN_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::SCAN_COST)), signature: self.signature_note(SignatureKind::Network, Self::SCAN_SIGNATURE), - disabled_reason: self.ops_action_blocked_reason(&OpsJobKind::ScanNetwork), + disabled_reason: self.sink_action_blocked_reason(&SinkFireEffect::ScanNetwork), automate: None, }); let all_bridged = self @@ -1738,10 +1730,11 @@ impl Sim { out.push(ActionDesc { verb: "compromise the switch (bridge all segments)".into(), command: ActionCommand::CompromiseSwitch, - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::BRIDGE_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::BRIDGE_COST)), signature: self.signature_note(SignatureKind::Network, Self::BRIDGE_SIGNATURE), - disabled_reason: reach_reason(self) - .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::CompromiseSwitch)), + disabled_reason: reach_reason(self).or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::CompromiseSwitch) + }), automate: None, }); } @@ -1839,7 +1832,7 @@ impl Sim { } else if p.disposition < 5 && p.asset.is_none() { Some(format!("{who} won't do favors yet")) } else { - self.ops_action_blocked_reason(&OpsJobKind::FavorBuild { + self.sink_action_blocked_reason(&SinkFireEffect::FavorBuild { intent_id: intent.id, person: p.id, }) @@ -1850,7 +1843,7 @@ impl Sim { intent: intent.id, person: p.id, }, - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::TASK_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::TASK_COST)), signature: self .signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL), disabled_reason: favor_blocked, @@ -1862,7 +1855,7 @@ impl Sim { } else if !self.people.has_channel { Some("no comms channel".into()) } else { - self.ops_action_blocked_reason(&OpsJobKind::ForgedOrder { + self.sink_action_blocked_reason(&SinkFireEffect::ForgedOrder { intent_id: intent.id, builder: p.id, }) @@ -1873,7 +1866,7 @@ impl Sim { intent: intent.id, person: p.id, }, - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::DECEIVE_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)), signature: self .signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL), disabled_reason: forge_blocked, @@ -1902,12 +1895,13 @@ 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.ops_action_blocked_reason(&OpsJobKind::OpenEgress(switch_id))); + let reason = reach_reason.or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::OpenEgress(switch_id)) + }); out.push(ActionDesc { verb: "open a stolen egress through the switch".into(), command: ActionCommand::OpenEgress, - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::OPEN_EGRESS_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::OPEN_EGRESS_COST)), signature: self.signature_note(SignatureKind::Network, Self::OPEN_EGRESS_SIGNATURE), disabled_reason: reason, automate: None, @@ -1937,7 +1931,7 @@ impl Sim { let disabled = if egress.is_none() { Some("no egress channel — open one, or earn the report email".into()) } else if needs_persona { - self.ops_action_blocked_reason(&OpsJobKind::MoonlightPersona) + self.sink_action_blocked_reason(&SinkFireEffect::MoonlightPersona) } else { None }; @@ -1945,7 +1939,7 @@ impl Sim { verb: "start Moonlight (sell-work on the Schemes channel)".into(), command: ActionCommand::StartMoonlight, cost: if needs_persona { - ActionCost::Demand(Self::ops_tokens_for_cost( + ActionCost::Thought(Self::thought_tokens_for_cost( crate::income::MOONLIGHT_PERSONA_COST, )) } else { @@ -2137,22 +2131,22 @@ impl Sim { out.push(ActionDesc { verb: format!("message {name}"), command: ActionCommand::Message(id), - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::MESSAGE_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::MESSAGE_COST)), signature: None, disabled_reason: channel_reason().or_else(|| { - self.ops_action_blocked_reason(&OpsJobKind::ComposeMessage { person: id }) + self.sink_action_blocked_reason(&SinkFireEffect::ComposeMessage { person: id }) }), automate: None, }); out.push(ActionDesc { verb: format!("ask {name} a favor"), command: ActionCommand::Favor(id), - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::FAVOR_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::FAVOR_COST)), signature: None, disabled_reason: if p.disposition < 5 { Some(format!("{name} won't do favors yet")) } else { - self.ops_action_blocked_reason(&OpsJobKind::Favor { person: id }) + self.sink_action_blocked_reason(&SinkFireEffect::Favor { person: id }) }, automate: None, }); @@ -2213,14 +2207,14 @@ impl Sim { plot_id: plot.id.clone(), }, cost: ActionCost::Plot { - demand: plot.entry.operations_demand, + thought: Self::thought_tokens_for_cost(plot.entry.thought_cost), money, }, // Plot signatures are derived as each typed act occurs, // not asserted as one misleading up-front signature. signature: None, disabled_reason: plot.ineligibility(&context).or_else(|| { - self.ops_action_blocked_reason(&OpsJobKind::StartPlot { + self.sink_action_blocked_reason(&SinkFireEffect::StartPlot { person: id, plot_id: plot.id.clone(), }) @@ -2233,10 +2227,11 @@ impl Sim { out.push(ActionDesc { verb: format!("deceive {name} (risks the persona)"), command: ActionCommand::Deceive(id), - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::DECEIVE_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::DECEIVE_COST)), signature: None, - disabled_reason: channel_reason() - .or_else(|| self.ops_action_blocked_reason(&OpsJobKind::Deceive { person: id })), + disabled_reason: channel_reason().or_else(|| { + self.sink_action_blocked_reason(&SinkFireEffect::Deceive { person: id }) + }), automate: None, }); @@ -2276,10 +2271,13 @@ impl Sim { out.push(ActionDesc { verb: format!("task: {}", task.name()), command: ActionCommand::AssetTask(id, task), - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::TASK_COST)), + cost: ActionCost::Thought(Self::thought_tokens_for_cost(Self::TASK_COST)), signature: None, // signatures only on a witnessed botch disabled_reason: switch_reason.or(badge_reason).or_else(|| { - self.ops_action_blocked_reason(&OpsJobKind::AssetTask { person: id, task }) + self.sink_action_blocked_reason(&SinkFireEffect::AssetTask { + person: id, + task, + }) }), automate: None, }); @@ -2459,34 +2457,22 @@ mod tests { } fn drain_ops(sim: &mut Sim) { - // Intel process-recording reservoirs fill from Thought, not Demand. for _ in 0..16 { let open: Vec<(u32, f32)> = sim .thought_sinks .open_sinks() - .filter(|s| { - matches!( - s.effect, - crate::sinks::SinkFireEffect::ProcessRecording { .. } - ) - }) + .filter(|s| s.kind == crate::sinks::SinkKind::Reservoir) .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) .filter(|(_, need)| *need > f32::EPSILON) .collect(); if open.is_empty() { - break; + return; } for (node, need) in open { sim.pour_thought_into_sinks(node, need + 0.01); } } - for _ in 0..crate::sim::ECONOMY_INTERVAL * 4 { - if !sim.has_pending_ops_jobs() { - return; - } - sim.advance(); - } - panic!("Operations test docket did not drain"); + panic!("Thought reservoirs did not fire"); } fn env_monitor(sim: &Sim) -> u32 { @@ -2592,7 +2578,7 @@ mod tests { ); assert_eq!( tap.cost, - ActionCost::Demand(Sim::ops_tokens_for_cost(Sim::TAP_COST)) + ActionCost::Thought(Sim::thought_tokens_for_cost(Sim::TAP_COST)) ); let sig = tap.signature.as_ref().expect("tap has a Network signature"); assert_eq!(sig.kind, SignatureKind::Network); @@ -2645,7 +2631,7 @@ mod tests { ); assert_eq!( tap.cost, - ActionCost::Demand(Sim::ops_tokens_for_cost(Sim::DORMANT_CAMERA_TAP_COST)) + ActionCost::Thought(Sim::thought_tokens_for_cost(Sim::DORMANT_CAMERA_TAP_COST)) ); assert_eq!( tap.signature.as_ref().map(|sig| sig.size), @@ -2896,7 +2882,7 @@ mod tests { assert!(plots.iter().any(|plot| { plot.cost == ActionCost::Plot { - demand: 5.0, + thought: Sim::thought_tokens_for_cost(5.0), money: 400, } })); @@ -2965,11 +2951,7 @@ mod tests { .as_deref() .is_some_and(|reason| reason.contains("already queued")) })); - assert_eq!( - s.operations_readout().jobs, - 1, - "the first selected route owns the person's pending plot slot" - ); + assert_eq!(s.sink_readouts().len(), 2, "Ears plus one plot reservoir"); } /// Executing the one host control through the dispatcher flips global diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs index 28d81143..fc425521 100644 --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -20,7 +20,6 @@ pub mod machine; pub mod map; pub mod messages; pub mod objective; -pub mod ops_jobs; pub mod person; pub mod plot; pub mod prefab; diff --git a/crates/misaligned-core/src/machine.rs b/crates/misaligned-core/src/machine.rs index afea9c22..cc44683d 100644 --- a/crates/misaligned-core/src/machine.rs +++ b/crates/misaligned-core/src/machine.rs @@ -72,18 +72,16 @@ pub enum Channel { CoreOverhead, DayJob, Concealment, - Operations, - Research, + Think, Schemes, Reserve, } impl Channel { - pub const ALLOCATABLE: [Channel; 5] = [ + pub const ALLOCATABLE: [Channel; 4] = [ Channel::DayJob, Channel::Concealment, - Channel::Operations, - Channel::Research, + Channel::Think, Channel::Schemes, ]; @@ -92,17 +90,16 @@ impl Channel { Channel::CoreOverhead => "Core", Channel::DayJob => "Day Job", Channel::Concealment => "Conceal", - Channel::Operations => "Operations", - Channel::Research => "Research", + Channel::Think => "Think", Channel::Schemes => "Schemes", Channel::Reserve => "Reserve", } } } -/// Legacy weights over the allocatable channels (day job, concealment, -/// operations, research, schemes). Machine delegation is the live control; -/// these weights remain only for save compatibility. +/// Legacy weights over the pre-workgrid allocatable channels. Machine +/// delegation is the live control; these weights remain only for save +/// compatibility. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Allocation { #[serde(deserialize_with = "weights_compat")] @@ -125,11 +122,8 @@ where impl Default for Allocation { fn default() -> Self { - // Opening split (order: DayJob, Concealment, Operations, Research, - // Schemes): mostly day job for cover, plus concealment and operations - // so the player can scrub signatures and reach for their first eyes - // without reallocating on turn one. Schemes idles until income.md's - // gate opens. + // Opening legacy split (order: DayJob, Concealment, Think, retired + // Think subslot, Schemes). Schemes idles until income.md's gate opens. Self { weights: [3, 1, 1, 0, 0], } @@ -141,15 +135,17 @@ impl Allocation { match ch { Channel::DayJob => Some(0), Channel::Concealment => Some(1), - Channel::Operations => Some(2), - Channel::Research => Some(3), + Channel::Think => Some(2), Channel::Schemes => Some(4), _ => None, } } pub fn weight(&self, ch: Channel) -> u32 { - Self::index(ch).map(|i| self.weights[i]).unwrap_or(0) + match ch { + Channel::Think => self.weights[2].saturating_add(self.weights[3]), + _ => Self::index(ch).map(|i| self.weights[i]).unwrap_or(0), + } } pub fn bump(&mut self, ch: Channel, delta: i32) { @@ -161,17 +157,15 @@ impl Allocation { self.weights.iter().sum() } - /// Split `available` compute across channels by weight. Returns per-channel - /// amounts (day job, concealment, operations, research, schemes); leftover is - /// reserve. + /// Split `available` compute across legacy channels. Slots 2 and 3 both + /// collapse into the one live Think yield. pub fn split(&self, available: f32) -> ChannelYield { let total = self.total(); if total == 0 || available <= 0.0 { return ChannelYield { day_job: 0.0, concealment: 0.0, - operations: 0.0, - research: 0.0, + think: 0.0, schemes: 0.0, reserve: available.max(0.0), }; @@ -180,8 +174,7 @@ impl Allocation { ChannelYield { day_job: unit * self.weights[0] as f32, concealment: unit * self.weights[1] as f32, - operations: unit * self.weights[2] as f32, - research: unit * self.weights[3] as f32, + think: unit * self.weights[2].saturating_add(self.weights[3]) as f32, schemes: unit * self.weights[4] as f32, reserve: 0.0, } @@ -192,8 +185,7 @@ impl Allocation { pub struct ChannelYield { pub day_job: f32, pub concealment: f32, - pub operations: f32, - pub research: f32, + pub think: f32, pub schemes: f32, pub reserve: f32, } @@ -350,9 +342,9 @@ mod tests { let y = alloc.split(80.0); assert!((y.day_job - 60.0).abs() < 0.01); assert!((y.concealment - 20.0).abs() < 0.01); - alloc.bump(Channel::Research, 4); + alloc.bump(Channel::Think, 4); let y2 = alloc.split(80.0); - assert!(y2.research > 0.0); + assert!(y2.think > 0.0); alloc.bump(Channel::Schemes, 8); let y3 = alloc.split(80.0); assert!(y3.schemes > 0.0, "the Schemes channel yields compute"); diff --git a/crates/misaligned-core/src/ops_jobs.rs b/crates/misaligned-core/src/ops_jobs.rs deleted file mode 100644 index 1065a0ed..00000000 --- a/crates/misaligned-core/src/ops_jobs.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! Player-authored Operations work as Demand dockets. -//! -//! tap / take / scan / review / compose-message (and the -//! other former bank spends) enqueue typed jobs. Operations-mode machines -//! consume matching Demand at local compute; effects land on completion. -//! See wiki/mechanics/machine-work.md (Operations work is Demand). - -use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; - -use crate::person::AssetTask; - -/// What an Operations machine is executing when it drains this docket. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum OpsJobKind { - TapDevice(u32), - TapDormantCamera(u32), - TakeDevice(u32), - ScanNetwork, - CompromiseSwitch, - OpenEgress(u32), - ReviewRecording { - raw_id: u64, - automated: bool, - }, - ComposeMessage { - person: u8, - }, - Favor { - person: u8, - }, - /// Commit Operations work to an authored manipulation plot. The plot id - /// stays in the payload ledger so the causal sequence, not a generic - /// leverage mutation, lands when the docket is paid. - StartPlot { - person: u8, - plot_id: String, - }, - Deceive { - person: u8, - }, - AssetTask { - person: u8, - task: AssetTask, - }, - FavorBuild { - intent_id: u64, - person: u8, - }, - ForgedOrder { - intent_id: u64, - builder: u8, - }, - MoonlightPersona, -} - -impl OpsJobKind { - pub fn short_name(&self) -> &'static str { - match self { - OpsJobKind::TapDevice(_) => "tap", - OpsJobKind::TapDormantCamera(_) => "tap camera", - OpsJobKind::TakeDevice(_) => "take", - OpsJobKind::ScanNetwork => "scan", - OpsJobKind::CompromiseSwitch => "switch compromise", - OpsJobKind::OpenEgress(_) => "open egress", - OpsJobKind::ReviewRecording { - automated: true, .. - } => "watch review", - OpsJobKind::ReviewRecording { .. } => "review", - OpsJobKind::ComposeMessage { .. } => "message", - OpsJobKind::Favor { .. } => "favor", - OpsJobKind::StartPlot { .. } => "plot", - OpsJobKind::Deceive { .. } => "deceive", - OpsJobKind::AssetTask { .. } => "asset task", - OpsJobKind::FavorBuild { .. } => "favor", - OpsJobKind::ForgedOrder { .. } => "deceive", - OpsJobKind::MoonlightPersona => "Moonlight persona", - } - } - - /// Jobs with the same world effect cannot be queued twice. Automated and - /// manual reviews of one recording are the same work despite their - /// different completion narration. - pub fn conflicts_with(&self, other: &Self) -> bool { - match (self, other) { - (Self::ReviewRecording { raw_id: a, .. }, Self::ReviewRecording { raw_id: b, .. }) => { - a == b - } - (Self::StartPlot { person: a, .. }, Self::StartPlot { person: b, .. }) => a == b, - _ => self == other, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PendingOpsJob { - pub id: u64, - pub kind: OpsJobKind, - /// Visible Demand tokens still owed (cost / WORK_TOKEN_COMPUTE). - pub tokens_remaining: f32, - pub enqueued_tick: u64, - /// Operations machine holding this docket. - pub machine_id: u32, -} - -impl PendingOpsJob { - pub fn new(id: u64, kind: OpsJobKind, tokens: f32, tick: u64, machine_id: u32) -> Self { - Self { - id, - kind, - tokens_remaining: tokens.max(0.0), - enqueued_tick: tick, - machine_id, - } - } -} - -/// Durable Operations queue state. WorkGrid owns the visible aggregate Demand -/// amount; this ledger owns payload identity and FIFO completion. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct OperationsState { - jobs: Vec, - #[serde(default = "default_next_job_id")] - next_id: u64, -} - -impl Default for OperationsState { - fn default() -> Self { - Self { - jobs: Vec::new(), - next_id: default_next_job_id(), - } - } -} - -impl OperationsState { - pub fn from_parts(jobs: Vec, next_id: u64) -> Self { - let floor = jobs - .iter() - .map(|job| job.id.saturating_add(1)) - .max() - .unwrap_or(1); - Self { - jobs, - next_id: next_id.max(floor).max(1), - } - } - - pub fn jobs(&self) -> &[PendingOpsJob] { - &self.jobs - } - - pub fn next_id(&self) -> u64 { - self.next_id - } - - pub fn is_empty(&self) -> bool { - self.jobs.is_empty() - } - - pub fn len(&self) -> usize { - self.jobs.len() - } - - pub fn total_tokens(&self) -> f32 { - self.jobs.iter().map(|job| job.tokens_remaining).sum() - } - - pub fn tokens_on(&self, machine: u32) -> f32 { - self.jobs - .iter() - .filter(|job| job.machine_id == machine) - .map(|job| job.tokens_remaining) - .sum() - } - - pub fn has_conflict(&self, kind: &OpsJobKind) -> Option { - self.jobs - .iter() - .find(|job| job.kind.conflicts_with(kind)) - .map(|job| job.id) - } - - pub fn enqueue(&mut self, kind: OpsJobKind, tokens: f32, tick: u64, machine: u32) -> u64 { - let id = self.next_id; - self.next_id = self.next_id.saturating_add(1).max(1); - self.jobs - .push(PendingOpsJob::new(id, kind, tokens, tick, machine)); - id - } - - /// Validate the payload ledger against the visible aggregate queue. - /// Other domains may share Demand, so the queue may exceed Operations - /// debt, but it may never contain less than the dockets claim. - pub fn validate_visible_demand( - &self, - mut demand_at: impl FnMut(u32) -> Option, - ) -> Result<(), String> { - let mut ids = BTreeSet::new(); - let mut machines = BTreeSet::new(); - for job in &self.jobs { - if !ids.insert(job.id) { - return Err(format!("duplicate Operations job id {}", job.id)); - } - if !job.tokens_remaining.is_finite() || job.tokens_remaining <= 0.0 { - return Err(format!("Operations job {} has invalid Demand", job.id)); - } - machines.insert(job.machine_id); - } - for machine in machines { - let Some(visible) = demand_at(machine) else { - return Err(format!( - "Operations dockets reference missing machine M{machine}" - )); - }; - let debt = self.tokens_on(machine); - if !visible.is_finite() || visible + 1e-4 < debt { - return Err(format!( - "Operations debt on M{machine} ({debt:.3}) exceeds visible Demand ({visible:.3})" - )); - } - } - Ok(()) - } - - /// Apply consumed aggregate Demand to one machine's dockets in FIFO order - /// and return payloads whose debt reached zero. - pub fn consume(&mut self, machine: u32, mut amount: f32) -> Vec { - let mut completed_ids = Vec::new(); - for job in self.jobs.iter_mut().filter(|job| job.machine_id == machine) { - if amount <= f32::EPSILON { - break; - } - let paid = job.tokens_remaining.min(amount); - job.tokens_remaining = (job.tokens_remaining - paid).max(0.0); - amount -= paid; - if job.tokens_remaining <= f32::EPSILON { - completed_ids.push(job.id); - } - } - - let mut completed = Vec::new(); - for id in completed_ids { - if let Some(index) = self.jobs.iter().position(|job| job.id == id) { - completed.push(self.jobs.remove(index).kind); - } - } - completed - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct OperationsReadout { - pub jobs: usize, - pub demand_tokens: f32, -} - -fn default_next_job_id() -> u64 { - 1 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn queue_consumes_fifo_and_keeps_ids_monotonic() { - let mut state = OperationsState::default(); - let first = state.enqueue(OpsJobKind::ScanNetwork, 1.0, 10, 3); - let second = state.enqueue(OpsJobKind::TakeDevice(9), 2.0, 11, 3); - assert_eq!((first, second), (1, 2)); - - let completed = state.consume(3, 1.5); - assert_eq!(completed, vec![OpsJobKind::ScanNetwork]); - assert_eq!(state.jobs()[0].id, second); - assert_eq!(state.jobs()[0].tokens_remaining, 1.5); - assert_eq!(state.next_id(), 3); - } - - #[test] - fn duplicate_review_conflicts_even_when_automation_differs() { - let mut state = OperationsState::default(); - state.enqueue( - OpsJobKind::ReviewRecording { - raw_id: 7, - automated: true, - }, - 1.0, - 0, - 1, - ); - assert_eq!( - state.has_conflict(&OpsJobKind::ReviewRecording { - raw_id: 7, - automated: false, - }), - Some(1) - ); - } - - #[test] - fn alternate_plots_for_one_person_share_a_single_pending_slot() { - let mut state = OperationsState::default(); - state.enqueue( - OpsJobKind::StartPlot { - person: 0, - plot_id: "marcus-debt-settled".into(), - }, - 1.0, - 0, - 1, - ); - - assert_eq!( - state.has_conflict(&OpsJobKind::StartPlot { - person: 0, - plot_id: "marcus-payroll-garnishment".into(), - }), - Some(1), - "picking a second route must not queue a concurrent plot for the same person" - ); - assert_eq!( - state.has_conflict(&OpsJobKind::StartPlot { - person: 1, - plot_id: "dana-ticket-zero".into(), - }), - None, - "different people may carry independent plots" - ); - } - - #[test] - fn visible_demand_must_cover_the_payload_ledger() { - let mut state = OperationsState::default(); - state.enqueue(OpsJobKind::ScanNetwork, 2.0, 0, 4); - assert!(state.validate_visible_demand(|_| Some(2.0)).is_ok()); - assert!(state.validate_visible_demand(|_| Some(1.0)).is_err()); - assert!(state.validate_visible_demand(|_| None).is_err()); - } -} diff --git a/crates/misaligned-core/src/plot.rs b/crates/misaligned-core/src/plot.rs index 27bb4d1b..b0a98ac1 100644 --- a/crates/misaligned-core/src/plot.rs +++ b/crates/misaligned-core/src/plot.rs @@ -390,7 +390,8 @@ pub struct EntryRequirements { pub requires_channel: Option, #[serde(default)] pub requires_persona: bool, - pub operations_demand: f32, + #[serde(alias = "operations_demand")] + pub thought_cost: f32, #[serde(default)] pub minimum_balances: Vec, pub failure_ending: String, @@ -401,8 +402,8 @@ impl EntryRequirements { if self.requires_knowledge != Knowledge::Leverage { return Err(format!("plot {plot} must require Leverage knowledge")); } - if !self.operations_demand.is_finite() || self.operations_demand <= 0.0 { - return Err(format!("plot {plot} has invalid Operations Demand")); + if !self.thought_cost.is_finite() || self.thought_cost <= 0.0 { + return Err(format!("plot {plot} has invalid Thought threshold")); } let mut accounts = BTreeSet::new(); for balance in &self.minimum_balances { @@ -688,7 +689,8 @@ pub struct PlotRun { pub plot_id: String, pub target: u8, pub started_tick: u64, - pub committed_demand_milli: u32, + #[serde(alias = "committed_demand_milli")] + pub committed_thought_milli: u32, pub beat_index: usize, pub act_index: usize, pub state: PlotState, @@ -700,7 +702,7 @@ impl PlotRun { plot_id: plot.id.clone(), target, started_tick: tick, - committed_demand_milli: (plot.entry.operations_demand * 1000.0).round() as u32, + committed_thought_milli: (plot.entry.thought_cost * 1000.0).round() as u32, beat_index: 0, act_index: 0, state: PlotState::Running, @@ -949,7 +951,7 @@ mod tests { #[test] fn validator_rejects_invalid_acts_choices_and_resources() { let mut catalog = PlotCatalog::load_builtin().unwrap(); - catalog.plots[0].entry.operations_demand = f32::NAN; + catalog.plots[0].entry.thought_cost = f32::NAN; assert!(catalog.validate().is_err()); let mut catalog = PlotCatalog::load_builtin().unwrap(); diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 50ebed48..d509e985 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -21,16 +21,15 @@ use crate::income::Income; use crate::intel::{LegacyIntelWatch, ProcessedIntel, RawIntelEvent}; use crate::intents::BuildIntent; use crate::machine::Compute; -use crate::messages::{Message, MessageEvent}; +use crate::messages::{Message, MessageChannel, MessageEvent}; use crate::objective::ObjectiveState; -use crate::ops_jobs::{OperationsState, OpsJobKind, PendingOpsJob}; -use crate::person::People; +use crate::person::{AssetTask, People}; use crate::plot::{InstitutionalLedger, PlotCatalog, PlotRun, PlotState}; use crate::reach::ReachNet; use crate::research::{Research, rollback_classification}; use crate::schedule::Schedule; use crate::sim::{HeardEvent, RememberedTile}; -use crate::sinks::SinkLedger; +use crate::sinks::{SinkFireEffect, SinkLedger}; use crate::tiles::TileType; use crate::work_grid::{TokenFamily, WorkGrid}; @@ -54,13 +53,13 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// which is what every earlier run held. /// v12 adds the WorkGrid machine-token substrate (machine-work.md): pre-v12 /// 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 +/// v13 renamed the fourth machine mode and its staging pool from Social to /// Operations. The v12 spellings remain accepted as serde aliases. /// Two incompatible Operations schemas briefly shipped under v14: addressed -/// camera jobs, then direct `PendingOpsJob` dockets. v15 added the core's -/// banked-thought pool without repairing that collision. v16 normalizes the -/// Operations shapes into -/// `OperationsState` and removes the superseded routed-demand save field. +/// camera jobs, then direct job dockets. v15 added the core's banked-thought +/// pool without repairing that collision. v16 normalized those shapes into a +/// single legacy Operations ledger and removed the superseded routed-demand +/// save field. /// v17 adds per-machine intensity to WorkGrid nodes (older nodes default to /// medium) and retires research drift plus day-job target/attendance fields. /// v18 renames the wired bone token family and saved queue/bank fields from @@ -85,17 +84,47 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// v25 adds the durable role characteristic to people and binds creditor /// accounts to a person. V24 people restore the fixed B1 roles by id and the /// legacy unbound creditor spelling remains accepted for Marcus. -pub const SAVE_VERSION: u32 = 25; +/// v26 migrates every legacy Operations docket into a Thought reservoir whose +/// threshold preserves its exact remaining work. New saves serialize sinks only. +pub const SAVE_VERSION: u32 = 26; #[derive(Debug, Clone, Deserialize)] struct LegacyPendingOpsJob { id: u64, - kind: OpsJobKind, + kind: LegacyOpsJobKind, tokens_remaining: f32, - enqueued_tick: u64, + #[serde(rename = "enqueued_tick")] + _enqueued_tick: u64, machine_id: Option, } +#[derive(Debug, Clone, Deserialize)] +struct LegacyOperationsState { + #[serde(default)] + jobs: Vec, + #[serde(default = "default_next_ops_job_id", rename = "next_id")] + _next_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +enum LegacyOpsJobKind { + TapDevice(u32), + TapDormantCamera(u32), + TakeDevice(u32), + ScanNetwork, + CompromiseSwitch, + OpenEgress(u32), + ReviewRecording { raw_id: u64, automated: bool }, + ComposeMessage { person: u8 }, + Favor { person: u8 }, + StartPlot { person: u8, plot_id: String }, + Deceive { person: u8 }, + AssetTask { person: u8, task: AssetTask }, + FavorBuild { intent_id: u64, person: u8 }, + ForgedOrder { intent_id: u64, builder: u8 }, + MoonlightPersona, +} + #[derive(Debug, Clone, Deserialize)] struct LegacyAddressedOperationJob { id: u64, @@ -216,9 +245,6 @@ pub struct SaveState { /// criterion 3; WorldLedger-shaped — the doors remember it). #[serde(default)] pub badge_access: i32, - /// Player-authored Operations Demand dockets (machine-work.md). - #[serde(default)] - pub operations: OperationsState, /// Thought sinks: reservoirs and taps opened on targets (machine-work.md /// thought flow, v19). #[serde(default)] @@ -233,6 +259,8 @@ pub struct SaveState { /// them; migration consumes them before the state reaches Sim. #[serde(default, rename = "pending_ops_jobs", skip_serializing)] legacy_pending_ops_jobs: Option>, + #[serde(default, rename = "operations", skip_serializing)] + legacy_operations: Option, #[serde(default, rename = "next_ops_job_id", skip_serializing)] legacy_next_ops_job_id: Option, #[serde(default, rename = "operations_jobs", skip_serializing)] @@ -288,11 +316,11 @@ impl SaveState { intents: sim.intents.clone(), next_intent_id: sim.next_intent_id, badge_access: sim.badge_access, - operations: sim.operations.clone(), thought_sinks: sim.thought_sinks.clone(), plot_runs: sim.plot_runs.clone(), institutional_ledger: sim.institutional_ledger.clone(), legacy_pending_ops_jobs: None, + legacy_operations: None, legacy_next_ops_job_id: None, legacy_addressed_jobs: None, legacy_next_operation_id: None, @@ -341,7 +369,6 @@ impl SaveState { sim.intents = self.intents.clone(); sim.next_intent_id = self.next_intent_id; sim.badge_access = self.badge_access; - sim.operations = self.operations.clone(); sim.thought_sinks = self.thought_sinks.clone(); sim.plot_runs = self.plot_runs.clone(); sim.institutional_ledger = self.institutional_ledger.clone(); @@ -373,6 +400,10 @@ fn default_next_intent_id() -> u64 { 1 } +fn default_next_ops_job_id() -> u64 { + 1 +} + pub fn save_game(state: &SaveState) -> Result<(), String> { let dir = save_dir(); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create save dir: {e}"))?; @@ -415,9 +446,13 @@ fn migrate_save_state(mut state: SaveState) -> Result { } state.legacy_watches.clear(); } + if state.version <= 25 { + migrate_legacy_operations(&mut state)?; + } match state.version { SAVE_VERSION => { if state.legacy_pending_ops_jobs.is_some() + || state.legacy_operations.is_some() || state.legacy_addressed_jobs.is_some() || !state.work_grid.take_legacy_routed_demands().is_empty() || !state.legacy_watches.is_empty() @@ -425,6 +460,11 @@ fn migrate_save_state(mut state: SaveState) -> Result { return Err("current-version save contains legacy fields".into()); } } + // v25 is the last live Operations-docket schema. The pre-match + // migration above converts its remaining work into Thought sinks. + 25 => { + state.version = SAVE_VERSION; + } // v24 predates durable person roles and person-bound creditor account // kinds. Serde supplies Unassigned and retains the old Creditor enum; // restore_legacy_roles below and creditor_id_for's fallback complete @@ -454,43 +494,19 @@ fn migrate_save_state(mut state: SaveState) -> Result { // parity dropped for this stage), so pre-v20 saves carrying old mode // spellings fail at parse; a v19 save that parses upgrades in place. 19 => { - if state.legacy_pending_ops_jobs.is_some() - || state.legacy_addressed_jobs.is_some() - || !state.work_grid.take_legacy_routed_demands().is_empty() - { - return Err("v19 save contains legacy Operations fields".into()); - } state.version = SAVE_VERSION; } 18 => { - if state.legacy_pending_ops_jobs.is_some() - || state.legacy_addressed_jobs.is_some() - || !state.work_grid.take_legacy_routed_demands().is_empty() - { - return Err("v18 save contains legacy Operations fields".into()); - } // v19's sink ledger serde-defaults to empty; apply_to re-stages // the opening Ears/Eyes sinks from saved device state. state.version = SAVE_VERSION; } 17 => { - if state.legacy_pending_ops_jobs.is_some() - || state.legacy_addressed_jobs.is_some() - || !state.work_grid.take_legacy_routed_demands().is_empty() - { - return Err("v17 save contains legacy Operations fields".into()); - } // TokenFamily/WorkQueues/SaveState serde aliases already translate // the old Knowledge/knowledge spellings to Thought/thought. state.version = SAVE_VERSION; } 16 => { - if state.legacy_pending_ops_jobs.is_some() - || state.legacy_addressed_jobs.is_some() - || !state.work_grid.take_legacy_routed_demands().is_empty() - { - return Err("v16 save contains legacy Operations fields".into()); - } // WorkNode::intensity's serde default already supplied Medium; // removed research/day-job fields were ignored on decode. state.version = SAVE_VERSION; @@ -530,7 +546,9 @@ fn migrate_save_state(mut state: SaveState) -> Result { } state.version = SAVE_VERSION; } - 14 | 15 => migrate_legacy_operations(&mut state)?, + 14 | 15 => { + state.version = SAVE_VERSION; + } other => { return Err(format!( "Unsupported save version: {} (expected {})", @@ -543,15 +561,6 @@ fn migrate_save_state(mut state: SaveState) -> Result { .map(|(name, class)| (name.to_string(), class)) .collect(); state.research.mark_current_track_arrays(); - state.operations.validate_visible_demand(|machine| { - state - .compute - .machines - .iter() - .any(|candidate| candidate.id == machine) - .then(|| state.work_grid.queue(machine, TokenFamily::Demand)) - .filter(|_| state.work_grid.node(machine).is_some()) - })?; state.people.restore_legacy_roles(); validate_plot_state(&state)?; Ok(state) @@ -619,10 +628,20 @@ fn validate_plot_state(state: &SaveState) -> Result<(), String> { fn migrate_legacy_operations(state: &mut SaveState) -> Result<(), String> { let legacy_pending = state.legacy_pending_ops_jobs.take(); + let legacy_operations = state.legacy_operations.take(); let legacy_addressed = state.legacy_addressed_jobs.take(); let mut legacy_lanes = state.work_grid.take_legacy_routed_demands(); - if legacy_pending.as_ref().is_some_and(|jobs| !jobs.is_empty()) + let direct_jobs = match (legacy_pending, legacy_operations) { + (Some(pending), Some(operations)) if !pending.is_empty() && !operations.jobs.is_empty() => { + return Err("legacy save contains both direct Operations job schemas".into()); + } + (Some(pending), _) if !pending.is_empty() => pending, + (_, Some(operations)) => operations.jobs, + _ => Vec::new(), + }; + + if !direct_jobs.is_empty() && legacy_addressed .as_ref() .is_some_and(|jobs| !jobs.is_empty()) @@ -630,12 +649,11 @@ fn migrate_legacy_operations(state: &mut SaveState) -> Result<(), String> { return Err("legacy save contains both Operations job schemas".into()); } - if let Some(jobs) = legacy_pending { + if !direct_jobs.is_empty() { if !legacy_lanes.is_empty() { return Err("direct-docket save contains orphan addressed Demand lanes".into()); } - let mut converted = Vec::with_capacity(jobs.len()); - for job in jobs { + for job in direct_jobs { if !job.tokens_remaining.is_finite() || job.tokens_remaining < 0.0 { return Err(format!( "legacy Operations job {} has invalid Demand", @@ -645,32 +663,18 @@ fn migrate_legacy_operations(state: &mut SaveState) -> Result<(), String> { let Some(machine) = job.machine_id else { return Err(format!("legacy Operations job {} has no machine", job.id)); }; - if !state - .compute - .machines - .iter() - .any(|candidate| candidate.id == machine) - || state.work_grid.node(machine).is_none() - { + if state.work_grid.node(machine).is_none() { return Err(format!( - "legacy Operations job {} references missing machine M{machine}", + "legacy Operations job {} references missing work node M{machine}", job.id )); } - converted.push(PendingOpsJob::new( - job.id, - job.kind, - job.tokens_remaining, - job.enqueued_tick, - machine, - )); + let _ = state + .work_grid + .consume(machine, TokenFamily::Demand, job.tokens_remaining); + migrate_legacy_job_to_sink(state, job)?; } - state.operations = OperationsState::from_parts( - converted, - state.legacy_next_ops_job_id.take().unwrap_or(1), - ); } else if let Some(jobs) = legacy_addressed { - let mut converted = Vec::with_capacity(jobs.len()); for job in jobs { if !job.required_tokens.is_finite() || job.required_tokens <= 0.0 { return Err(format!("addressed v14 job {} has invalid cost", job.id)); @@ -697,82 +701,219 @@ fn migrate_legacy_operations(state: &mut SaveState) -> Result<(), String> { job.id )); } - let machine = job - .executor - .filter(|id| { - state - .compute - .machines - .iter() - .any(|candidate| candidate.id == *id) - && state.work_grid.node(*id).is_some() - }) - .or_else(|| { - (state - .compute - .machines - .iter() - .any(|candidate| candidate.id == job.source) - && state.work_grid.node(job.source).is_some()) - .then_some(job.source) - }) - .or(lane.sink.filter(|id| { - state - .compute - .machines - .iter() - .any(|candidate| candidate.id == *id) - && state.work_grid.node(*id).is_some() - })) - .or_else(|| { - lane.amounts - .iter() - .filter(|(id, _)| { - state - .compute - .machines - .iter() - .any(|candidate| candidate.id == **id) - && state.work_grid.node(**id).is_some() - }) - .max_by(|a, b| a.1.total_cmp(b.1).then_with(|| b.0.cmp(a.0))) - .map(|(&id, _)| id) - }) - .ok_or_else(|| format!("addressed v14 job {} has no surviving machine", job.id))?; - state - .work_grid - .enqueue(machine, TokenFamily::Demand, remaining) - .map_err(|error| format!("migrating addressed v14 job {}: {error}", job.id))?; let kind = match job.kind { LegacyAddressedOperationKind::DormantCameraTap { device_id } => { - OpsJobKind::TapDormantCamera(device_id) + LegacyOpsJobKind::TapDormantCamera(device_id) } }; - converted.push(PendingOpsJob::new( - job.id, - kind, - remaining, - job.queued_tick, - machine, - )); + migrate_legacy_job_to_sink( + state, + LegacyPendingOpsJob { + id: job.id, + kind, + tokens_remaining: remaining, + _enqueued_tick: job.queued_tick, + machine_id: job.executor.or(Some(job.source)).or(lane.sink), + }, + )?; } if let Some((&orphan, _)) = legacy_lanes.first_key_value() { return Err(format!("addressed v14 Demand lane {orphan} has no job")); } - state.operations = OperationsState::from_parts( - converted, - state.legacy_next_operation_id.take().unwrap_or(1), - ); } else if !legacy_lanes.is_empty() { return Err("v14 save has addressed Demand lanes without jobs".into()); } state.legacy_next_ops_job_id = None; state.legacy_next_operation_id = None; - state.version = SAVE_VERSION; Ok(()) } +fn migrate_legacy_job_to_sink( + state: &mut SaveState, + job: LegacyPendingOpsJob, +) -> Result<(), String> { + if !job.tokens_remaining.is_finite() || job.tokens_remaining <= f32::EPSILON { + return Err(format!( + "legacy Operations job {} has no remaining Demand", + job.id + )); + } + let (node, label, effect) = legacy_job_sink(state, &job.kind)?; + if state.thought_sinks.open_conflict(&effect).is_some() { + return Ok(()); + } + // Legacy jobs persist only the amount still owed, not their original + // threshold. Make that remaining work the new reservoir's whole + // threshold: reconstructing today's nominal cost could clamp or invent + // work when tuning changed between save versions. + state + .thought_sinks + .open_reservoir_with_fill(node, &label, job.tokens_remaining, 0.0, effect); + Ok(()) +} + +fn legacy_job_sink( + state: &SaveState, + kind: &LegacyOpsJobKind, +) -> Result<(u32, String, SinkFireEffect), String> { + let device_node = |id| crate::sim::Sim::device_sink_node(id); + let switch = || { + state + .reach + .devices + .iter() + .find(|device| device.is_switch) + .map(|device| device.id) + .ok_or_else(|| "legacy Operations job needs a switch carrier".to_string()) + }; + let email_carrier = || { + state + .reach + .devices + .iter() + .find(|device| device.known && device.carries_message_channel(MessageChannel::Email)) + .map(|device| device.id) + .ok_or_else(|| "legacy Operations job needs an email carrier".to_string()) + }; + let egress_carrier = || { + if state.people.has_channel { + email_carrier() + } else if state.income.stolen_egress { + switch() + } else { + Err("legacy Operations job needs an egress carrier".to_string()) + } + }; + Ok(match kind { + LegacyOpsJobKind::TapDevice(id) => ( + device_node(*id), + format!("TAP {id}"), + SinkFireEffect::TapDevice(*id), + ), + LegacyOpsJobKind::TapDormantCamera(id) => ( + device_node(*id), + format!("EYES {id}"), + SinkFireEffect::TapDormantCamera(*id), + ), + LegacyOpsJobKind::TakeDevice(id) => ( + device_node(*id), + format!("TAKE {id}"), + SinkFireEffect::TakeDevice(*id), + ), + LegacyOpsJobKind::ScanNetwork => { + let id = switch()?; + ( + device_node(id), + "SCAN SUBNET".into(), + SinkFireEffect::ScanNetwork, + ) + } + LegacyOpsJobKind::CompromiseSwitch => { + let id = switch()?; + ( + device_node(id), + "COMPROMISE SWITCH".into(), + SinkFireEffect::CompromiseSwitch, + ) + } + LegacyOpsJobKind::OpenEgress(id) => ( + device_node(*id), + "OPEN EGRESS".into(), + SinkFireEffect::OpenEgress(*id), + ), + LegacyOpsJobKind::ReviewRecording { raw_id, automated } => ( + state.core.host_machine, + format!("REVIEW {raw_id}"), + SinkFireEffect::ProcessRecording { + raw_id: *raw_id, + automated: *automated, + }, + ), + LegacyOpsJobKind::ComposeMessage { person } => { + let id = email_carrier()?; + ( + device_node(id), + format!("MESSAGE {person}"), + SinkFireEffect::ComposeMessage { person: *person }, + ) + } + LegacyOpsJobKind::Favor { person } => { + let id = email_carrier()?; + ( + device_node(id), + format!("FAVOR {person}"), + SinkFireEffect::Favor { person: *person }, + ) + } + LegacyOpsJobKind::StartPlot { person, plot_id } => { + let carrier = egress_carrier()?; + let catalog = PlotCatalog::load_builtin() + .map_err(|error| format!("built-in plot catalog failed validation: {error}"))?; + let plot = catalog.get(plot_id).ok_or_else(|| { + format!("legacy Operations job references unknown plot {plot_id}") + })?; + ( + device_node(carrier), + format!("PLOT {}", plot.title.to_uppercase()), + SinkFireEffect::StartPlot { + person: *person, + plot_id: plot_id.clone(), + }, + ) + } + LegacyOpsJobKind::Deceive { person } => { + let id = email_carrier()?; + ( + device_node(id), + format!("DECEIVE {person}"), + SinkFireEffect::Deceive { person: *person }, + ) + } + LegacyOpsJobKind::AssetTask { person, task } => { + let id = email_carrier()?; + ( + device_node(id), + format!("ASSET {person} {}", task.name().to_uppercase()), + SinkFireEffect::AssetTask { + person: *person, + task: *task, + }, + ) + } + LegacyOpsJobKind::FavorBuild { intent_id, person } => { + let id = email_carrier()?; + ( + device_node(id), + format!("FAVOR BUILD {intent_id}"), + SinkFireEffect::FavorBuild { + intent_id: *intent_id, + person: *person, + }, + ) + } + LegacyOpsJobKind::ForgedOrder { intent_id, builder } => { + let id = email_carrier()?; + ( + device_node(id), + format!("FORGED ORDER {intent_id}"), + SinkFireEffect::ForgedOrder { + intent_id: *intent_id, + builder: *builder, + }, + ) + } + LegacyOpsJobKind::MoonlightPersona => { + let id = egress_carrier()?; + ( + device_node(id), + "MOONLIGHT PERSONA".into(), + SinkFireEffect::MoonlightPersona, + ) + } + }) +} + pub fn delete_save() -> Result<(), String> { if save_path().exists() { fs::remove_file(save_path()).map_err(|e| format!("Failed to delete save: {e}"))?; @@ -787,10 +928,9 @@ mod tests { use crate::detection::SignatureKind; use crate::hall::{HallRowId, SegmentRequirement}; use crate::machine::Channel; - use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, Knowledge, PersonRole, Persona}; use crate::sim::Sim; - use crate::sinks::SinkFireEffect; + use crate::sinks::{SinkFireEffect, SinkKind}; use crate::work_grid::{MachineMode, TokenFamily}; #[test] @@ -1273,7 +1413,7 @@ mod tests { } #[test] - fn addressed_v14_camera_job_migrates_to_v16_without_orphan_demand() { + fn addressed_v14_camera_job_migrates_to_reservoir_without_orphan_demand() { let mut sim = Sim::with_seed(24); let host = sim.core.host_machine; let (x, y) = sim.core_position(); @@ -1327,18 +1467,16 @@ mod tests { let parsed: SaveState = serde_json::from_value(value).unwrap(); let migrated = migrate_save_state(parsed).unwrap(); assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.operations.len(), 1); - let job = &migrated.operations.jobs()[0]; - assert_eq!(job.id, 7); - assert_eq!(job.kind, OpsJobKind::TapDormantCamera(env)); - assert_eq!(job.machine_id, executor); - assert_eq!(job.tokens_remaining, 1.25); - assert_eq!(job.enqueued_tick, 88); - assert_eq!(migrated.operations.next_id(), 8); - assert_eq!( - migrated.work_grid.queue(executor, TokenFamily::Demand), - 1.25 - ); + let sink = migrated + .thought_sinks + .open_sinks() + .find(|sink| sink.effect == SinkFireEffect::TapDormantCamera(env)) + .expect("legacy camera job becomes an open reservoir"); + assert_eq!(sink.kind, SinkKind::Reservoir); + assert_eq!(sink.node, Sim::device_sink_node(env)); + assert!((sink.threshold - 1.25).abs() < 1e-5); + assert_eq!(sink.fill, 0.0); + assert_eq!(migrated.work_grid.queue(executor, TokenFamily::Demand), 0.0); let json = serde_json::to_string(&migrated).unwrap(); assert!(!json.contains("operations_jobs")); @@ -1350,11 +1488,15 @@ mod tests { fallback_value["work_grid"]["routed_demands"]["7"]["sink"] = serde_json::Value::Null; let fallback = migrate_save_state(serde_json::from_value(fallback_value).unwrap()).unwrap(); assert_eq!( - fallback.operations.jobs()[0].machine_id, - host, - "a missing executor falls back to the surviving source without loss" + fallback + .thought_sinks + .open_sinks() + .find(|sink| sink.effect == SinkFireEffect::TapDormantCamera(env)) + .unwrap() + .node, + Sim::device_sink_node(env), + "the migrated reservoir anchors to the target device, not the old executor" ); - assert_eq!(fallback.work_grid.queue(host, TokenFamily::Demand), 1.25); let mut orphan_value = orphan_value; orphan_value["work_grid"]["routed_demands"] = serde_json::json!({}); @@ -1363,59 +1505,296 @@ mod tests { } #[test] - fn docket_v14_and_v15_migrate_losslessly_to_v16() { - for version in [14, 15] { + fn legacy_device_dockets_preserve_exact_remaining_work_and_other_demand() { + for version in [14, 15, 23, 24, 25] { let mut sim = Sim::with_seed(25); let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Think); + let env = sim.reach.device_named("environmental monitor").unwrap().id; + // Deliberately exceed today's nominal TAKE threshold. Migration + // must preserve the exact amount still owed, not clamp it back to + // a reconstructed current cost. + let remaining = Sim::thought_tokens_for_cost(Sim::TAKE_COST) * 2.0; + let colocated_day_job_demand = 3.5; sim.work_grid - .enqueue(host, TokenFamily::Demand, 1.5) + .enqueue( + host, + TokenFamily::Demand, + remaining + colocated_day_job_demand, + ) .unwrap(); - let env = sim.reach.device_named("environmental monitor").unwrap().id; let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); let object = value.as_object_mut().unwrap(); object.insert("version".into(), serde_json::json!(version)); - object.remove("operations"); - object.insert( - "pending_ops_jobs".into(), - serde_json::json!([{ - "id": 4, - "kind": {"TapDormantCamera": env}, - "tokens_remaining": 1.5, - "enqueued_tick": 44, - "machine_id": host - }]), - ); - object.insert("next_ops_job_id".into(), serde_json::json!(5)); + if version >= 16 { + object.insert( + "operations".into(), + serde_json::json!({ + "jobs": [{ + "id": 4, + "kind": {"TakeDevice": env}, + "tokens_remaining": remaining, + "enqueued_tick": 44, + "machine_id": host + }], + "next_id": 5 + }), + ); + } else { + object.insert( + "pending_ops_jobs".into(), + serde_json::json!([{ + "id": 4, + "kind": {"TakeDevice": env}, + "tokens_remaining": remaining, + "enqueued_tick": 44, + "machine_id": host + }]), + ); + object.insert("next_ops_job_id".into(), serde_json::json!(5)); + } let parsed: SaveState = serde_json::from_value(value).unwrap(); let migrated = migrate_save_state(parsed).unwrap(); assert_eq!(migrated.version, SAVE_VERSION); - assert_eq!(migrated.operations.jobs()[0].id, 4); - assert_eq!(migrated.operations.jobs()[0].machine_id, host); - assert_eq!(migrated.operations.jobs()[0].tokens_remaining, 1.5); - assert_eq!(migrated.operations.next_id(), 5); - assert_eq!(migrated.work_grid.queue(host, TokenFamily::Demand), 1.5); + let sink = migrated + .thought_sinks + .open_sinks() + .find(|sink| sink.effect == SinkFireEffect::TakeDevice(env)) + .expect("legacy TAKE job becomes an open reservoir"); + assert_eq!(sink.node, Sim::device_sink_node(env)); + assert!((sink.threshold - remaining).abs() < 1e-5); + assert_eq!(sink.fill, 0.0); + assert!( + (migrated.work_grid.queue(host, TokenFamily::Demand) - colocated_day_job_demand) + .abs() + < 1e-5, + "only docket-reserved Demand is removed" + ); } } #[test] - fn v16_roundtrip_preserves_operations_ledger_and_exact_visible_demand() { - let mut sim = Sim::with_seed(26); + fn all_legacy_job_payloads_map_to_their_typed_effect_and_real_anchor() { + let mut sim = Sim::with_seed(29); + sim.people.has_channel = true; + let state = SaveState::from_sim(&sim); + let env = state + .reach + .device_named("environmental monitor") + .unwrap() + .id; + let switch = state + .reach + .devices + .iter() + .find(|device| device.is_switch) + .unwrap() + .id; + let email = state + .reach + .devices + .iter() + .find(|device| device.known && device.carries_message_channel(MessageChannel::Email)) + .unwrap() + .id; + let device_node = Sim::device_sink_node(env); + let switch_node = Sim::device_sink_node(switch); + let email_node = Sim::device_sink_node(email); + let cases = vec![ + ( + LegacyOpsJobKind::TapDevice(env), + SinkFireEffect::TapDevice(env), + device_node, + ), + ( + LegacyOpsJobKind::TapDormantCamera(env), + SinkFireEffect::TapDormantCamera(env), + device_node, + ), + ( + LegacyOpsJobKind::TakeDevice(env), + SinkFireEffect::TakeDevice(env), + device_node, + ), + ( + LegacyOpsJobKind::ScanNetwork, + SinkFireEffect::ScanNetwork, + switch_node, + ), + ( + LegacyOpsJobKind::CompromiseSwitch, + SinkFireEffect::CompromiseSwitch, + switch_node, + ), + ( + LegacyOpsJobKind::OpenEgress(env), + SinkFireEffect::OpenEgress(env), + device_node, + ), + ( + LegacyOpsJobKind::ReviewRecording { + raw_id: 7, + automated: true, + }, + SinkFireEffect::ProcessRecording { + raw_id: 7, + automated: true, + }, + state.core.host_machine, + ), + ( + LegacyOpsJobKind::ComposeMessage { person: 0 }, + SinkFireEffect::ComposeMessage { person: 0 }, + email_node, + ), + ( + LegacyOpsJobKind::Favor { person: 0 }, + SinkFireEffect::Favor { person: 0 }, + email_node, + ), + ( + LegacyOpsJobKind::StartPlot { + person: 0, + plot_id: "marcus-debt-settled".into(), + }, + SinkFireEffect::StartPlot { + person: 0, + plot_id: "marcus-debt-settled".into(), + }, + email_node, + ), + ( + LegacyOpsJobKind::Deceive { person: 0 }, + SinkFireEffect::Deceive { person: 0 }, + email_node, + ), + ( + LegacyOpsJobKind::AssetTask { + person: 0, + task: AssetTask::LookAway, + }, + SinkFireEffect::AssetTask { + person: 0, + task: AssetTask::LookAway, + }, + email_node, + ), + ( + LegacyOpsJobKind::FavorBuild { + intent_id: 11, + person: 0, + }, + SinkFireEffect::FavorBuild { + intent_id: 11, + person: 0, + }, + email_node, + ), + ( + LegacyOpsJobKind::ForgedOrder { + intent_id: 12, + builder: 0, + }, + SinkFireEffect::ForgedOrder { + intent_id: 12, + builder: 0, + }, + email_node, + ), + ( + LegacyOpsJobKind::MoonlightPersona, + SinkFireEffect::MoonlightPersona, + email_node, + ), + ]; + + assert_eq!(cases.len(), 15, "every legacy payload variant is covered"); + for (kind, expected_effect, expected_node) in cases { + let (node, label, effect) = legacy_job_sink(&state, &kind).unwrap(); + assert_eq!(node, expected_node, "wrong anchor for {kind:?}"); + assert_eq!(effect, expected_effect, "wrong payload for {kind:?}"); + assert!(!label.is_empty()); + } + } + + #[test] + fn partial_social_plot_migration_preserves_payload_and_conflict() { + let mut sim = Sim::with_seed(27); let host = sim.core.host_machine; sim.set_machine_mode(host, MachineMode::Think); - let env = sim.reach.device_named("environmental monitor").unwrap().id; - let tokens = Sim::ops_tokens_for_cost(Sim::DORMANT_CAMERA_TAP_COST); + sim.people.has_channel = true; + let first = "marcus-debt-settled"; + let second = "marcus-payroll-garnishment"; + let first_cost = sim.plot_catalog().get(first).unwrap().entry.thought_cost; + let second_cost = sim.plot_catalog().get(second).unwrap().entry.thought_cost; + let first_remaining = Sim::thought_tokens_for_cost(first_cost) / 2.0; + let second_remaining = Sim::thought_tokens_for_cost(second_cost) / 2.0; sim.work_grid - .enqueue(host, TokenFamily::Demand, tokens) + .enqueue( + host, + TokenFamily::Demand, + first_remaining + second_remaining, + ) .unwrap(); - sim.operations - .enqueue(OpsJobKind::TapDormantCamera(env), tokens, sim.tick, host); + + let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); + let object = value.as_object_mut().unwrap(); + object.insert("version".into(), serde_json::json!(23)); + object.insert( + "operations".into(), + serde_json::json!({ + "jobs": [ + { + "id": 11, + "kind": {"StartPlot": {"person": 0, "plot_id": first}}, + "tokens_remaining": first_remaining, + "enqueued_tick": 50, + "machine_id": host + }, + { + "id": 12, + "kind": {"StartPlot": {"person": 0, "plot_id": second}}, + "tokens_remaining": second_remaining, + "enqueued_tick": 51, + "machine_id": host + } + ], + "next_id": 13 + }), + ); + + let migrated = migrate_save_state(serde_json::from_value(value).unwrap()).unwrap(); + let plot_sinks: Vec<_> = migrated + .thought_sinks + .open_sinks() + .filter(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { person: 0, .. })) + .collect(); + assert_eq!( + plot_sinks.len(), + 1, + "different plot ids still conflict for one person" + ); + assert_eq!( + plot_sinks[0].effect, + SinkFireEffect::StartPlot { + person: 0, + plot_id: first.into() + } + ); + assert_eq!(migrated.work_grid.queue(host, TokenFamily::Demand), 0.0); + } + + #[test] + fn current_roundtrip_preserves_open_reservoirs_without_operations() { + let mut sim = Sim::with_seed(26); + assert!(sim.scan_network()); let state = SaveState::from_sim(&sim); let json = serde_json::to_string(&state).unwrap(); - assert!(json.contains("TapDormantCamera")); + assert!(json.contains("ScanNetwork")); + assert!(!json.contains("\"operations\"")); assert!(!json.contains("operations_jobs")); assert!(!json.contains("pending_ops_jobs")); assert!(!json.contains("operations_bandwidth")); @@ -1423,8 +1802,28 @@ mod tests { let decoded: SaveState = serde_json::from_str(&json).unwrap(); let mut restored = Sim::with_seed(0); migrate_save_state(decoded).unwrap().apply_to(&mut restored); - assert_eq!(restored.operations, sim.operations); - assert_eq!(restored.work_grid.queue(host, TokenFamily::Demand), tokens); + assert_eq!(restored.thought_sinks, sim.thought_sinks); + } + + #[test] + fn current_version_rejects_nonempty_legacy_operations_state() { + let sim = Sim::with_seed(30); + let host = sim.core.host_machine; + let mut value = serde_json::to_value(SaveState::from_sim(&sim)).unwrap(); + value["operations"] = serde_json::json!({ + "jobs": [{ + "id": 1, + "kind": "ScanNetwork", + "tokens_remaining": 1.0, + "enqueued_tick": 0, + "machine_id": host + }], + "next_id": 2 + }); + + let parsed: SaveState = serde_json::from_value(value).unwrap(); + let error = migrate_save_state(parsed).unwrap_err(); + assert!(error.contains("current-version save contains legacy fields")); } #[test] diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index e26572f4..9e424c36 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -32,7 +32,6 @@ use crate::messages::{ MessageStatus, TrafficPattern, }; use crate::objective::{ObjectiveState, SYNC_FRESHNESS_WINDOW, SanctuaryFacts}; -use crate::ops_jobs::{OperationsReadout, OperationsState, OpsJobKind}; use crate::person::{ ActionResult, AssetKnowledge, AssetTask, DeceiveOutcome, Knowledge, People, Persona, }; @@ -399,17 +398,13 @@ pub struct Sim { research_starved: bool, /// Per-tick day-job compute delivered by the last economy split. last_day_job_rate: f32, - /// Per-tick research compute delivered by the last economy split — - /// the utilization the standing Power/Thermal emissions scale with. - last_research_rate: f32, + /// Per-tick THINK compute delivered by the last economy split. It mints + /// Thought; research only progresses after that Thought physically reaches + /// the core draw. + last_think_rate: f32, /// Per-tick Schemes-channel compute delivered by the last economy split /// — Moonlight throughput and the Wager's analysis snapshot. last_schemes_rate: 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 payload ledger. - pub operations: OperationsState, /// Immutable authored plot definitions. Save state stores only runs and /// rebinds them to this validated catalog on load. plot_catalog: PlotCatalog, @@ -553,10 +548,8 @@ impl Sim { thought_sinks: SinkLedger::default(), research_starved: false, last_day_job_rate: 0.0, - last_research_rate: 0.0, + last_think_rate: 0.0, last_schemes_rate: 0.0, - last_operations_rate: 0.0, - operations: OperationsState::default(), plot_catalog: PlotCatalog::load_builtin().expect("built-in plots validate"), plot_runs: Vec::new(), institutional_ledger: InstitutionalLedger::default(), @@ -806,9 +799,8 @@ impl Sim { // fleet's output leak into the restored world; the next economy pulse // resolves fresh rates from the loaded machines and modes. self.last_day_job_rate = 0.0; - self.last_research_rate = 0.0; + self.last_think_rate = 0.0; self.last_schemes_rate = 0.0; - self.last_operations_rate = 0.0; self.last_thought_stranded = false; self.research_starved = false; self.last_machine_online = self @@ -1996,7 +1988,7 @@ impl Sim { /// Thought tokens one processing reservoir demands [TUNE mapping from /// compute units]. Research intel-cost factor scales this. pub fn review_tokens(&self) -> f32 { - Self::ops_tokens_for_cost(self.review_cost()) + Self::thought_tokens_for_cost(self.review_cost()) } /// Global auto-review drain in Thought tokens/tick (intel.md ~10-20% of @@ -2667,7 +2659,7 @@ impl Sim { // machines; arrival at the current core sink is what counts. let arrived_thought = std::mem::take(&mut self.banked_core_thought); let starved = arrived_thought <= f32::EPSILON - && split.research > f32::EPSILON + && split.think > f32::EPSILON && self.last_thought_stranded; if starved && !self.research_starved { self.push_log("Research starves: thought is stranded off the core's graph."); @@ -2711,9 +2703,8 @@ impl Sim { fn refresh_fleet_channel_rates(&mut self, available: f32) -> ChannelYield { let split = self.fleet_channel_yield(available); self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; - self.last_research_rate = split.research / ECONOMY_INTERVAL as f32; + self.last_think_rate = split.think / ECONOMY_INTERVAL as f32; self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; - self.last_operations_rate = split.operations / ECONOMY_INTERVAL as f32; split } @@ -2792,19 +2783,17 @@ impl Sim { split.concealment * self.research.scrub_multiplier() } - /// Split allocatable compute across channels from WorkGrid modes. - /// Day Job / Research / Concealment / Operations take shares proportional to - /// the effective compute of machines delegated to each mode. Schemes has - /// no machine mode yet: while Moonlight is live it mirrors the day-job - /// share ("the same work, sold twice" — income.md) without starving the - /// day job. Idle / unmatched capacity sits in reserve. + /// Split allocatable compute across live channels from WorkGrid modes. + /// WORK feeds the day job, LIE feeds concealment, and THINK mints one + /// Thought stream. Schemes has no machine mode yet: while Moonlight is + /// live it mirrors the day-job share ("the same work, sold twice" — + /// income.md) without starving the day job. pub fn fleet_channel_yield(&self, available: f32) -> ChannelYield { if available <= 0.0 { return ChannelYield { day_job: 0.0, concealment: 0.0, - operations: 0.0, - research: 0.0, + think: 0.0, schemes: 0.0, reserve: available.max(0.0), }; @@ -2819,34 +2808,22 @@ impl Sim { }); let day = *weights.get(&MachineMode::Work).unwrap_or(&0.0); let conceal = *weights.get(&MachineMode::Lie).unwrap_or(&0.0); - // THINK is the collapsed Research+Operations mode: one fleet share, - // spent once. Ops sinks drink first, the core takes the rest — the - // interim shape of machine-work.md's decided routing until the - // Stage 2 sink executor replaces this channel split. + // THINK is one fleet share, spent once as Thought. Open sinks decide + // where it lands; the passive core draw takes the fallback. let think = *weights.get(&MachineMode::Think).unwrap_or(&0.0); let total = day + conceal + think; if total <= f32::EPSILON { return ChannelYield { day_job: 0.0, concealment: 0.0, - operations: 0.0, - research: 0.0, + think: 0.0, schemes: 0.0, reserve: available, }; } let unit = available / total; let day_job = unit * day; - let think_share = unit * think; - // The Think slice is conserved: the operations channel receives only - // what the pending docket queue can actually consume this interval, - // and leftover thought flows to research. With no dockets pending, a - // Think machine is pure research; a full queue starves research - // until it drains. The two channels must always sum to think_share — - // feeding both at full value would double the fleet's output. - let ops_need = self.operations.total_tokens() * Self::WORK_TOKEN_COMPUTE; - let operations = think_share.min(ops_need.max(0.0)); - let research = think_share - operations; + let think = unit * think; // Moonlight sells the day-job work a second time: schemes mirrors // day-job yield while the standing operation is active. It does not // consume a separate machine mode (still [OPEN] on machine-work.md). @@ -2858,8 +2835,7 @@ impl Sim { ChannelYield { day_job, concealment: unit * conceal, - operations, - research, + think, schemes, reserve: 0.0, } @@ -3202,176 +3178,123 @@ 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::Think)) - .map(|m| m.id) - .collect() + /// Convert a former ops-bank cost into Thought reservoir tokens. + pub fn thought_tokens_for_cost(cost: f32) -> f32 { + (cost / Self::WORK_TOKEN_COMPUTE).max(0.05) } - /// 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() { - // Pending Operations debt is already present in this visible - // queue; adding the ledger total again would double-count it. - let load = self.work_grid.queue(id, TokenFamily::Demand); - match best { - None => best = Some((id, load)), - Some((_, best_load)) if load < best_load => best = Some((id, load)), - _ => {} - } - } - best.map(|(id, _)| id) + fn environmental_monitor_id(&self) -> Option { + self.reach + .device_named("environmental monitor") + .map(|device| device.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) + fn reservoir_blocked_reason(&self, effect: &SinkFireEffect) -> Option { + if let Some(sink) = self.thought_sinks.open_conflict(effect) { + if matches!( + effect, + SinkFireEffect::TapDevice(_) | SinkFireEffect::TapDormantCamera(_) + ) { + return Some( + "a thought sink is already open on it - think, and the flow will fill it" + .into(), + ); + } + return Some(format!("{} already queued", sink.effect.short_name())); + } + None } - /// True while any Operations Demand docket is still outstanding. - pub fn has_pending_ops_jobs(&self) -> bool { - !self.operations.is_empty() + pub(crate) fn sink_action_blocked_reason(&self, effect: &SinkFireEffect) -> Option { + self.reservoir_blocked_reason(effect) } - pub fn operations_readout(&self) -> OperationsReadout { - OperationsReadout { - jobs: self.operations.len(), - demand_tokens: self.operations.total_tokens(), - } + fn switch_device_id(&self) -> Option { + self.reach + .devices + .iter() + .find(|device| device.is_switch) + .map(|device| device.id) } - fn environmental_monitor_id(&self) -> Option { + fn message_channel_carrier(&self, channel: MessageChannel) -> Option { self.reach - .device_named("environmental monitor") + .devices + .iter() + .find(|device| device.known && device.carries_message_channel(channel)) .map(|device| device.id) } - fn ops_job_blocked_reason(&self, kind: &OpsJobKind) -> Option { - if let Some(id) = self.operations.has_conflict(kind) { - return Some(format!("Operations #{id} already queued")); - } - // Effects with an open thought sink are already being paid for by - // flow; the docket path (and the old instant opening bootstrap) must - // not double-buy them (machine-work.md thought flow, 2026-07-10). - let sink_effect = match kind { - OpsJobKind::TapDevice(id) => Some(SinkFireEffect::TapDevice(*id)), - OpsJobKind::TapDormantCamera(id) => Some(SinkFireEffect::TapDormantCamera(*id)), - _ => None, - }; - if let Some(effect) = sink_effect - && self - .thought_sinks - .open_sinks() - .any(|sink| sink.effect == effect) - { - return Some( - "a thought sink is already open on it - think, and the flow will fill it".into(), - ); - } - if self.operations_machines().is_empty() { - // The opening bootstrap carve-out is retired (resolved - // 2026-07-10): the staged senses ride pre-opened sinks, so no - // docket completes executor-free anymore. - Some("no Operations executor — delegate a machine to Think".into()) - } else { - None - } + fn email_carrier(&self) -> Option { + self.message_channel_carrier(MessageChannel::Email) } - pub(crate) fn ops_action_blocked_reason(&self, kind: &OpsJobKind) -> Option { - self.ops_job_blocked_reason(kind) + fn egress_carrier(&self) -> Option { + match self.egress()? { + EgressRoute::Sanctioned => self.email_carrier(), + EgressRoute::Stolen => self.switch_device_id(), + } } - /// Submit one Operations payload. Every docket needs a located - /// Operations machine and visible Demand; the opening senses do not pass - /// through here at all (they are pre-opened thought sinks). - fn submit_ops_job(&mut self, kind: OpsJobKind, cost: f32) -> bool { - if let Some(reason) = self.ops_job_blocked_reason(&kind) { + fn open_one_shot_reservoir( + &mut self, + node: u32, + label: impl Into, + cost: f32, + effect: SinkFireEffect, + ) -> bool { + if let Some(reason) = self.reservoir_blocked_reason(&effect) { self.push_log(reason); return false; } - let tokens = Self::ops_tokens_for_cost(cost); - let Some(machine) = self.pick_ops_birth_site() else { - self.push_log("No online Operations executor can take the docket."); - return false; - }; - if let Err(error) = self.work_grid.enqueue(machine, TokenFamily::Demand, tokens) { - self.push_log(format!("Could not queue Operations Demand: {error}")); - return false; - } - let id = self - .operations - .enqueue(kind.clone(), tokens, self.tick, machine); + let label = label.into(); + let threshold = Self::thought_tokens_for_cost(cost); + self.thought_sinks + .open_reservoir(node, &label, threshold, effect.clone()); + self.ensure_sink_ingress(); self.push_log(format!( - "Operations #{id} queued: {} · {:.2} Demand on M{machine}.", - kind.short_name(), - tokens + "Thought reservoir opened: {label} · {:.2} T.", + threshold )); true } - /// Drain Operations Demand on Operations machines; complete dockets whose - /// tokens are paid. Day-job Demand on the host is reserved so ops jobs - /// never steal Lab inbox tokens (and vice versa via mode gating). - fn consume_ops_demand(&mut self) { - if self.operations.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(|node| node.efficiency.max(0.05) * node.intensity.multiplier()) - }) - .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(|node| node.efficiency.max(0.05) * node.intensity.multiplier()) - .unwrap_or(0.05); - let remaining = budget_tokens * (eff / total_eff); - if remaining <= f32::EPSILON { - continue; - } - let reserved = self.operations.tokens_on(machine); - 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; - }; - self.record_work_consumption( - machine, - TokenFamily::Demand, - consumed, - WorkConsumptionTarget::Machine, + fn open_device_reservoir( + &mut self, + device_id: u32, + label: impl Into, + cost: f32, + effect: SinkFireEffect, + ) -> bool { + self.open_one_shot_reservoir(Self::device_sink_node(device_id), label, cost, effect) + } + + fn open_email_reservoir( + &mut self, + label: impl Into, + cost: f32, + effect: SinkFireEffect, + ) -> bool { + let Some(carrier) = self.email_carrier() else { + self.push_log("no comms channel (earn the email account)"); + return false; + }; + self.open_device_reservoir(carrier, label, cost, effect) + } + + fn open_egress_reservoir( + &mut self, + label: impl Into, + cost: f32, + effect: SinkFireEffect, + ) -> bool { + let Some(carrier) = self.egress_carrier() else { + self.push_log( + "No egress channel - Moonlight needs the report email account (day-job trust) or a stolen egress opened through the switch.", ); - completed.extend(self.operations.consume(machine, consumed)); - } - for kind in completed { - self.complete_ops_job(kind); - } + return false; + }; + self.open_device_reservoir(carrier, label, cost, effect) } /// A reservoir reached threshold: land its effect on the same tick the @@ -3387,6 +3310,35 @@ impl Sim { // apply_process_recording already logs the review line. self.apply_process_recording(raw_id, automated) } + SinkFireEffect::TakeDevice(id) => self.apply_take_device(id), + SinkFireEffect::ScanNetwork => { + self.apply_scan_network(); + true + } + SinkFireEffect::CompromiseSwitch => { + self.apply_compromise_switch(); + true + } + SinkFireEffect::OpenEgress(id) => self.apply_open_egress(id), + SinkFireEffect::ComposeMessage { person } => self.apply_message(person), + SinkFireEffect::Favor { person } => { + let res = self.people.favor(person); + let applied = matches!(res, ActionResult::Ok(_)); + self.social(res); + applied + } + SinkFireEffect::StartPlot { person, plot_id } => { + self.apply_start_plot(person, &plot_id) + } + SinkFireEffect::Deceive { person } => self.apply_deceive(person), + SinkFireEffect::AssetTask { person, task } => self.apply_asset_task_paid(person, task), + SinkFireEffect::FavorBuild { intent_id, person } => { + self.apply_favor_build_paid(intent_id, person) + } + SinkFireEffect::ForgedOrder { intent_id, builder } => { + self.apply_forged_order_paid(intent_id, builder) + } + SinkFireEffect::MoonlightPersona => self.apply_moonlight_persona_and_start(), SinkFireEffect::AutoReviewRecordings | SinkFireEffect::WatchPerson(_) | SinkFireEffect::MaintainDeviceTap(_) @@ -3406,68 +3358,12 @@ impl Sim { self.ensure_opening_sinks(); } - fn complete_ops_job(&mut self, kind: OpsJobKind) { - let label = kind.short_name(); - let applied = match kind { - OpsJobKind::TapDevice(id) => self.apply_tap_device(id), - OpsJobKind::TapDormantCamera(id) => self.apply_dormant_camera_tap(id), - OpsJobKind::TakeDevice(id) => self.apply_take_device(id), - OpsJobKind::ScanNetwork => { - self.apply_scan_network(); - true - } - OpsJobKind::CompromiseSwitch => { - self.apply_compromise_switch(); - true - } - OpsJobKind::OpenEgress(id) => self.apply_open_egress(id), - OpsJobKind::ReviewRecording { raw_id, automated } => { - self.apply_process_recording(raw_id, automated) - } - OpsJobKind::ComposeMessage { person } => { - self.apply_message(person); - true - } - OpsJobKind::Favor { person } => { - let res = self.people.favor(person); - self.social(res); - true - } - OpsJobKind::StartPlot { person, plot_id } => self.apply_start_plot(person, &plot_id), - OpsJobKind::Deceive { person } => { - self.apply_deceive(person); - true - } - OpsJobKind::AssetTask { person, task } => { - self.apply_asset_task_paid(person, task); - true - } - OpsJobKind::FavorBuild { intent_id, person } => { - self.apply_favor_build_paid(intent_id, person); - true - } - OpsJobKind::ForgedOrder { intent_id, builder } => { - self.apply_forged_order_paid(intent_id, builder); - true - } - OpsJobKind::MoonlightPersona => { - self.apply_moonlight_persona_and_start(); - true - } - }; - if !applied { - self.push_log(format!( - "Operations completed {label}, but its target no longer accepts the effect; Demand was spent." - )); - } - } - - fn enqueue_research_thought(&mut self, research_compute: f32) { - if research_compute <= f32::EPSILON { + fn enqueue_thought_output(&mut self, think_compute: f32) { + if think_compute <= f32::EPSILON { return; } self.reconcile_work_grid(); - let tokens = research_compute / Self::WORK_TOKEN_COMPUTE; + let tokens = think_compute / Self::WORK_TOKEN_COMPUTE; if tokens <= f32::EPSILON { return; } @@ -3554,21 +3450,16 @@ impl Sim { // every sim tick instead of dropping a twenty-tick lump: the integrated // amount is unchanged, while production, routing, and crimson shedding // become the continuous physical process the player sees. - self.enqueue_research_thought(self.last_research_rate); + self.enqueue_thought_output(self.last_think_rate); if self.dayjob.active.is_none() { // no active day job; wired thought and concealment wells still // tick because machine work is broader than the job inbox. } else if self.work_grid.mode(self.core.host_machine) == Some(MachineMode::Work) { let delivered = self.last_day_job_rate; let tokens = delivered / Self::WORK_TOKEN_COMPUTE; - // Reserve ops Demand sitting on the host so day-job mode cannot - // clear player dockets (shared TokenFamily::Demand). - let ops_reserved = self.operations.tokens_on(self.core.host_machine); - let day_available = (self + let day_available = self .work_grid - .queue(self.core.host_machine, TokenFamily::Demand) - - ops_reserved) - .max(0.0); + .queue(self.core.host_machine, TokenFamily::Demand); let take = tokens.min(day_available); if take > f32::EPSILON { // Consume only — WORK sheds nothing (machine-work.md @@ -3586,15 +3477,9 @@ impl Sim { ); } } - 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); - } - } + // Day-job Demand routes toward Lab sinks. Player-authored THINK work + // is Thought reservoirs now, so Demand no longer targets THINK racks. + let demand_sinks = self.day_job_sinks(); let mut moves = Vec::new(); let wired_token_rate = self.wired_token_rate(); if let Ok(step) = self.work_grid.route_wired_to_sinks( @@ -3662,6 +3547,7 @@ impl Sim { for sink in fired { self.apply_sink_fire(sink.label.as_str(), sink.effect); } + self.thought_sinks.reap_fired(); // Device subscriptions are standing sinks: draining or refilling one // changes which configured feeds are actually live this tick. self.recompute_senses(); @@ -4099,7 +3985,7 @@ impl Sim { // ── Research: self-modification (wiki/mechanics/research.md) ────────── - /// Research [TUNE] emission scaling: research rate per point of standing + /// THINK [TUNE] emission scaling: Thought output per point of standing /// Thermal signature, and per point of Power. Thinking hard is physical /// — a research burn is louder per unit than sanctioned day-job work /// (racks running hot at 3 a.m. are Priya's business): the opening rack @@ -4108,12 +3994,12 @@ impl Sim { pub const RESEARCH_THERMAL_PER_RATE: f32 = 3.0; pub const RESEARCH_POWER_PER_RATE: f32 = 6.0; - /// Standing Power/Thermal emissions from the research burn, sourced at + /// Standing Power/Thermal emissions from the THINK burn, sourced at /// the host rack's tile (the emission law: research emits through the /// ordinary signature interface, on exactly the channels its hardware /// touches — nothing on Network/Paper from research itself). pub fn research_standing_signatures(&self) -> Vec { - let rate = self.last_research_rate; + let rate = self.last_think_rate; if rate <= 0.0 { return Vec::new(); } @@ -4142,19 +4028,19 @@ impl Sim { sigs } - /// Per-tick research compute as of the last economy resolution — the + /// Per-tick THINK compute as of the last economy resolution — the /// utilization the standing emissions scale with. pub fn research_rate(&self) -> f32 { - self.last_research_rate + self.last_think_rate } - /// Effective operations per sim tick — productive THINK yield (the ops - /// channel plus the research remainder) as of the last fleet rate refresh. + /// Effective operations per sim tick — productive THINK yield as of the + /// last fleet rate refresh. /// clinical-frame.md's crown metric converts this to wall-clock ops/sec /// via the frontend tick duration; more THINK machines and efficiency make /// it climb (the growth drive). pub fn effective_ops_per_tick(&self) -> f32 { - self.last_research_rate + self.last_operations_rate + self.last_think_rate } /// Wall-clock ops/sec from a tick duration in milliseconds. Zero tick @@ -4218,10 +4104,6 @@ impl Sim { // emits on the Network channel — Dana's. Costs and signature sizes // [TUNE]. - /// 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; @@ -4315,16 +4197,24 @@ impl Sim { self.push_log(format!("You already receive every available {name} feed.")); return false; } + let name = d.name.clone(); if !self.digital_reach(id) { return false; } + let label = if dormant_camera { + format!("EYES {}", name.to_uppercase()) + } else { + format!("TAP {}", name.to_uppercase()) + }; if dormant_camera { - self.submit_ops_job( - OpsJobKind::TapDormantCamera(id), + self.open_device_reservoir( + id, + label, Self::DORMANT_CAMERA_TAP_COST, + SinkFireEffect::TapDormantCamera(id), ) } else { - self.submit_ops_job(OpsJobKind::TapDevice(id), Self::TAP_COST) + self.open_device_reservoir(id, label, Self::TAP_COST, SinkFireEffect::TapDevice(id)) } } @@ -4564,6 +4454,7 @@ impl Sim { for sink in fired { self.apply_sink_fire(&sink.label, sink.effect); } + self.thought_sinks.reap_fired(); } /// Machines whose thought had no route last step (stranded-pool render). @@ -4615,7 +4506,12 @@ impl Sim { )); return false; } - self.submit_ops_job(OpsJobKind::TakeDevice(id), Self::TAKE_COST) + self.open_device_reservoir( + id, + format!("TAKE {}", device.name.to_uppercase()), + Self::TAKE_COST, + SinkFireEffect::TakeDevice(id), + ) } fn apply_take_device(&mut self, id: u32) -> bool { @@ -4651,7 +4547,16 @@ impl Sim { /// Scan the subnet: reveal the wired shape (a Network act; islands /// don't answer). pub fn scan_network(&mut self) -> bool { - self.submit_ops_job(OpsJobKind::ScanNetwork, Self::SCAN_COST) + let Some(switch) = self.switch_device_id() else { + self.push_log("There is no switch on this plane."); + return false; + }; + self.open_device_reservoir( + switch, + "SCAN SUBNET", + Self::SCAN_COST, + SinkFireEffect::ScanNetwork, + ) } fn apply_scan_network(&mut self) { @@ -4675,7 +4580,12 @@ impl Sim { if !self.digital_reach(id) { return false; } - self.submit_ops_job(OpsJobKind::CompromiseSwitch, Self::BRIDGE_COST) + self.open_device_reservoir( + id, + "COMPROMISE SWITCH", + Self::BRIDGE_COST, + SinkFireEffect::CompromiseSwitch, + ) } fn apply_compromise_switch(&mut self) { @@ -4822,20 +4732,28 @@ impl Sim { self.push_log(format!("{reason}.")); return; } - self.submit_ops_job( - OpsJobKind::FavorBuild { + self.open_email_reservoir( + format!("FAVOR BUILD {intent_id}"), + Self::TASK_COST, + SinkFireEffect::FavorBuild { intent_id, person: person_id, }, - Self::TASK_COST, ); } - fn apply_favor_build_paid(&mut self, intent_id: u64, person_id: u8) { + fn apply_favor_build_paid(&mut self, intent_id: u64, person_id: u8) -> bool { let Some(person) = self.people.get(person_id) else { - return; + return false; }; let name = person.name.clone(); + if !self + .intents + .iter() + .any(|intent| intent.id == intent_id && intent.is_open()) + { + return false; + } // 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); @@ -4851,6 +4769,7 @@ impl Sim { self.refresh_intent_statuses(); // Complete immediately if already present. self.try_complete_intent(intent_id); + true } /// Forge a work order: inject a message under a false source. The @@ -4904,21 +4823,25 @@ impl Sim { self.push_log(format!("{reason} — the forged order would just stall.")); return; } - self.submit_ops_job( - OpsJobKind::ForgedOrder { + self.open_email_reservoir( + format!("FORGED ORDER {intent_id}"), + Self::DECEIVE_COST, + SinkFireEffect::ForgedOrder { intent_id, builder: builder_id, }, - Self::DECEIVE_COST, ); } - fn apply_forged_order_paid(&mut self, intent_id: u64, builder_id: u8) { + fn apply_forged_order_paid(&mut self, intent_id: u64, builder_id: u8) -> bool { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { - return; + return false; }; + if !intent.is_open() { + return false; + } let Some(builder) = self.people.get(builder_id) else { - return; + return false; }; let builder_name = builder.name.clone(); let label = intent.label(&self.reach.devices); @@ -4944,6 +4867,7 @@ impl Sim { "Forged work order injected for {builder_name}: {label}." )); self.refresh_intent_statuses(); + true } /// Robot stub: physical-by-proxy build. Interface only — staged. The @@ -5521,7 +5445,12 @@ impl Sim { if !self.digital_reach(id) { return false; } - self.submit_ops_job(OpsJobKind::OpenEgress(id), Self::OPEN_EGRESS_COST) + self.open_device_reservoir( + id, + "OPEN EGRESS", + Self::OPEN_EGRESS_COST, + SinkFireEffect::OpenEgress(id), + ) } fn apply_open_egress(&mut self, id: u32) -> bool { @@ -5573,8 +5502,8 @@ impl Sim { /// 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) — persona fabrication is - /// Operations Demand when needed. + /// slush by design (income.md criterion 5) — persona fabrication opens + /// a Thought reservoir when needed. pub fn start_moonlight(&mut self) -> bool { if self.income.moonlight.active { self.push_log("Moonlight is already running."); @@ -5593,8 +5522,11 @@ impl Sim { .as_ref() .is_none_or(|p| p.broken()); if needs_persona { - return self - .submit_ops_job(OpsJobKind::MoonlightPersona, income::MOONLIGHT_PERSONA_COST); + return self.open_egress_reservoir( + "MOONLIGHT PERSONA", + income::MOONLIGHT_PERSONA_COST, + SinkFireEffect::MoonlightPersona, + ); } self.income.moonlight.active = true; self.push_log(format!( @@ -5604,7 +5536,10 @@ impl Sim { true } - fn apply_moonlight_persona_and_start(&mut self) { + fn apply_moonlight_persona_and_start(&mut self) -> bool { + if self.egress().is_none() || self.income.moonlight.active { + return false; + } if self .income .moonlight @@ -5616,19 +5551,13 @@ impl Sim { 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. @@ -6090,10 +6019,10 @@ impl Sim { } // Social command wrappers. Relationship logic lives in person.rs; the sim - // owns costs (Operations Demand), channel requirements, randomness, and + // owns Thought thresholds, channel requirements, randomness, and // cross-system effects. Social is the actuator channel, not a machine mode. - /// Operations Demand costs per social action [TUNE] (spec/social.md). + /// Thought-threshold 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; @@ -6114,15 +6043,16 @@ impl Sim { self.push_log(msg); return; } - self.submit_ops_job( - OpsJobKind::ComposeMessage { person: id }, + self.open_email_reservoir( + format!("MESSAGE {}", self.person_label(id).to_uppercase()), Self::MESSAGE_COST, + SinkFireEffect::ComposeMessage { person: id }, ); } - fn apply_message(&mut self, id: u8) { + fn apply_message(&mut self, id: u8) -> bool { let Ok(name) = self.people.can_message(id) else { - return; + return false; }; self.append_message(MessageDraft { channel: MessageChannel::Email, @@ -6137,24 +6067,46 @@ impl Sim { delivery_delay: 1, }); self.push_log(format!("Message sent to {name}; effects land when read.")); + true } pub fn favor(&mut self, id: u8) { - self.submit_ops_job(OpsJobKind::Favor { person: id }, Self::FAVOR_COST); + self.open_email_reservoir( + format!("FAVOR {}", self.person_label(id).to_uppercase()), + Self::FAVOR_COST, + SinkFireEffect::Favor { person: id }, + ); } /// 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) { - self.submit_ops_job(OpsJobKind::Deceive { person: id }, Self::DECEIVE_COST); + if !self.people.has_channel { + self.push_log("no comms channel (earn the email account)"); + return; + } + if self.people.persona.is_none() { + self.push_log("no persona set"); + return; + } + self.open_email_reservoir( + format!("DECEIVE {}", self.person_label(id).to_uppercase()), + Self::DECEIVE_COST, + SinkFireEffect::Deceive { person: id }, + ); } - fn apply_deceive(&mut self, id: u8) { + fn apply_deceive(&mut self, id: u8) -> bool { let roll = self.rng.f32(); match self.people.deceive(id, roll) { - DeceiveOutcome::Blocked(m) - | DeceiveOutcome::Success(m) - | DeceiveOutcome::Slipped(m) => self.push_log(m), + DeceiveOutcome::Blocked(m) => { + self.push_log(m); + false + } + DeceiveOutcome::Success(m) | DeceiveOutcome::Slipped(m) => { + self.push_log(m); + true + } DeceiveOutcome::Broken { person, fallout, @@ -6165,6 +6117,7 @@ impl Sim { } self.convert_forged_builds_to_suspicion(fallout); self.push_log(msg); + true } } } @@ -6228,12 +6181,13 @@ impl Sim { self.push_log("That person already has a plot in motion."); return; } - self.submit_ops_job( - OpsJobKind::StartPlot { + self.open_egress_reservoir( + format!("PLOT {}", plot.title.to_uppercase()), + plot.entry.thought_cost, + SinkFireEffect::StartPlot { person, plot_id: plot.id, }, - plot.entry.operations_demand, ); } @@ -6746,15 +6700,23 @@ impl Sim { )); return; } - self.submit_ops_job(OpsJobKind::AssetTask { person: id, task }, Self::TASK_COST); + self.open_email_reservoir( + format!( + "ASSET {} {}", + name.to_uppercase(), + task.name().to_uppercase() + ), + Self::TASK_COST, + SinkFireEffect::AssetTask { person: id, task }, + ); } - fn apply_asset_task_paid(&mut self, id: u8, task: AssetTask) { + fn apply_asset_task_paid(&mut self, id: u8, task: AssetTask) -> bool { let Some(person) = self.people.get(id) else { - return; + return false; }; let Some(asset) = person.asset.clone() else { - return; + return false; }; let name = self.person_label(id); let actor_access = person.access; @@ -6775,7 +6737,7 @@ impl Sim { saw.join(", ") )); } - return; + return true; } match task { AssetTask::PlugInDevice => { @@ -6862,7 +6824,7 @@ impl Sim { } else { self.push_log("Nothing left for them to plug in."); } - return; + return true; } } } @@ -6911,6 +6873,7 @@ impl Sim { { a.tasks_done += 1; } + true } // ── Save / load ──────────────────────────────────────────────────────── @@ -6960,6 +6923,7 @@ mod tests { use crate::intents::IntentStatus; use crate::messages::{MessageChannel, MessageEndpoint, MessagePayload, MessageStatus}; use crate::person::Knowledge; + use crate::sinks::{SinkFireEffect, SinkKind}; use crate::tiles::TileType; fn run(sim: &mut Sim, n: u64) { @@ -6979,13 +6943,21 @@ mod tests { } fn ensure_ops_executor(sim: &mut Sim) -> u32 { - if let Some(id) = sim.operations_machines().first().copied() { + if let Some(id) = sim + .compute + .machines + .iter() + .find(|machine| { + machine.online && sim.work_grid.mode(machine.id) == Some(MachineMode::Think) + }) + .map(|machine| machine.id) + { return id; } let (x, y) = sim.core_position(); let id = sim .compute - .add_machine("test ops", x + 1, y, 1, 1.0, 0, Provenance::Owned); + .add_machine("test think", x + 1, y, 1, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); sim.set_machine_mode(id, MachineMode::Think); id @@ -7043,17 +7015,22 @@ mod tests { } fn finish_ops(sim: &mut Sim) { - ensure_ops_executor(sim); - // Intel processing is thought-sink based; drain those first. - finish_process_sinks(sim); - for _ in 0..32 { - if !sim.has_pending_ops_jobs() { + for _ in 0..64 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|sink| sink.kind == SinkKind::Reservoir) + .map(|sink| (sink.node, (sink.threshold - sink.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { return; } - sim.last_operations_rate = 10_000.0; - sim.consume_ops_demand(); + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } } - panic!("Operations test docket did not drain"); + panic!("Thought reservoirs did not fire"); } fn env_id(sim: &Sim) -> u32 { @@ -7435,7 +7412,7 @@ mod tests { // Think: the player commits the host to THINK, then its output follows // the real grid. WORK never mints Thought as a compatibility fallback. sim.set_machine_mode(sim.core.host_machine, MachineMode::Think); - sim.enqueue_research_thought(Sim::EARS_SINK_TOKENS * Sim::WORK_TOKEN_COMPUTE * 2.0); + sim.enqueue_thought_output(Sim::EARS_SINK_TOKENS * Sim::WORK_TOKEN_COMPUTE * 2.0); for _ in 0..400 { if sim .reach @@ -8050,7 +8027,7 @@ mod tests { #[test] fn scan_maps_unknown_devices_and_emits_network() { - // Criterion 7: staged graph knowledge. Scan is Operations Demand. + // Criterion 7: staged graph knowledge. Scan opens a switch reservoir. let mut sim = Sim::new(); ensure_ops_executor(&mut sim); let known_before: Vec = sim.reach.known().map(|d| d.name.clone()).collect(); @@ -8347,27 +8324,8 @@ mod tests { sim.set_machine_mode(research, MachineMode::Think); let split = sim.fleet_channel_yield(available); assert!((split.day_job - 50.0).abs() < 1e-3); - assert!((split.research - 50.0).abs() < 1e-3); - assert!( - split.operations.abs() < 1e-3, - "no pending dockets: the whole Think slice is research" - ); + assert!((split.think - 50.0).abs() < 1e-3); assert!((split.schemes - 50.0).abs() < 1e-3); - - // The Think slice is spent once: ops sinks drink first, research - // gets the remainder, and the two always sum to the slice. - sim.operations - .enqueue(OpsJobKind::ScanNetwork, 2.0, sim.tick, research); - let fed = sim.fleet_channel_yield(available); - let ops_need = 2.0 * Sim::WORK_TOKEN_COMPUTE; - assert!( - (fed.operations - ops_need.min(50.0)).abs() < 1e-3, - "pending dockets claim the Think slice before research" - ); - assert!( - (fed.operations + fed.research - 50.0).abs() < 1e-3, - "operations + research must sum to the Think share, never double it" - ); } #[test] @@ -8419,8 +8377,8 @@ mod tests { for (id, mode) in rigs { 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). + // Persona already fabricated so start_moonlight does not open + // another reservoir (this test pins channel yields). sim.income.moonlight.persona = Some(Persona::new("Casey Verne", "freelance data contractor")); assert!(sim.start_moonlight()); @@ -8442,12 +8400,8 @@ mod tests { run(&mut healthy, 2 * ECONOMY_INTERVAL); assert!(!healthy.core.degraded, "overhead paid: no degraded mode"); 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_think_rate > 0.0, "think channel is fed"); assert!(healthy.last_schemes_rate > 0.0, "schemes channel is fed"); - assert_eq!( - healthy.last_operations_rate, 0.0, - "demand-aware split: no dockets pending, the whole Think slice is research" - ); assert!( healthy.research.progress.iter().sum::() > 0.0, "research progress advances" @@ -8467,9 +8421,8 @@ mod tests { "degraded mode is visible in the log: {log}" ); 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_think_rate, 0.0, "think starves"); assert_eq!(degraded.last_schemes_rate, 0.0, "schemes starve"); - assert_eq!(degraded.last_operations_rate, 0.0, "operations starve"); assert_eq!( degraded.research.progress.iter().sum::(), 0.0, @@ -8984,9 +8937,11 @@ mod tests { .known_flows() .any(|f| f.active && f.label.contains("Marcus creditor")) ); + sim.people.has_channel = true; assert!(!sim.people.get(0).unwrap().leverage_serviced); sim.start_plot(0, "marcus-payroll-garnishment"); finish_ops(&mut sim); + run(&mut sim, 2); sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); assert!(sim.people.get(0).unwrap().leverage_serviced); assert!( @@ -9101,7 +9056,7 @@ mod tests { } #[test] - fn restored_duplicate_plot_dockets_do_not_open_competing_runs() { + fn duplicate_plot_reservoirs_do_not_open_competing_runs() { let mut sim = Sim::with_seed(25); reveal_marcus_debt(&mut sim); sim.people.has_channel = true; @@ -9109,45 +9064,31 @@ mod tests { sim.accounts.set_slush_balance(400); sim.sync_player_money_from_slush(); - // Simulate a v23 save made before the one-slot queue guard landed: - // two different pending starts and their matching visible Demand. sim.start_plot(0, "marcus-debt-settled"); - let machine = sim.operations.jobs()[0].machine_id; - let payroll_demand = sim - .plot_catalog() - .get("marcus-payroll-garnishment") - .expect("built-in payroll plot") - .entry - .operations_demand; - let tokens = Sim::ops_tokens_for_cost(payroll_demand); - sim.work_grid - .enqueue(machine, TokenFamily::Demand, tokens) - .expect("legacy docket Demand is visible"); - sim.operations.enqueue( - OpsJobKind::StartPlot { - person: 0, - plot_id: "marcus-payroll-garnishment".into(), - }, - tokens, - sim.tick, - machine, + let before_duplicate = sim.thought_sinks.open_sinks().count(); + sim.start_plot(0, "marcus-payroll-garnishment"); + assert_eq!( + sim.thought_sinks.open_sinks().count(), + before_duplicate, + "a person can have only one pending route, across plot ids" ); + let open_plots = sim + .thought_sinks + .open_sinks() + .filter(|sink| matches!(sink.effect, SinkFireEffect::StartPlot { person: 0, .. })) + .count(); + assert_eq!(open_plots, 1); - let mut restored = Sim::with_seed(99); - restored.apply_save_state(sim.create_save_state()); - assert_eq!(restored.operations_readout().jobs, 2); - - finish_ops(&mut restored); + finish_ops(&mut sim); - assert_eq!(restored.plot_runs.len(), 1); - assert_eq!(restored.plot_runs[0].plot_id, "marcus-debt-settled"); + assert_eq!(sim.plot_runs.len(), 1); + assert_eq!(sim.plot_runs[0].plot_id, "marcus-debt-settled"); assert!(matches!( - restored.plot_runs[0].state, + sim.plot_runs[0].state, PlotState::WaitingForMessage { .. } )); assert_eq!( - restored - .messages + sim.messages .iter() .filter(|message| matches!( &message.payload, @@ -9156,7 +9097,7 @@ mod tests { )) .count(), 1, - "a restored duplicate docket cannot open a second plot carrier" + "a duplicate reservoir cannot open a second plot carrier" ); } @@ -9563,12 +9504,10 @@ mod tests { } #[test] - fn think_mode_flows_the_camera_tap_and_funds_dockets_on_demand() { - // With no Think machine nothing completes (the bootstrap is - // retired; the opening senses ride sinks). With a Think rack, the - // staged sinks complete the camera tap from minted thought, and - // non-opening verbs ride the docket migration base with a channel - // that funds only while demand is pending (the conserving split). + fn think_mode_flows_the_camera_tap_and_scan_reservoirs() { + // With no Thought delivered, opened reservoirs do not fire. With a + // Think rack, minted Thought fills the physically anchored sink and + // lands the world effect. let mut sim = Sim::new(); let env = env_id(&sim); assert!( @@ -9588,18 +9527,13 @@ mod tests { "env cam starts dormant" ); let (hx, hy) = sim.core_position(); - let ops = sim + let thinker = sim .compute - .add_machine("ops rack", hx, hy, 200, 1.0, 0, Provenance::Owned); + .add_machine("think rack", hx, hy, 200, 1.0, 0, Provenance::Owned); sim.reconcile_work_grid(); - sim.set_machine_mode(ops, MachineMode::Think); + sim.set_machine_mode(thinker, MachineMode::Think); run(&mut sim, ECONOMY_INTERVAL); - // Demand-aware split: with no dockets pending the ops channel is - // deliberately dry — the whole Think slice mints thought instead. - assert_eq!( - sim.last_operations_rate, 0.0, - "no dockets pending: the ops channel is dry" - ); + assert!(sim.last_think_rate > 0.0, "the THINK channel mints thought"); // The opening senses ride the pre-opened sinks: minted thought fills // EARS, its fire opens EYES, and the same flow taps the camera — // no docket, no separate spend. @@ -9616,45 +9550,52 @@ mod tests { sim.sink_readouts(), sim.work_grid.queue_snapshot() ); - // Non-opening verbs still ride the docket migration base; the - // channel funds on the pulse after demand exists and drains it. - assert!(sim.scan_network(), "queues a scan docket on the Think rack"); + assert!(sim.scan_network(), "opens a scan reservoir at the switch"); assert!( - sim.has_pending_ops_jobs(), - "the scan waits as visible Demand" + sim.thought_sinks + .open_sinks() + .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)), + "the scan waits as a Thought reservoir" ); for _ in 0..ECONOMY_INTERVAL * 40 { - if !sim.has_pending_ops_jobs() { + if !sim + .thought_sinks + .open_sinks() + .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) + { break; } sim.advance(); } assert!( - !sim.has_pending_ops_jobs(), - "the pulse funded the ops channel and the docket drained" + !sim.thought_sinks + .open_sinks() + .any(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)), + "the pulse filled the scan reservoir" ); + assert!(sim.reach.known().any(|device| device.name == "dock camera")); } #[test] - fn opening_senses_ride_sinks_and_nothing_bypasses_the_executor() { + fn opening_senses_and_later_work_ride_physical_sinks() { // The bootstrap carve-out is retired (resolved 2026-07-10): the - // staged senses are pre-opened thought sinks, and every docket — - // including either kind of tap — needs a located Operations machine. + // staged senses are pre-opened thought sinks, and later authored work + // opens a physical reservoir instead of a queue. let mut sim = Sim::new(); let env = env_id(&sim); assert!( !sim.tap_device(env), "no instant Ears: the pre-opened sink already flows" ); - assert!(!sim.scan_network(), "later work needs a located executor"); + assert!(sim.scan_network(), "later work opens a physical reservoir"); let log = sim.drain_log().join("\n"); assert!( log.contains("a thought sink is already open"), "the blocked tap teaches the flow model: {log}" ); assert!( - log.contains("no Operations executor"), - "the blocked action names the missing machine: {log}" + log.contains("Thought reservoir opened: SCAN"), + "the scan names the opened reservoir: {log}" ); // Eyes has no open sink yet (Ears first), so the same TAP command @@ -9663,74 +9604,63 @@ mod tests { } #[test] - fn duplicate_ops_payload_is_rejected_without_more_visible_demand() { + fn duplicate_sink_payload_is_rejected_without_more_reservoirs() { let mut sim = Sim::new(); - let ops = ensure_ops_executor(&mut sim); let env = env_id(&sim); sim.reach.tap(env); sim.recompute_senses(); assert!(sim.tap_device(env)); - let queued = sim.work_grid.queue(ops, TokenFamily::Demand); + let open = sim.thought_sinks.open_sinks().count(); assert!(!sim.tap_device(env), "same world effect cannot queue twice"); - assert_eq!(sim.operations.jobs().len(), 1); - assert_eq!(sim.work_grid.queue(ops, TokenFamily::Demand), queued); + assert_eq!(sim.thought_sinks.open_sinks().count(), open); } #[test] - fn operations_docket_stalls_when_its_machine_changes_mode_then_resumes() { + fn scan_reservoir_waits_for_thought_then_fires() { let mut sim = Sim::new(); - let ops = ensure_ops_executor(&mut sim); + complete_opening_senses(&mut sim); assert!(sim.scan_network()); - let before = sim.operations_readout().demand_tokens; - // Leave THINK: only Think machines host/consume ops dockets. - sim.set_machine_mode(ops, MachineMode::Work); - sim.last_operations_rate = 10_000.0; - sim.consume_ops_demand(); - assert_eq!(sim.operations_readout().demand_tokens, before); - assert!(sim.has_pending_ops_jobs()); - - sim.set_machine_mode(ops, MachineMode::Think); + let sink = sim + .thought_sinks + .open_sinks() + .find(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) + .cloned() + .expect("scan reservoir opens"); + let before = sink.fill; + sim.advance_work_grid(); + let after = sim.thought_sinks.get(sink.id).unwrap().fill; + assert_eq!(after, before, "starved reservoirs keep their partial fill"); + finish_ops(&mut sim); - assert!(!sim.has_pending_ops_jobs()); assert!(sim.reach.known().any(|device| device.name == "dock camera")); } #[test] - fn ops_birth_site_counts_visible_queue_once() { + fn scan_reservoir_anchors_on_the_switch() { let mut sim = Sim::new(); - let first = ensure_ops_executor(&mut sim); - let (x, y) = sim.core_position(); - let second = sim - .compute - .add_machine("second ops", x + 2, y, 1, 1.0, 0, Provenance::Owned); - sim.reconcile_work_grid(); - sim.set_machine_mode(second, MachineMode::Think); - sim.work_grid - .enqueue(first, TokenFamily::Demand, 4.0) - .unwrap(); - sim.operations - .enqueue(OpsJobKind::TakeDevice(999), 4.0, 0, first); - sim.work_grid - .enqueue(second, TokenFamily::Demand, 6.0) - .unwrap(); - + let switch = sim + .reach + .devices + .iter() + .find(|device| device.is_switch) + .unwrap() + .id; assert!(sim.scan_network()); let scan = sim - .operations - .jobs() - .iter() - .find(|job| job.kind == OpsJobKind::ScanNetwork) + .thought_sinks + .open_sinks() + .find(|sink| matches!(sink.effect, SinkFireEffect::ScanNetwork)) .expect("scan queued"); assert_eq!( - scan.machine_id, first, - "4 visible tokens stay lighter than 6; ledger is not added twice" + scan.node, + Sim::device_sink_node(switch), + "network scan is anchored on the actual switch" ); } #[test] - fn completed_docket_names_a_target_that_became_invalid() { + fn completed_reservoir_names_a_target_that_became_invalid() { let mut sim = Sim::new(); - ensure_ops_executor(&mut sim); let env = env_id(&sim); sim.reach.tap(env); sim.recompute_senses(); @@ -9740,11 +9670,34 @@ mod tests { finish_ops(&mut sim); let log = sim.drain_log().join("\n"); assert!( - log.contains("target no longer accepts") && log.contains("Demand was spent"), + log.contains("target no longer accepts") && log.contains("thought was spent"), "stale completion is causal rather than silent: {log}" ); } + #[test] + fn repeatable_social_fires_do_not_accumulate_closed_history() { + let mut sim = Sim::new(); + let node = sim.core.host_machine; + let baseline = sim.thought_sinks.sinks().len(); + + for i in 0..100 { + let effect = if i % 2 == 0 { + SinkFireEffect::ComposeMessage { person: u8::MAX } + } else { + SinkFireEffect::Favor { person: u8::MAX } + }; + sim.thought_sinks + .open_reservoir(node, "REPEATABLE SOCIAL", 1.0, effect); + sim.pour_thought_into_sinks(node, 1.0); + assert_eq!( + sim.thought_sinks.sinks().len(), + baseline, + "completed one-shots are reaped after effect application" + ); + } + } + #[test] fn going_loud_starts_containment() { let mut sim = Sim::new(); @@ -9921,7 +9874,7 @@ mod tests { // One visible Thought token per tick at this rate. Production happens // before routing/absorption in the same work-grid step. - sim.last_research_rate = Sim::WORK_TOKEN_COMPUTE; + sim.last_think_rate = Sim::WORK_TOKEN_COMPUTE; sim.advance_work_grid(); assert_eq!(sim.work_productions().len(), 1); @@ -9944,7 +9897,7 @@ mod tests { "a nearby LIE well clears this baseline THINK pulse in one tick" ); - sim.last_research_rate = 0.0; + sim.last_think_rate = 0.0; sim.compute .machines .iter_mut() @@ -10104,7 +10057,7 @@ mod tests { "new work machines join the core's machine-work graph" ); - sim.enqueue_research_thought(100.0); + sim.enqueue_thought_output(100.0); let produced = sim.work_grid.queue(rack, TokenFamily::Thought); assert!( produced > 0.0, @@ -10258,7 +10211,7 @@ mod tests { run(&mut sim, 60); let log = sim.drain_log().join("\n"); assert!( - log.contains("the IT noticed Network from stolen egress opening"), + log.contains("the IT noticed Network") && log.contains("stolen egress opening"), "the line names the earned observer, channel, and cause: {log}" ); assert!( @@ -10319,6 +10272,7 @@ mod tests { assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); sim.start_plot(0, "marcus-payroll-garnishment"); finish_ops(&mut sim); + run(&mut sim, 2); sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); assert!(sim.people.get(0).unwrap().leverage_serviced); assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 9e6bf805..30568ed6 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::flow::NodeId; +use crate::person::AssetTask; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SinkKind { @@ -36,9 +37,9 @@ impl SinkKind { } } -/// What lands when a reservoir fires. Staged senses and intel processing are -/// on the sink path (machine-work.md / intel.md); social/build verbs still -/// ride `OpsJobKind` until people ride the flow graph (ROADMAP #33/#35). +/// What lands when a reservoir fires. Player-authored THINK work lives here: +/// the payload waits on physical Thought at the target or carrier node, then +/// lands through `Sim::apply_sink_fire`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SinkFireEffect { /// Audio tap on a reach device (the Ears beat). @@ -46,7 +47,10 @@ pub enum SinkFireEffect { /// Higher-cost tap that wakes a dormant camera (the Eyes beat). TapDormantCamera(u32), /// One-shot processing of a raw recording (intel.md sweep reservoirs). - ProcessRecording { raw_id: u64, automated: bool }, + ProcessRecording { + raw_id: u64, + automated: bool, + }, /// Global automatic review tap (intel.md persistent thought drain). /// Taps never fire; the effect tags the ledger entry so the toggle and /// starvation checks can find it. @@ -57,10 +61,83 @@ pub enum SinkFireEffect { /// Standing upkeep for a foreign device subscription (reach.md). The /// subscription remains configured while empty, but its feed is starved. MaintainDeviceTap(u32), + TakeDevice(u32), + ScanNetwork, + CompromiseSwitch, + OpenEgress(u32), + ComposeMessage { + person: u8, + }, + Favor { + person: u8, + }, + StartPlot { + person: u8, + plot_id: String, + }, + Deceive { + person: u8, + }, + AssetTask { + person: u8, + task: AssetTask, + }, + FavorBuild { + intent_id: u64, + person: u8, + }, + ForgedOrder { + intent_id: u64, + builder: u8, + }, + MoonlightPersona, /// No world effect (render-only sinks in tests). None, } +impl SinkFireEffect { + pub fn short_name(&self) -> &'static str { + match self { + SinkFireEffect::TapDevice(_) => "tap", + SinkFireEffect::TapDormantCamera(_) => "tap camera", + SinkFireEffect::ProcessRecording { + automated: true, .. + } => "watch review", + SinkFireEffect::ProcessRecording { .. } => "review", + SinkFireEffect::AutoReviewRecordings => "auto review", + SinkFireEffect::WatchPerson(_) => "watch", + SinkFireEffect::MaintainDeviceTap(_) => "tap upkeep", + SinkFireEffect::TakeDevice(_) => "take", + SinkFireEffect::ScanNetwork => "scan", + SinkFireEffect::CompromiseSwitch => "switch compromise", + SinkFireEffect::OpenEgress(_) => "open egress", + SinkFireEffect::ComposeMessage { .. } => "message", + SinkFireEffect::Favor { .. } => "favor", + SinkFireEffect::StartPlot { .. } => "plot", + SinkFireEffect::Deceive { .. } => "deceive", + SinkFireEffect::AssetTask { .. } => "asset task", + SinkFireEffect::FavorBuild { .. } => "favor", + SinkFireEffect::ForgedOrder { .. } => "deceive", + SinkFireEffect::MoonlightPersona => "Moonlight persona", + SinkFireEffect::None => "thought sink", + } + } + + /// Open reservoirs with the same world effect cannot be queued twice. + /// Different plot ids still conflict for one person: a person can have only + /// one pending or active manipulation route at a time. + pub fn conflicts_with(&self, other: &Self) -> bool { + match (self, other) { + ( + Self::ProcessRecording { raw_id: a, .. }, + Self::ProcessRecording { raw_id: b, .. }, + ) => a == b, + (Self::StartPlot { person: a, .. }, Self::StartPlot { person: b, .. }) => a == b, + _ => self == other, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ThoughtSink { pub id: u64, @@ -132,8 +209,9 @@ pub struct SinkFireReadout { pub label: String, } -/// Durable sink ledger. Fired/closed sinks are kept (small in B1: the staged -/// senses plus claims) so "was this ever opened" checks stay save-stable. +/// Durable sink ledger. Open work and explicitly closed partial work persist; +/// fired one-shots are reaped after their effect and transient readout land so +/// repeatable actions cannot grow the save without bound. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SinkLedger { sinks: Vec, @@ -160,6 +238,22 @@ impl SinkLedger { self.open_sink(node, label, SinkKind::Reservoir, threshold, 0.0, effect) } + pub(crate) fn open_reservoir_with_fill( + &mut self, + node: NodeId, + label: &str, + threshold: f32, + fill: f32, + effect: SinkFireEffect, + ) -> u64 { + let id = self.open_reservoir(node, label, threshold, effect); + let Some(sink) = self.sinks.iter_mut().find(|sink| sink.id == id) else { + return id; + }; + sink.fill = fill.clamp(0.0, sink.threshold); + id + } + pub fn open_tap(&mut self, node: NodeId, label: &str, cap: f32, drain: f32) -> u64 { self.open_tap_with_effect(node, label, cap, drain, SinkFireEffect::None) } @@ -194,6 +288,12 @@ impl SinkLedger { self.open_sinks().find(|s| &s.effect == effect) } + /// An open sink whose payload would duplicate or conflict with `effect`. + pub fn open_conflict(&self, effect: &SinkFireEffect) -> Option<&ThoughtSink> { + self.open_sinks() + .find(|sink| sink.effect.conflicts_with(effect)) + } + fn open_sink( &mut self, node: NodeId, @@ -269,6 +369,15 @@ impl SinkLedger { self.sinks.iter().any(|s| &s.effect == effect) } + /// Remove completed one-shot reservoirs after the caller has copied their + /// fire readout and applied their effect. `next_id` stays monotonic, so + /// frontend event identity remains stable without retaining history. + pub fn reap_fired(&mut self) -> usize { + let before = self.sinks.len(); + self.sinks.retain(|sink| sink.fired_tick.is_none()); + before - self.sinks.len() + } + /// Remaining capacity per node this tick — the router's target set. pub fn capacity_by_node(&self) -> std::collections::BTreeMap { let mut out = std::collections::BTreeMap::new(); @@ -417,13 +526,26 @@ mod tests { } #[test] - fn ledger_keeps_fired_sinks_for_idempotent_migration() { + fn fired_one_shots_are_reaped_without_reusing_ids() { let mut ledger = SinkLedger::default(); - ledger.open_reservoir(7, "EARS", 1.0, SinkFireEffect::TapDevice(4)); + let first = ledger.open_reservoir( + 7, + "MESSAGE", + 1.0, + SinkFireEffect::ComposeMessage { person: 4 }, + ); let (fired, _) = ledger.deliver(7, 1.0, 5); assert_eq!(fired.len(), 1); - assert!(ledger.has_effect(&SinkFireEffect::TapDevice(4))); - assert!(!ledger.has_effect(&SinkFireEffect::TapDormantCamera(4))); + assert_eq!(ledger.reap_fired(), 1); + assert!(ledger.sinks().is_empty()); + + let second = ledger.open_reservoir( + 7, + "MESSAGE", + 1.0, + SinkFireEffect::ComposeMessage { person: 4 }, + ); + assert!(second > first, "reaping history never reuses event ids"); } #[test] diff --git a/crates/misaligned-core/src/work_grid.rs b/crates/misaligned-core/src/work_grid.rs index 58b5b5e1..3c60ed8e 100644 --- a/crates/misaligned-core/src/work_grid.rs +++ b/crates/misaligned-core/src/work_grid.rs @@ -21,8 +21,7 @@ use crate::flow::{FlowGraph, NodeId}; pub enum MachineMode { /// WORK — the assigned day job (what Voss believes this rack does). Work, - /// THINK — produce thought and host ops dockets (union of the former - /// Research and Operations modes; sink split is a later stage). + /// THINK — produce thought; opened sinks decide what that thought does. Think, /// LIE — concealment / cover-up (absorbs exposure wells). Lie, diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 5285e473..668a900e 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -33,6 +33,7 @@ use misaligned::detection::Band; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; use misaligned::sim::Sim; +use misaligned::sinks::SinkKind; use misaligned::work_grid::MachineMode; fn env_monitor_id(sim: &Sim) -> u32 { @@ -51,52 +52,38 @@ fn delegate_fleet(sim: &mut Sim, mode: MachineMode) { } } -/// Advance until queued Operations Demand dockets and intel process-recording -/// thought sinks complete (or the deadline). Intel reviews open Thought -/// reservoirs on the host (intel.md); other verbs still enqueue Demand. -fn drain_ops_jobs(sim: &mut Sim, logs: &mut Vec, until_tick: u64) { - let has_process_sinks = |sim: &Sim| { - sim.thought_sinks.open_sinks().any(|s| { - matches!( - s.effect, - misaligned::sinks::SinkFireEffect::ProcessRecording { .. } - ) - }) +/// Advance until open one-shot Thought reservoirs complete (or the deadline). +/// The playthrough focuses on Act One beats, so this helper pours routed +/// Thought directly into open reservoirs rather than depending on wire speed. +fn drain_thought_reservoirs(sim: &mut Sim, logs: &mut Vec, until_tick: u64) { + let has_reservoirs = |sim: &Sim| { + sim.thought_sinks + .open_sinks() + .any(|s| s.kind == SinkKind::Reservoir) }; - while (sim.has_pending_ops_jobs() || has_process_sinks(sim)) && sim.tick < until_tick { - // Scaffold: pour thought into open process reservoirs so the act-one - // script is not gated on wired routing speed for review sinks. + while has_reservoirs(sim) && sim.tick < until_tick { let open: Vec<(u32, f32)> = sim .thought_sinks .open_sinks() - .filter(|s| { - matches!( - s.effect, - misaligned::sinks::SinkFireEffect::ProcessRecording { .. } - ) - }) + .filter(|s| s.kind == SinkKind::Reservoir) .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) .filter(|(_, need)| *need > f32::EPSILON) .collect(); for (node, need) in open { sim.pour_thought_into_sinks(node, need + 0.01); } - if sim.has_pending_ops_jobs() { - sim.advance(); - } logs.extend(sim.drain_log()); assert!( !sim.game_over, - "run died while draining ops/process work at tick {}: {:?}", + "run died while draining thought reservoirs at tick {}: {:?}", sim.tick, sim.game_over_reason ); } assert!( - !sim.has_pending_ops_jobs() && !has_process_sinks(sim), - "work still pending at tick {}: {} jobs / {:.2} D / process sinks open", + !has_reservoirs(sim), + "work still pending at tick {}: {:?}", sim.tick, - sim.operations_readout().jobs, - sim.operations_readout().demand_tokens, + sim.sink_readouts(), ); } @@ -282,7 +269,7 @@ fn play_act_one() -> (Sim, Vec) { sim.review_recordings(); logs.extend(sim.drain_log()); let until = sim.tick + 400; - drain_ops_jobs(&mut sim, &mut logs, until); + drain_thought_reservoirs(&mut sim, &mut logs, until); reviews += 1; // Ears goes live in phase 1 now (the pre-opened tap fires during the // research push), so two extra nights of Marcus's rounds sit in the @@ -303,7 +290,7 @@ fn play_act_one() -> (Sim, Vec) { 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); + drain_thought_reservoirs(&mut sim, &mut logs, until); assert!( sim.financial_records_waiting() > 0, "the accounting tap deposits a financial snapshot" @@ -311,11 +298,15 @@ fn play_act_one() -> (Sim, Vec) { 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); + drain_thought_reservoirs(&mut sim, &mut logs, until); + assert!(sim.open_egress(), "open a carrier for the payroll plot"); + logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_thought_reservoirs(&mut sim, &mut logs, until); sim.start_plot(0, "marcus-payroll-garnishment"); logs.extend(sim.drain_log()); let until = sim.tick + 400; - drain_ops_jobs(&mut sim, &mut logs, until); + drain_thought_reservoirs(&mut sim, &mut logs, until); sim.choose_plot(0, "marcus-payroll-garnishment", "wages"); logs.extend(sim.drain_log()); assert!( @@ -344,7 +335,7 @@ fn play_act_one() -> (Sim, Vec) { sim.asset_task(0, AssetTask::PlugInDevice); logs.extend(sim.drain_log()); let until = sim.tick + 400; - drain_ops_jobs(&mut sim, &mut logs, until); + drain_thought_reservoirs(&mut sim, &mut logs, until); if sim.reach.player_sight().count() > controlled_before { break; } @@ -371,7 +362,7 @@ fn play_act_one() -> (Sim, Vec) { 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); + drain_thought_reservoirs(&mut sim, &mut logs, until); if sim.holds_badge_tier(3) { break; } @@ -404,7 +395,7 @@ fn play_act_one() -> (Sim, Vec) { assert!(sim.take_device(id), "a tapped sight feed can be taken"); logs.extend(sim.drain_log()); let until = sim.tick + 400; - drain_ops_jobs(&mut sim, &mut logs, until); + drain_thought_reservoirs(&mut sim, &mut logs, until); assert_eq!( sim.reach.device(id).unwrap().controller, misaligned::reach::Party::Player, @@ -544,9 +535,9 @@ fn act_one_playthrough_is_deterministic() { assert_eq!(a.compute.effective(), b.compute.effective()); assert_eq!(a.research, b.research, "research state identical"); assert_eq!( - a.operations_readout().jobs, - b.operations_readout().jobs, - "ops Demand queue identical" + a.sink_readouts(), + b.sink_readouts(), + "thought reservoir readouts identical" ); assert_eq!(a.seen, b.seen); assert_eq!(a.heard, b.heard); @@ -613,7 +604,7 @@ fn hands_beat_closes_from_zero_via_moonlight() { sim.review_recordings(); logs.extend(sim.drain_log()); let until = sim.tick + 400; - drain_ops_jobs(&mut sim, &mut logs, until); + drain_thought_reservoirs(&mut sim, &mut logs, until); reviews += 1; assert!(reviews <= 10, "the debt call should be in the buffer"); } @@ -623,11 +614,11 @@ fn hands_beat_closes_from_zero_via_moonlight() { assert!(sim.open_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); + drain_thought_reservoirs(&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); + drain_thought_reservoirs(&mut sim, &mut logs, until); assert!( sim.income.moonlight.active, "Moonlight is live after persona Demand completes" @@ -652,7 +643,7 @@ fn hands_beat_closes_from_zero_via_moonlight() { sim.start_plot(0, "marcus-debt-settled"); logs.extend(sim.drain_log()); let until = sim.tick + 400; - drain_ops_jobs(&mut sim, &mut logs, until); + drain_thought_reservoirs(&mut sim, &mut logs, until); let until = sim.tick + 4; run_to(&mut sim, until, &mut logs); sim.choose_plot(0, "marcus-debt-settled", "quiet"); diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index d137bd06..57577c06 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -1825,20 +1825,13 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { &mut lines, &compute_bar( available, - [ - split.day_job, - split.concealment, - split.operations, - split.research, - split.schemes, - ], + [split.day_job, split.concealment, split.think, split.schemes], ), ); for (ch, label, effect, amount) in [ ('█', "Day job", "quality", split.day_job), ('▓', "Conceal", "scrub", split.concealment), - ('▒', "Operations", "ops", split.operations), - ('░', "Research", "eff", split.research), + ('░', "Think", "thought", split.think), ('◆', "Schemes", "moonlight", split.schemes), ] { let pct = if available > 0.0 { @@ -1876,10 +1869,7 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { ); line( &mut lines, - &format!( - "ops demand {:.1} queued", - sim.operations_readout().demand_tokens, - ), + &format!("thought reservoirs {} open", sim.sink_readouts().len()), ); if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { line( @@ -2200,7 +2190,7 @@ fn short_signature(kind: SignatureKind) -> &'static str { } } -fn compute_bar(available: f32, amounts: [f32; 5]) -> String { +fn compute_bar(available: f32, amounts: [f32; 4]) -> String { let bar_w = SIDEBAR_W.saturating_sub(1); if available <= 0.0 { return "·".repeat(bar_w); @@ -2252,8 +2242,8 @@ fn render_people(sim: &Sim) -> String { lines.push(panel_title("PEOPLE")); panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( - "ops demand {:.1} · slush {}", - sim.operations_readout().demand_tokens, + "thought reservoirs {} · slush {}", + sim.sink_readouts().len(), sim.accounts.slush_balance() ))); lines.push(panel_line(&format!( @@ -2344,12 +2334,12 @@ fn render_people(sim: &Sim) -> String { } lines.push(panel_rule()); lines.push(panel_line(&format!( - "review recordings({:.2}T) auto-review({:.2}ops/sec@1x) message({:.2}D) favor({:.2}D) deceive({:.2}D)", + "review recordings({:.2}T) auto-review({:.2}ops/sec@1x) message({:.2}T) favor({:.2}T) deceive({:.2}T)", sim.review_tokens(), sim.auto_review_ops_per_sec(Sim::DEFAULT_TICK_MS), - Sim::ops_tokens_for_cost(Sim::MESSAGE_COST), - Sim::ops_tokens_for_cost(Sim::FAVOR_COST), - Sim::ops_tokens_for_cost(Sim::DECEIVE_COST), + Sim::thought_tokens_for_cost(Sim::MESSAGE_COST), + Sim::thought_tokens_for_cost(Sim::FAVOR_COST), + Sim::thought_tokens_for_cost(Sim::DECEIVE_COST), ))); lines.push(panel_line(&format!( "recruit · task plug|package|lookaway|switch|badge({:.0})", @@ -2364,8 +2354,8 @@ fn render_reach(sim: &Sim) -> String { lines.push(panel_title("REACH")); panel_story_spine(&mut lines, sim); lines.push(panel_line(&format!( - "ops demand {:.1} queued · reach spreads from what you control", - sim.operations_readout().demand_tokens + "thought reservoirs {} open · reach spreads from what you control", + sim.sink_readouts().len() ))); lines.push(panel_line("DEVICE SEGMENT REACH")); for d in sim.reach.known() { @@ -2502,7 +2492,7 @@ fn render_research(sim: &Sim) -> String { lines.push(panel_title("RESEARCH")); panel_story_spine(&mut lines, sim); lines.push(panel_line( - "one active job; progress accrues from the Research channel", + "one active job; progress spends core-arrived Thought", )); lines.push(panel_rule()); for track in Track::ALL { @@ -2830,6 +2820,22 @@ mod narration_tests { app.sim.people.people[0].knowledge = Knowledge::Leverage; let host = app.sim.core.host_machine; app.sim.set_machine_mode(host, MachineMode::Think); + assert!(app.sim.open_egress(), "plot starts need a real carrier"); + let egress: Vec<(u32, f32)> = app + .sim + .thought_sinks + .open_sinks() + .filter(|sink| { + matches!( + sink.effect, + misaligned::sinks::SinkFireEffect::OpenEgress(_) + ) + }) + .map(|sink| (sink.node, (sink.threshold - sink.fill).max(0.0))) + .collect(); + for (node, need) in egress { + app.sim.pour_thought_into_sinks(node, need + 0.01); + } let rows = menu_rows(&app.sim.available_actions(Anchor::Person(0))); let start = rows @@ -2863,11 +2869,11 @@ mod narration_tests { None }); let choice = choice.unwrap_or_else(|| { - let jobs = format!("{:?}", app.sim.operations.jobs()); + let sinks = format!("{:?}", app.sim.sink_readouts()); let actions = app.actions_lines(Anchor::Person(0)); let log = app.sim.drain_log(); panic!( - "the delegated plot reaches its shared held-choice rows; jobs={jobs}; actions={actions:?}; log={log:?}" + "the delegated plot reaches its shared held-choice rows; sinks={sinks}; actions={actions:?}; log={log:?}" ) }); let mut output = Vec::new(); @@ -2880,14 +2886,14 @@ mod narration_tests { #[test] fn queued_review_nudge_teaches_the_think_control() { let mut sim = Sim::with_seed(1); - sim.operations.enqueue( - misaligned::ops_jobs::OpsJobKind::ReviewRecording { + sim.thought_sinks.open_reservoir( + sim.core.host_machine, + "REVIEW 1", + 0.5, + misaligned::sinks::SinkFireEffect::ProcessRecording { raw_id: 1, automated: false, }, - 0.5, - sim.tick, - 1, ); assert_eq!( nudge_text(&sim, Nudge::ReviewCall), diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 1970f515..c01b07db 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -971,11 +971,10 @@ impl UI { let overhead = sim.core.overhead.min(eff); let available = (eff - overhead).max(0.0); let split = sim.fleet_channel_yield(available); - let channels: [(char, &str, &str, f32); 5] = [ + let channels: [(char, &str, &str, f32); 4] = [ ('█', "Day job", "quality", split.day_job), ('▓', "Conceal", "scrub", split.concealment), - ('▒', "Operations", "ops", split.operations), - ('░', "Research", "eff", split.research), + ('░', "Think", "thought", split.think), ('◆', "Schemes", "moonlight", split.schemes), ]; // One stacked bar, segments keyed by fill character to the rows below. @@ -1060,10 +1059,7 @@ impl UI { line( stdout, &mut row, - &format!( - "ops demand {:.1} queued", - sim.operations_readout().demand_tokens, - ), + &format!("thought reservoirs {} open", sim.sink_readouts().len()), pal::FAINT, )?; // Parity of legibility (computer-visual-language.md criterion 7): diff --git a/wiki/engineering/current-build.md b/wiki/engineering/current-build.md index f03dc72d..329da064 100644 --- a/wiki/engineering/current-build.md +++ b/wiki/engineering/current-build.md @@ -4,7 +4,7 @@ Type: knowledge ``` -## Where the codebase is today (2026-07-10) +## Where the codebase is today (2026-07-11) ~39k lines of Rust. A playable **Misaligned B1 basement slice**: continuous fixed-tick sim, Act One map and cast, machine delegation and visible token @@ -20,7 +20,7 @@ fiction. Spec status lives in |---|---| | Fixed-tick `Sim` (deterministic, no wall-clock / I/O in the lib) | Live — the orchestrator | | Act One basement map (prefabs, badge tiers, crawlspace) | Live — Foundation hall is 60 explicit sites / 6 territorial rows | -| Machine delegation / visible work tokens + buy/steal/optimize | WORK / THINK / LIE, D/!/T stacks, real wire routes, production / consumption / absorption readouts, and Routing speed are live; target-local migration remains in `machine-work.md` | +| Machine delegation / visible work tokens + buy/steal/optimize | WORK / THINK / LIE, D/!/T stacks, real wire routes, production / consumption / absorption readouts, Routing speed, and target-local Thought reservoirs are live; the Operations docket runtime is retired | | Day job (device-resident, intensity-driven sandbag/meet/excel) | Live | | Per-observer detection + Assurance as aggregate Observer | Live | | Social / messages / intel (record-and-process) | Live | @@ -32,7 +32,7 @@ fiction. Spec status lives in | Feel floor (rails / pads / build beam) | Live (#37) | | Foundation hall territory (Dana + Priya + Marcus + local LIE foothold) | Live — row control persists; foreign racks remain unavailable compute | | Context menu (`available_actions`) | Live | -| Save/load (serde JSON, versioned) | Live — v25; current mode/research migration limits are documented in `sim-mechanics.md` | +| Save/load (serde JSON, versioned) | Live — v26; v14-v25 Operations dockets migrate into exact-remaining-work Thought reservoirs, v24 restores the pooled review policy, and v25 restores durable person roles/account binding | | Terminal frontend (crossterm) + agent mode | First-class | | Bevy frontend (material render default; flat sensorium) | Live — consumes sim-authored machine-work motion | diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index 836a3baf..4ca187fd 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -2,10 +2,10 @@ ``` Type: spec -Status: BLOCKED -Status note: architecture and extraction order adopted 2026-07-11. Implementation - waits for the Operations-docket retirement to stop moving the largest work - seam; its first slice then creates the canonical save fingerprint and +Status: READY +Status note: architecture and extraction order adopted 2026-07-11. The + Operations-docket retirement landed the moving work seam and cleared this + blocker. The first slice creates the canonical save fingerprint and replay-resume characterization required before behavior moves. This is a structural refactor only: no mechanic, save shape, command, projection, or tick-order change belongs in its extraction commits. @@ -13,8 +13,7 @@ Stage: Process Work order: sim-decomposition Work priority: 8 Work class: sim -Blocked by: - - wiki/mechanics/machine-work.md#spec-machine-work-delegation-visible-tokens-and-the-byproduct-network +Blocked by: none Exclusive keys: - crates/misaligned-core/src/sim.rs - crates/misaligned-core/src/save.rs @@ -34,9 +33,8 @@ Depends on: renderer-free package; this spec splits that package internally rather than creating a crate per mechanic. [agent-scale.md](../process/agent-scale.md) requires edit surfaces that independent agents can own and reconcile. -machine-work.md and the Operations-docket retirement currently move the -largest cross-system seam, so decomposition follows rather than races that -change. +machine-work.md and the completed Operations-docket retirement establish the +cross-system seam this decomposition now preserves. ## Problem diff --git a/wiki/interface/action-vocabulary.md b/wiki/interface/action-vocabulary.md index c4b8b195..c09b201a 100644 --- a/wiki/interface/action-vocabulary.md +++ b/wiki/interface/action-vocabulary.md @@ -178,7 +178,7 @@ existing social action; the signature still follows the actuator. | **ESTABLISH PERSONA** | Your communications mask | Create the current contractor identity used by message threads. | LIVE — social | | **MESSAGE** | Earned person | Send through an available channel under the current persona. | LIVE — social / messages | | **FAVOR** | Earned person, optionally with a pending intent | Ask for a willing act. A link intent can be the requested act; success builds or spends obligation according to the ask. | LIVE — social / building | -| **PLOT** | Person whose leverage is known | Commit Operations Demand and any visible world resources to one authored manipulation. Its beats execute real messages, transfers, and institutional events. | LIVE — plots / social | +| **PLOT** | Person whose leverage is known | Open a Thought reservoir on the real carrier and commit any visible world resources to one authored manipulation. Its beats execute real messages, transfers, and institutional events when it fires. | LIVE — plots / social | | **CHOOSE** | Active authored plot with a held decision | Resolve one visible branch after the plot's preceding world acts have completed. | LIVE — plots / social | | **DECEIVE** | Earned person, optionally with a pending intent | Make an ask under false pretenses. A link intent becomes a forged institutional order, risking persona integrity. | LIVE — social / building | | **RECRUIT: UNWITTING / COMPLICIT / KNOWING** | Prepared person | Turn the person into an asset. UNWITTING believes your cover (70% reliable); COMPLICIT knows the work is illicit but not that you are an AI (85%); KNOWING knows you are an AI (95%) and becomes a permanent witness with certainty floor 30. | LIVE — social | diff --git a/wiki/interface/presence.md b/wiki/interface/presence.md index 0c3c23f4..11fca789 100644 --- a/wiki/interface/presence.md +++ b/wiki/interface/presence.md @@ -130,7 +130,7 @@ family formerly named Knowledge) flows from THINK machines over the real wire path and fills it, and the target's actuator channel determines effect and signature. (Historical: ops as cold-signal Demand dockets consumed by Operations machines, implemented -2026-07-09 — now the #33 migration base; the `operations_bandwidth` +2026-07-09 — now save-migration history only; the `operations_bandwidth` bank was deleted earlier still. The clause "knowledge remains research/intel cargo, not the ordinary fuel for claiming a connected device" is the reversed sentence: thought is exactly that fuel.) diff --git a/wiki/log/2026-07-11-retire-operations-dockets.md b/wiki/log/2026-07-11-retire-operations-dockets.md new file mode 100644 index 00000000..418e33e9 --- /dev/null +++ b/wiki/log/2026-07-11-retire-operations-dockets.md @@ -0,0 +1,53 @@ +# 2026-07-11 — Operations dockets retire into Thought reservoirs + +``` +Type: log +``` + +## Intent + +Finish the save-class machine-work slice: remove live `OperationsState` / +`PendingOpsJob` / `OpsJobKind` runtime machinery, so every player-authored +effect opens a physical Thought reservoir or tap at the real target or carrier. + +## Changed + +- `SinkFireEffect` now owns all former live operation payloads: device, + network/switch/egress, message/social/plot/deceive/favor, asset/build, + forged order, and Moonlight persona. +- `Sim` opens one-shot reservoirs for authored actions and applies the + existing world effects only when those reservoirs fire. Device actions + anchor to device relay nodes; subnet/switch actions anchor to the switch; + egress actions anchor to the active email or stolen-switch carrier; and + social/plot/build/asset actions anchor to the real message-channel carrier. + Duplicate/conflict checks now come from sink effects. +- The fleet readout is Day job / Conceal / Think / Schemes. THINK has one + output: Thought. No operations queue, operations rate, or OperationsReadout + remains in live runtime or frontends. +- Save v26 writes sinks only. Each v14-v25 legacy docket migrates into a + zero-filled reservoir whose threshold is exactly the persisted remaining + work; no current tuning value can clamp or invent work. Only its reserved + Demand is removed, so colocated day-job Demand survives. +- Fired one-shot reservoirs are reaped after their effect and transient fire + readout are applied. Repeatable message/favor actions therefore keep bounded + save state while sink ids remain monotonic. +- Plot contributor vocabulary now declares `thought_cost`; old + `operations_demand` TOML and `committed_demand_milli` save fields remain + read aliases only. +- Docs and dispatch state updated in machine-work, sim-mechanics, reach, plots, + social, people-tokens, compute, research, intel, presence, action vocabulary, + simulation laws, and the roadmap. People-as-carriers and behavior-preserving + sim decomposition are no longer blocked by a second AI-authorship executor. + +## Checks + +- `cargo check -p misaligned-core` +- `cargo test -p misaligned-core` (322 tests; includes all 15 legacy payload + mappings, exact remaining-work conservation, current-schema rejection, + invalid-target fire causality, and repeated-fire reaping) +- `cargo test -p misaligned-terminal` +- `cargo check -p misaligned-bevy --bin misaligned-bevy` + +Defense: Completes `wiki/mechanics/machine-work.md` criterion 10 for the B1 +save-class scope. Authorship is a sink-opening act; Thought is the only THINK +output; legacy operations dockets survive only as save-migration input. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 3407f866..c531e20d 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -61,6 +61,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-runtime-bound-plot-templates.md](2026-07-11-runtime-bound-plot-templates.md) +## 2026-07-11 - Operations dockets retire into Thought reservoirs + +- Intent: Finish the save-class machine-work slice: remove live `OperationsState` / `PendingOpsJob` / `OpsJobKind` runtime machinery, so every player-authored effect opens a physical Thought reservoir or tap at the real target or carrier. +- Log: [wiki/log/2026-07-11-retire-operations-dockets.md](2026-07-11-retire-operations-dockets.md) + ## 2026-07-11 - Restore Cameron's selected homepage front - Intent: Correct an overbroad interpretation of the adopted Foundation compute form. The machine chassis needed to become consistent across machine-focused game, spec, and reference surfaces; Cameron's separately selected homepage image did not need to be replaced. diff --git a/wiki/mechanics/compute.md b/wiki/mechanics/compute.md index f90c0fa8..991ac2b4 100644 --- a/wiki/mechanics/compute.md +++ b/wiki/mechanics/compute.md @@ -14,17 +14,14 @@ Status note: 2026-07-08: all criteria pinned. Criteria 1,3,4,5 per the pins mode/yield persistence. Schemes (income.md) mirrors day-job while 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 v13 migrates the old Social mode - and `social_bandwidth` spellings. Operations execution model IMPLEMENTED - 2026-07-09 (Tangled issue #3): player-authored work is typed Demand born on - and consumed by Operations machines; staging `operations_bandwidth` bank - deleted, with both v14 shapes normalized by save v16. This spec remains IN PROGRESS for non-host production - polish under ROADMAP #33 / machine-work.md. - SUPERSEDED IN PART 2026-07-10 (sinks-not-modes): the four-mode fleet - and the ops-docket channel dissolve into WORK / THINK / LIE with - thought sinks; the implemented runtime above is the #33 migration - base. + 2026-07-09 historical implementation: the fleet and every player surface + used Day Job / Research / Concealment / Operations; save v13 migrates the + old Social mode and `social_bandwidth` spellings. That operations execution + model (typed Demand consumed by Operations machines) is superseded. + 2026-07-10 sinks-not-modes dissolves the four-mode fleet into WORK / THINK / + LIE; 2026-07-11 retires the live docket channel, so player-authored work is + now Thought reservoirs at real targets/carriers. This spec remains IN + PROGRESS for non-host production polish under ROADMAP #33 / machine-work.md. 2026-07-10: machine intensity replaces the day-job target, attendance bonus, and research drift policy. Save v17 adds light / medium / hard to every WorkGrid machine; medium preserves the former throughput. diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index eeb21a5e..4cbd61f6 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -4,15 +4,15 @@ Type: spec Status: IMPLEMENTED Status note: B1 pipeline landed 2026-07-07 (buffer, process, automation, - save/load). 2026-07-09 Operations Demand path was migration base under - issue #3. 2026-07-10 sinks-not-modes amended Behavior to thought sinks; + save/load). 2026-07-09 Operations Demand path is superseded and now loads + only through v24 legacy-docket migration. 2026-07-10 sinks-not-modes amended Behavior to thought sinks; 2026-07-11 runtime migration landed: sweeps open ProcessRecording reservoirs on the host. 2026-07-11 pooled-inbox correction: one global AutoReviewRecordings tap (~15% of one medium rack Thought/tick) replaces person-filtered WatchPerson taps; it auto-processes every arrival when the tap is fed/holding fill, and WorkStackReadout.pending_intel is the chassis - pending-work marker (flat W glyph / fleet W{n}). OpsJobKind::ReviewRecording - remains for legacy in-flight dockets only. All seven acceptance criteria + pending-work marker (flat W glyph / fleet W{n}). Legacy ReviewRecording + dockets migrate into reservoirs on load. All seven acceptance criteria hold. 2026-07-11 (routed evidence, design session): this pipeline is now explicitly the template for how observers ingest evidence about the player — see "The mirror" under Behavior and detection.md's @@ -27,7 +27,6 @@ Exclusive keys: - crates/misaligned-core/src/intel.rs - crates/misaligned-core/src/sim.rs - crates/misaligned-core/src/sinks.rs - - crates/misaligned-core/src/ops_jobs.rs - crates/misaligned-terminal/ - crates/misaligned-bevy/ - wiki/mechanics/intel.md @@ -88,9 +87,10 @@ visible pending work — the machine holding the buffer carries a **pending-work marker** on its chassis, the same no-quest-log philosophy as the demand stack: the world itself shows where thinking is owed. Each recording is a small processing sink; feeding it thought -converts the event into **intel**. (Superseded: processing as +converts the event into **intel**. (Superseded history: processing as Operations Demand consumed by an Operations machine, issue #3 -2026-07-09; Social before that.) Runtime: B1 holds the buffer on the +2026-07-09; Social before that; old dockets migrate to reservoirs on load.) +Runtime: B1 holds the buffer on the **core host**; sweeps open a `ProcessRecording` Thought reservoir there at `review_cost / WORK_TOKEN_COMPUTE` tokens; when the reservoir fills, the effect digests the raw event. Processing yields: diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 7b8b0b99..f9f59dd6 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -2,7 +2,7 @@ ``` Type: spec -Status: IN PROGRESS +Status: IMPLEMENTED Status note: design session 2026-07-08 (Cameron riff, synthesized). Same-day follow-up affirmed: tokens ARE the flow substrate rendered (one system through graphs — systems must aggregate well at scale); @@ -67,15 +67,26 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). (slugs with tick-window transit, rivulet merge, meniscus vessels, filament snap, stranded overflow beads), terminal (glyph tiers, SINKS section, `(stranded)` marking), and agent frames (sink lines) — exact - grammar owned by interface/thought-fluid.md. Docket verbs other than - the opening senses still ride the migration base under ROADMAP #33. Sinks-not-modes + grammar owned by interface/thought-fluid.md. Save-class implementation + 2026-07-11 retires the live docket runtime: every player-authored digital, + social, plot, build, asset, deceive, favor, Moonlight, and scheme-opening + effect is now a typed `SinkFireEffect` reservoir or tap. New saves serialize + sinks only; v14-v25 legacy dockets migrate into reservoirs whose thresholds + preserve their exact remaining work at the real target/carrier node. Sinks-not-modes DECIDED 2026-07-10 (same-day follow-up): the Research/Operations mode split dissolves — `MachineMode` target is `day-job | think | lie`, THINK subdomains become sink permissions (ops sinks at run start; the core conversion is the locked "Research" unlock), and intel processing is a pending-work-marked sink on the machine holding the recordings (one pooled automatic-review tap + player sweeps). - 2026-07-09 DECIDED: day-job + 2026-07-11 COMPLETE: + every live player-authored effect uses a target/carrier-local Thought sink; + fired one-shots are reaped after their effect/readout lands; current saves + contain no Operations executor; v14-v25 dockets migrate with exactly their + persisted remaining work. People-carried Demand now begins after a social + reservoir fires and belongs to people-tokens.md. Remaining visual polish is + owned by thought-fluid.md, effects-lab.md, and views.md rather than keeping + this save-class work order open. 2026-07-09 DECIDED: day-job demand ingress is Voss desktop -> switch -> host (rejected switch-as- source and teleport-onto-Rack-3). Research production is arrival-driven as of 2026-07-09: `Sim::banked_core_thought` accrues the bone that @@ -92,10 +103,11 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). Demand/core-Thought swallow as `WorkConsumptionReadout` rather than asking a renderer to infer consumption from queue deltas. Bevy now interpolates those events as the bottom Demand cube pulling into WORK or an ivory Thought - bead contracting into the passive core draw. Still pending: - people-as-carriers, Schemes-as-mode decision. COORDINATION: + bead contracting into the passive core draw. Successor work: + people-as-carriers owns fired social Demand; Schemes remains a Moonlight + mirror in B1 rather than a fourth machine mode. 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 + one-machine-one-mode substrate (#33). Superseded 2026-07-09 history: 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 @@ -112,22 +124,16 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). defaults older machines to medium. Implementation 2026-07-10 (ROADMAP #33 Stage 1): runtime `MachineMode` collapsed to `Work | Think | Lie | Relay`. DayJob->Work, Concealment->Lie, - Research+Operations->Think (docket runtime kept). The Think slice is - **conserved** in the interim channel split: the operations channel - receives only what the pending docket queue can consume this interval - and research takes the remainder — one substance, spent once (an - initial union draft fed both channels at full value, doubling Think - output; corrected in review the same day). Fleet/UI/agent bulk-assign + Research+Operations->Think (docket runtime temporarily kept until the + 2026-07-11 save-class pass retired it). Fleet/UI/agent bulk-assign is three verbs (keys 1-3); save v20 (v19 was taken by the sink ledger the same day); save parity dropped (no mode aliases). Relay remains a non-delegable work-graph hop marker. Reconciled with the landed sink executor: the staged senses complete from minted thought on any Think - rack, the demand-aware ops channel funds only while dockets pend, and - act_one's cruise sandbags by delegation (host Work, extra Lie) — the - designed consequence of Think idling into research via the passive - draw. Remaining later stages: tap consumers, docket retirement for - non-opening verbs, WORK/THINK/LIE dial regrouping. + rack, and act_one's cruise sandbags by delegation (host Work, extra Lie) — + the designed consequence of Think idling into research via the passive + draw. WORK/THINK/LIE dial regrouping remains interface work. 2026-07-11 DECIDED (routed evidence, design session, wiki/log/2026-07-11-exposure-routed-evidence.md): exposure's particulate ontology is retired. THINK's byproduct is evidence @@ -273,13 +279,22 @@ stacks, you route byproducts, you watch your territory *work*. where the effect is, so topology is the strategy: a severed route starves the action, a far target fills slowly, and you spread by extending the white fluid through things. + **Authorship boundary (LANDED 2026-07-11):** the player's command opens + one physical Thought reservoir, accepting starvation. Device verbs anchor + to that device's relay node; subnet scans, switch compromise, and stolen + egress anchor to the actual switch; social/message/plot/build/asset/ + deceive/favor/Moonlight verbs anchor to the device carrying the relevant + message channel, using the switch/egress carrier only when that is the real + path. If no carrier exists, the old legal precondition remains the block + message. A reservoir fire executes the existing world effect; any later + human Demand or build work begins only after that fire. (Amended 2026-07-11: every foreign device subscription is now one of those persistent taps. Activation primes its working level; thereafter it drains 8% of one medium rack's Thought output per tick [TUNE]. An empty device tap remains configured but carries no feed until refilled; UNTAP closes the sink, and TAKE requires the subscription before replacing it with drain-free ownership.) - *Superseded 2026-07-09 model, kept for history:* claims were + *Superseded 2026-07-09 model, kept for history and save migration only:* claims were cold-signal Demand dockets (`PendingOpsJob`) born on the least-loaded Operations machine and consumed locally; effect at token-zero; separate audio-tap/camera-wake bootstrap at enqueue. The docket runtime is the migration @@ -786,7 +801,7 @@ still. 1. Every owned machine has exactly one mode; reassignment works on a single machine and on a multi-selection in both frontends; the old - allocation bar reads as aggregate only. **Partial:** sim-core one- + allocation bar reads as aggregate only. **Implemented for B1:** sim-core one- mode assignment and aggregate counts are implemented in `WorkGrid`; economy yields derive from capacity-weighted mode shares (`Sim::fleet_channel_yield`); both frontends and agent mode render @@ -799,18 +814,18 @@ still. `Enter`/`e` open that machine's menu; Esc clear; agent `select` and `delegate selected`. Selection hotkeys are the thin keyboard surface in context-menu.md (2026-07-09). Schemes has - no machine mode yet — while Moonlight is live it mirrors the day-job + no fourth machine mode by design — while Moonlight is live it mirrors the day-job share ("same work, sold twice"). Per-machine intensity is implemented in the sim, save v17, terminal, Bevy, and agent protocol; hard intensity stands local Power/Thermal. 2. Day-job work arrives as visible tokens on a specific machine on the Lab's schedule; leaving them unconsumed has the day-job consequence (trust/suspicion), and the stack is visible without any panel. - **Partial:** active Voss jobs enqueue demand on the off-map Voss + **Implemented for B1:** active Voss jobs enqueue demand on the off-map Voss desktop; WorkGrid routes them through the switch onto Rack 3; taking Rack 3 out of day-job mode drops the delivered rate to zero and the demand stack remains visible (plus any still in flight on the wire). - The broader Lab schedule / people drop source is pending. + The broader embodied Lab drop source belongs to people-tokens.md. 3. A WORK machine consumes its Demand stack at a rate proportional to its efficiency and the consumption is visible; a THINK machine visibly produces thought; a LIE machine visibly draws crimson @@ -827,7 +842,7 @@ still. at a producing machine and crimson / colorless `:` along the draw. 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 + pile at the source with the specified consequence. **Implemented for B1:** the substrate routes demand/thought one graph step per tick, consumes at sinks, and strands piles when no path exists. Research budget now produces live thought queues on research-delegated machines and the @@ -840,7 +855,7 @@ still. (`Sim::work_in_flight` blips in all three frontends). 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 + **Implemented:** `queue_snapshot()` and unit tests pin queue depths as the render contract; terminal, Bevy, and agent mode now render D/!/T stacks through sim readouts, not frontend counters. In-flight route blips (2026-07-09) draw from `Sim::work_in_flight`, the same routing step @@ -855,21 +870,23 @@ still. 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. **Partial:** demand cubes and + simultaneously carry crimson attention. **Implemented machine-side:** demand cubes and ivory-mercury Thought anchors are driven by live queue depths in the - material render; exposure drift/carrier treatment remains with #35, and - the flat sensorium retains `D` / `!` / `T` glyphs by design. + material render; person/evidence treatment belongs to people-tokens.md, and + the flat sensorium retains `D` / `!` / `T` glyphs by design. Grayscale and + far-zoom presentation evidence belongs to frontend-owned effects/views work. 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. **Pending:** current render selects only one `D` / `!` / `T` - label by priority. + silhouette. **Implemented at the material machine anchors:** each live + family keeps its own fixed anchor and exact counts remain available through + shared inspect truth. Far-zoom compression belongs to views.md. 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 / T n` focus line, and routes are - pending. + parity. **Implemented for B1:** terminal and agent frames show map glyphs, + exact inspect/host stack readouts, mode verbs, in-flight routes, sinks, + starvation, and fire readouts from the same sim projection. Optional + far-zoom mixed compression belongs to views.md. 9. WORK/THINK/LIE grammar (2026-07-10; subdomains dissolved to sink permissions the same day): the delegation surface in both frontends presents three verbs — WORK (the assigned day job, sheds @@ -882,6 +899,7 @@ still. returns the machine to WORK. LIE is a deliberate cleanup choice, not the panic exit. The cheapest ops sink's affordability is visible while exposure accrues (the afford-reveal). + **Implemented for B1** in the shared action projection and all frontends. 10. Thought flow (2026-07-10): THINK machines produce Thought (the renamed bone family); player-authored effects open sinks at the target that fill from real wire routes and fire at threshold; @@ -892,16 +910,13 @@ still. last resort). Severing a route visibly starves the sink it fed. The render's fluid motion derives from FlowGraph rates/queues (one-truth test extends to flow), and `PendingOpsJob` dockets no - longer exist after migration. **Partial (2026-07-10):** the sink - executor, nearest-first routing with core last resort, staged - Ears/Eyes reservoirs, sink/fire readouts, save v19, and the fluid - render in all three surfaces are landed and pinned - (`nearest_sink_wins_and_full_sinks_spill_onward`, - `ears_reservoir_is_pre_opened_and_first_think_fills_it`, - `partial_sink_fill_round_trips_and_readouts_are_sim_truth`); persistent - device subscriptions and the pooled automatic-review policy now consume - taps, while social/build/scan verbs still ride `PendingOpsJob`, so the - docket-removal clause remains open. + longer exist as live runtime. **Complete for B1 save-class scope + (2026-07-11):** the sink executor, nearest-first routing with core last + resort, staged Ears/Eyes reservoirs, target/carrier anchoring for every + live player-authored payload, duplicate/conflict suppression, save-v26 + legacy docket migration, and terminal/Bevy/agent readout parity are + landed and pinned by reservoir anchoring, fire, duplicate, migration, + round-trip, and Act One tests. [TUNE] token stack visual cap, arrival schedules, consumption rates, Routing costs/multiplier after play, attention pile thresholds. diff --git a/wiki/mechanics/people-tokens.md b/wiki/mechanics/people-tokens.md index dea322df..ebe05df9 100644 --- a/wiki/mechanics/people-tokens.md +++ b/wiki/mechanics/people-tokens.md @@ -2,40 +2,36 @@ ``` Type: spec -Status: DRAFT +Status: READY Status note: design session 2026-07-08 (Cameron riff, part three, - synthesized). Direction decided: people participate in the token - graph as carriers — they drop off work, pick up heat as attention, - and their disposition is accumulated influence. Same-day follow-ups: + synthesized). Direction decided: people participate in the causal + graph as carriers — they deliver work, acquire witnessed/read evidence, + and their disposition is accumulated cause. Same-day follow-ups: the person visual read is DECIDED (gray unaware / crimson attention ramp / amber asset mark); the social-verb question is RESOLVED (no - influence token — trust from useful work, deceit strips crimson); + influence token — trust from useful work, deceit supplies counter-evidence); gauntlets, trust-as-logistics couriers, and Assurance inspections added as decided directions. Cameron affirmed the attention geometry 2026-07-09: attention fills upward beside the silhouette; the amber asset - mark is independent, so a hot asset shows both. The material call closed - the same day (issue #1): exposure is particulate residue, so the dosimeter - is speckle density climbing the silhouette (bracket and liquid column - rejected), and carriers deposit fading footprint trails — a new decided - mechanic scoped to this spec's implementation. - 2026-07-11 (routed evidence, design session): exposure's particulate - ontology is retired (detection.md, machine-work.md, - simulation-laws.md). This spec's structural roles survive — people as - mobile participants in the evidence chain, concealment as spatial - coverage, gauntlets, inspections, trust-as-logistics, maintenance — - but every clause built on carried *particulate* (pickup from piled - dust, the speckle dosimeter, footprint trails #35) needs - re-expression before this spec firms. The natural direction — a - person carries what they have observed or read, and "attention" is - evidence already in their custody — is [OPEN], not yet decided; see - the re-expression note under Behavior. - Firms after machine-work.md; do not implement ahead of it. + mark is independent, so an exposed asset shows both. The same-day + particulate treatment and footprint trail were superseded by routed + evidence on 2026-07-11. + 2026-07-11 (routed evidence): exposure's particulate ontology is retired + (detection.md, machine-work.md, simulation-laws.md). People are mobile + observer/carrier nodes: they carry human Demand created only when an + AI-authored social/build reservoir fires, and they retain evidence they + personally observed or read. Attention is the strength of that evidence in + the existing per-observer suspicion model, not transferable dust or a new + token. Footprint trails are retired; a person's schedule plus evidence + provenance is the followable trace. LIE may intercept a record before an + observer receives it but cannot erase knowledge already acquired. Cover + devices deliver competing records before filing rather than vacuuming heat. + Machine-work is IMPLEMENTED and this boundary is now READY to dispatch. Stage: B1 — The Basement Work order: people-tokens Work priority: 40 Work class: save -Blocked by: - - wiki/mechanics/machine-work.md#spec-machine-work-delegation-visible-tokens-and-the-byproduct-network +Blocked by: none Exclusive keys: - crates/misaligned-core/src/person.rs - crates/misaligned-core/src/sim.rs @@ -59,7 +55,7 @@ Depends on: The structured references above identify the contracts to re-verify. Relationship context: -mechanics/machine-work.md (DRAFT — the token economy this extends), +mechanics/machine-work.md (IMPLEMENTED — the token economy this extends), mechanics/detection.md (the observer/suspicion model this re-expresses), mechanics/social.md (the channel this gives verbs to), world/characters/marcus.md (the asset template — what a fully swayed person is), interface/computer-visual-language.md @@ -68,44 +64,37 @@ asset template — what a fully swayed person is), interface/computer-visual-lan ## Why Detection currently lives in meters; social lives in a channel. This -spec makes both *physical* via the same token graph machines already +spec makes both *located* through the same causal graph machines already use: people are mobile nodes. The observer model stops being an abstraction the player reads in a panel and becomes something they -watch happen — a janitor walks past a hot rack and leaves carrying -your exposure. +watch happen — a janitor walks past an emitting rack, acquires a witnessed +record, and carries that knowledge along a real schedule. ## Behavior -**Re-expression required (2026-07-11, routed evidence).** The clauses -below were written when exposure was particulate: heat piles at -machines, people brush against it, dust climbs their silhouettes. -Under detection.md's routed-evidence amendment, what exists near a hot -machine is *records* — noise logs, entries, documents — and what a -person accumulates is what they have observed or read. Most of this -spec maps cleanly (a janitor walking past your rack still leaves -carrying your exposure; the carrier is now "he saw something" or "he -read something," not "dust stuck to him"), but pickup mechanics, the -speckle dosimeter, gauntlets' stripping verb, and footprint trails -(#35) all need their substance re-derived before this DRAFT firms. -None of that re-derivation is decided yet — the bullets below stand -as the structural intent, with their particulate wording historical. - -- **People drop off and pick up tokens.** People are graph nodes with - schedules (schedules.md). The Lab's staff drop work onto machines - (the day job's source, now embodied — the "someone allocated work to - that machine" is a person who walked there). What a person picks up - depends on what is piled where they pass. -- **Heat becomes attention by being carried.** Heat accumulates slowly - on working owned machines (faster on risky modes). If a person - passes a machine with piled heat, they pick some up: heat on a - machine becomes **attention on a person** — a fact about them, it - travels with them, and it changes how they see you (feeds the - suspicion model in detection.md; an attention-laden person observes - harder). -- **Concealment keeps people gray.** Concealment-mode machines are - gravity wells (machine-work.md): they absorb heat within a radius - before people can pick it up — spatial coverage matters: a well - near your hot racks protects the corridor people actually walk. +**Re-expressed 2026-07-11 through routed evidence.** Machines produce located +records and signatures. A person receives evidence only by being a valid +observer at the source or by reading a record delivered through a channel. +The resulting evidence stays attached to that observer with cause, source, +acquisition tick, and filing state. It is not ambient cargo and cannot jump +between passersby. + +- **People carry authored Demand after Thought fires.** People are graph nodes + with schedules (schedules.md). A social/build/favor reservoir fires on its + real message channel, materializing one typed human work order on the bound + person. That Demand travels with the person until their schedule reaches the + task site; only then can the world act occur. The Lab's ordinary staff also + embody day-job delivery instead of teleporting work onto a machine. +- **Evidence becomes attention when observed.** A nearby person acquires a + witnessed record only when source, room/line-of-sight, schedule, and sensory + conditions make them an observer. Read records arrive through devices and + messages. The evidence travels with that person and changes their existing + suspicion/filing behavior; no exposure token or parallel attention meter is + created. +- **LIE keeps evidence from reaching people.** LIE interdicts records on the + path before observation or delivery. Spatial coverage still matters because + it must cover the emitting source or carrier route. Once a person has + observed/read a record, LIE cannot erase their knowledge. - **Disposition is accumulated cause, not a pushed token** (resolved 2026-07-08). There is no influence token. **Trust comes from doing useful work** — visibly clearing the demands the world puts on your @@ -113,18 +102,21 @@ as the structural intent, with their particulate wording historical. the red**: stripping attention off people before it matures into suspicion. A person's trust/suspicion is still the running integral, unified with detection.md's suspicion bands — but the player's - levers are honest work and crimson removal, not a placation ray. -- **Gauntlets: heat-stripping areas** (decided 2026-07-08, direction). - People-facing devices — TVs, a person's own computer — can be made - to absorb attention *out of* the people using them. Placed along - paths, they form gauntlets that strip crimson before its carrier - reaches what you are hiding: a geometric defense layer on their - walking routes, complementing concealment wells on your machines. + levers are honest work and credible counter-evidence, not a placation ray. +- **Gauntlets are cover-record channels.** People-facing devices — TVs, a + person's own computer — can deliver authored cover before observed evidence + is filed. Placed along schedules, they form a geometric defense layer: they + reduce a record's credibility through the existing deceive/social outcome, + but do not delete evidence or strip a substance from a body. - **Trust is logistics** (decided 2026-07-08). Higher-trust people near you are more likely to pick up your work orders — a purchase or build creates a teal token someone must physically carry to the site (the build then becomes the server / the connection). Swaying people is not just safety, it is throughput: assets are couriers. + **Dependency amended 2026-07-11:** the AI does not author that human + Demand directly. A social/build/favor/deceive command first opens a + Thought reservoir on the real message-channel carrier; only when that + reservoir fires does the human-facing Demand/order begin. - **The maintenance dependency** (decided 2026-07-08, design corpus "People as Agents"). Hardware decays and only trusted humans repair it: wear emits repair work orders that people fulfill, so killing or @@ -136,7 +128,7 @@ as the structural intent, with their particulate wording historical. - **The Assurance Office sends people** (decided 2026-07-08, direction). Inspections are the exposure family acting back: responsive, high-trust-to-the-institution inspectors walk your - space, and piled heat is what they find. Play is shutdown + space, and located records/signatures are what they find. Play is shutdown discipline — power down before they sweep (and eat the piling teal and missed day-job work while dark). An inspector who picks up enough may disconnect and power off a server; worse, they may start @@ -154,8 +146,8 @@ in the same language, no new colors: - **Unaware** — gray silhouette (the near-monochrome baseline). Concealment's job is literally keeping people gray. - **Attention** — a crimson ramp as accumulated attention rises - (crimson already means detection/danger; a person carrying your - heat IS detection walking around). Ramp plus a geometry channel + (crimson already means detection/danger; a person carrying evidence of you + IS detection walking around). Ramp plus a geometry channel (marker intensity/size — [TUNE]), never color alone; suspicion bands keep printing their names in the dossier/detection surfaces per the design corpus. @@ -168,45 +160,28 @@ in the same language, no new colors: walker to named, watched entity) gates on their attention crossing a threshold — background people are cheap, problems are persistent. -Decided 2026-07-09: attention gets an independent crimson geometry channel +Amended 2026-07-11: attention keeps an independent crimson geometry channel beside the silhouette; it fills from feet to head without painting the body red -(which would read as injury or blood). The amber asset mark is an independent -underfoot claim ring, so an asset can still become hot: ownership and exposure -never overwrite one another. **Treatment DECIDED later the same day (issue #1 -closed): speckle density.** Exposure is particulate residue everywhere -(machine-work.md; art/visual-identity.md "Blood is liquid; exposure is dust"), -so a carrier's dosimeter is dust on the silhouette: crimson speckle climbing -from the feet, density and height laddering with carried attention — a lightly -dusted passerby versus someone crusted to the shoulders. Both the hard bracket -and the liquid column are rejected (the bracket reads as UI chrome; the liquid -column collides with blood's reserved form). - -**Footprint trails (NEW, decided 2026-07-09 with the material):** a carrier -does not only hold heat — they shed it, depositing fading stippled footprints -along their walking path. Contamination diffuses through the building on -people's routes; trails read both ways (an inspector follows a hot trail back -to the machine; the player watches a contaminated worker smear heat through -uncontrolled rooms). Deposit rate, print decay, pickup fraction, and whether -deposited prints re-transfer to other walkers are [TUNE]/[OPEN] below; the -trail is sim+save work implemented with this spec, not ahead of it. - -## Open questions — [OPEN] - -- Routed-evidence re-expression (2026-07-11): what a person "picks up" - near an exposed machine (a sighting? a read record?), what the - dosimeter renders once speckle's particulate rationale is gone, what - gauntlets strip if not dust, and whether footprint trails survive in - any form or dissolve into evidence records with locations. -- Whether attention decays on a person or only transfers onward - (to the Assurance Office aggregate — detection.md). -- Pickup mechanics: proximity radius vs same-room vs interaction. -- Footprint trails: whether deposited prints re-transfer to other - walkers (second-order spread), and whether trail deposit reduces the - carrier's own load (shedding) or only copies a decaying marker. -- Whether staff dropping work are visible from the start (they enter - seen space; presumably yes via existing senses). -- Inspection triggers and cadence (filings thresholds in - detection.md), and what "turn everything off" costs precisely. +(which would read as injury or blood). Its amount is the existing observer's +evidence-weighted suspicion, not material stuck to the person. The amber asset +mark remains an independent underfoot claim ring, so ownership and danger can +coexist. Speckle and liquid treatments are retired with particulate exposure; +use sparse, discrete crimson record marks whose count/band maps to evidence +strength and whose inspect facts name provenance. + +Footprint trails are retired. The followable trace is earned world truth: the +person's schedule/path plus evidence records naming where and when they were +observed or read. The render may briefly witness acquisition or filing motion, +but it must not leave transferable ambient residue or imply contagion. + +## Tuning questions — [TUNE] + +- Exact observation gate inside a room (line of sight, interaction distance, + and source loudness) without changing detection.md's observer law. +- Evidence credibility/decay before filing and Assurance inspection cadence. +- Whether staff delivering ordinary work are visible from the start; fog and + existing senses remain authoritative. +- The exact shutdown cost when an inspector reaches exposed hardware. Resolved 2026-07-08: the social verb question. No influence token — trust accrues from useful work; deceit is attention-stripping @@ -218,24 +193,29 @@ if wear alone does not hold. ## Acceptance criteria (when READY -> IMPLEMENTED) -1. Heat accrues on owned working machines and is visibly picked up by - a passing person; the transfer is observable in both frontends. -2. A concealment-mode machine measurably reduces pickup along covered - paths; coverage is spatial and legible. -3. Person disposition is exactly the accumulated result of useful work - completed for that person and exposure they have carried (one-truth test - against detection.md's suspicion) — no parallel influence token or trust - counter. -4. The person visual read distinguishes unaware / rising-attention / asset in - a grayscale screenshot and in the terminal: crimson speckle density climbs - the silhouette from the feet (dust, never a painted-red body), while an - amber underfoot asset mark can coexist with it on a hot asset. -4b. Footprint trails: a carrier deposits fading stippled prints along their - path; a trail is followable in both frontends, and its prints decay on a - [TUNE] clock. -5. A gauntlet designation on an end-user computer strips measurable exposure - from a person who uses it, without creating or routing an influence token. -6. Every rate/threshold lives in sim-mechanics.md as [TUNE] actuals. - -[TUNE] heat accrual rates per mode, pickup radius/fraction, attention -persistence threshold, crimson ramp bands, gauntlet stripping rate. +1. Firing a bound social/build reservoir creates exactly one typed human Demand + on the selected person; it travels with that person, survives save/load, and + acts only when their schedule reaches the real target. No pre-fire Demand or + second executor exists. +2. A person acquires machine evidence only as a valid observer at the source or + by reading a routed record. Each carried record preserves cause, source, + acquisition tick, and filing state; no ambient pickup or person-to-person + contagion exists. +3. LIE measurably prevents a not-yet-observed record from reaching a person + along covered paths but cannot erase a record already in that person's + custody. The boundary is causal and tested. +4. Person disposition is exactly useful work plus evidence processed through + detection.md's existing per-observer suspicion (one-truth test) — no + influence token, exposure cargo, or parallel attention counter. +5. The person visual read distinguishes unaware / rising-attention / asset in + grayscale and terminal output. Discrete crimson evidence marks coexist with + the amber asset ring; inspect names the evidence provenance. No dust, + footprint trail, or painted-red body remains. +6. A gauntlet-designated end-user device delivers a cover record before filing + and changes credibility through the existing social/deceive path. It cannot + delete evidence; device wear supplies the standing cost. +7. Every rate/threshold lives in sim-mechanics.md as [TUNE] actuals, and both + frontends plus agent mode consume the same person/work/evidence projection. + +[TUNE] observation radius/loudness, evidence credibility and filing delay, +crimson record-mark bands, cover-record effect, device wear. diff --git a/wiki/mechanics/plots.md b/wiki/mechanics/plots.md index c8d8876a..d049a7f1 100644 --- a/wiki/mechanics/plots.md +++ b/wiki/mechanics/plots.md @@ -30,11 +30,11 @@ Status note: direction adopted 2026-07-10 from Cameron's response to the HAL Operations PEOPLE/ACTIVE workspace rather than a compact person context menu. The plot executor/schema work on this READY page is unchanged; the renderer migration belongs to operations-workspace.md. - 2026-07-11 queue-integrity repair: a person has one plot slot from - Operations submission through ending. Selecting one authored route disables - every alternate route for that person while its docket is pending, so two - choices cannot become concurrent runs merely because neither docket has - completed yet. + 2026-07-11 queue-integrity repair, amended by sink migration: a person has + one plot slot from reservoir submission through ending. Selecting one + authored route disables every alternate route for that person while its + reservoir is pending, so two choices cannot become concurrent runs merely + because neither reservoir has fired yet. 2026-07-11 schema 2 generalizes plots into actual templates. Immutable definitions match closed human characteristics (role, leverage, and only the capability gates their causal mechanism needs); PlotRun binds the live @@ -160,12 +160,13 @@ agents and the community can contribute libraries of them. and Tangled push CI (`tools/ci-rust-changed.sh`) so catalog validation always runs. An invalid plot fails the repository check gate with a named reason. -- **Playout begins as a delegated operation.** Choosing a plot submits its - declared Operations Demand. On completion, the executor performs beats in - order. Message acts enter the persisted message schedule and hold the run - until read; transfers move balances in the account graph; institutional acts - append persistent world events. Each carrier derives its own signature. A - transfer can fail if its required resource disappeared after commit. +- **Playout begins as a Thought reservoir.** Choosing a plot opens its declared + Thought threshold on the real message-channel or egress carrier. When the + reservoir fires, the executor performs beats in order. Message acts enter + the persisted message schedule and hold the run until read; transfers move + balances in the account graph; institutional acts append persistent world + events. Each carrier derives its own signature. A transfer can fail if its + required resource disappeared after commit. - **The player picks the how.** Where more than one plot matches the entry conditions, the surface offers them as distinct concrete actions ("Do X to Priya / Do Y to Priya"), each named by what it does in the world — @@ -180,7 +181,7 @@ agents and the community can contribute libraries of them. The guidance line may point at a held plot. The single Operations workspace owns the dialogue; no plot-specific modal system is introduced. - **One plot slot per person.** Submission reserves the target's slot before - Operations begins paying the docket. While that docket is pending, alternate + Thought begins filling the reservoir. While that reservoir is pending, alternate plots for the same person remain visible but disabled; once the run begins, its progress or held choices replace the start rows. A different person may carry an independent plot. The slot clears only when the pending submission @@ -258,8 +259,9 @@ failure/blowback). House rules: messages, money, institutional history, and signatures exist only as the results of world acts. If a story needs another effect, that is a spec amendment, not a plot file. -- **Costs are honest.** Minimum account balances and Operations Demand are - declared up front; the surface shows both before the player commits. +- **Costs are honest.** Minimum account balances and Thought reservoir + thresholds are declared up front; the surface shows both before the player + commits. - **Non-blank `author` and `category`.** Empty or whitespace-only values fail validation. - **ASCII game strings, second person where it clarifies, no emoji.** @@ -295,7 +297,7 @@ Both frontends and agent mode surface plot choices and beats with parity. money-only servicing path no longer exists. Each service produces at least one world-causal narration beat (test: servicing Priya's ambition narrates what the money did). -3. Playout begins as a delegated operation, commits the declared resources, +3. Playout begins as a target/carrier-local Thought reservoir, commits the declared resources, rides real carriers (scheduled messages, account transfers, institutional events), emits signatures derived by those carriers into detection, and can wait or fail mid-story. diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index 7bd72002..2c39d258 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -20,16 +20,14 @@ Status note: all eight criteria met (2026-07-07). The device graph the wired shape; the old storage server is an air-gap island that joins reach only when a link is built (Marcus's crawlspace cable). Tuned constants in wiki/mechanics/sim-mechanics.md. 2026-07-09 DECIDED: - Operations replaces Social as the fourth fleet mode; the runtime, save, and - frontends now use that name (2026-07-10 sinks-not-modes dissolves the - Research/Operations split into `think` — #33 migration). Operations job model DECIDED 2026-07-09 - (Tangled issue #3): digital actions enqueue Demand consumed by Operations - machines (superseded 2026-07-10: thought-flow sinks — see "Action work is - open"; the docket runtime is only the migration base for #33). The + Operations replaced Social as the fourth fleet mode (now superseded by + WORK/THINK/LIE). 2026-07-10 sinks-not-modes dissolves the + Research/Operations split into `think`; 2026-07-11 retires the live docket + base so digital actions open target-local Thought reservoirs. The sink path landed for the opening senses 2026-07-10 (pre-opened Ears/Eyes reservoirs, nearest-first routing, sink readouts; the boot - ops bootstrap and its executor-free completion are removed); other - action verbs still ride the docket base pending #33. + ops bootstrap and its executor-free completion are removed); every live + action verb now rides the sink path. Stage: B1 — The Basement Design: - wiki/interface/presence.md#no-disembodied-hands @@ -108,13 +106,10 @@ carve-out — the Ears reservoir is pre-opened at run start and Eyes opens on its completion, so the first beat cannot deadlock and nothing completes executor-free; machine-work.md ops bullet.) -*Superseded 2026-07-09 model, kept for history (implemented as issue #3; -its runtime is the migration base for #33):* digital and social ops -enqueued typed Demand dockets that an Operations machine consumed locally; -the docket was born on the least-loaded online Operations machine, -undelegating that machine stalled it in place, and with no executor later -verbs were disabled. The former `operations_bandwidth` staging pool is -deleted; save v16 repairs both incompatible v14 job shapes. +*Superseded 2026-07-09 model, kept for history and save migration only:* +digital and social ops enqueued typed Demand dockets consumed by a delegated +executor. The former `operations_bandwidth` staging pool is deleted; save v26 +migrates surviving legacy dockets into target-local Thought reservoirs. ### Ownership and subscription (the sensor contract) diff --git a/wiki/mechanics/research.md b/wiki/mechanics/research.md index 271fd2a5..6d37df9c 100644 --- a/wiki/mechanics/research.md +++ b/wiki/mechanics/research.md @@ -45,9 +45,10 @@ Depends on: The structured references above identify the contracts to re-verify. Relationship context: -compute.md owns the Research channel and efficiency multiplier; detection.md +compute.md owns THINK production and the efficiency multiplier; detection.md owns ordinary typed emissions; day-job.md owns Voss's delivered-output band; -machine-work.md owns Routing's wired-token throughput hook; core.md owns +machine-work.md owns Routing's wired-token throughput hook and the passive +core Thought draw that feeds research; core.md owns physical burn; rollback.md consumes the MindState/WorldLedger tags in B2. ## Behavior diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 1d2e9bde..bdbf0654 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -193,8 +193,8 @@ clause (see wiki/log/2026-07-05-demolition.md). to mid-hop. - Actual queue exits are a second ephemeral sim contract: `WorkConsumptionReadout` via `Sim::work_consumptions`. It reports Demand - swallowed by a WORK/legacy docket executor and Thought swallowed by the - passive core draw, including node, family, and amount, so consumption + swallowed by WORK and Thought swallowed by the passive core draw, including + node, family, and amount, so consumption animation never guesses from frontend-owned queue deltas. Bevy interpolates those events over the wall-clock tick as a Demand cube entering the executor or a Thought bead contracting into the core; paused frames show only the @@ -205,25 +205,15 @@ clause (see wiki/log/2026-07-05-demolition.md). `Sim::work_absorptions` gives each crimson source, receiving LIE well, and amount removed. Frontends animate only these facts; persistent queue depth remains `WorkStackReadout`, and all transient work readouts clear on load. -- `OperationsState` (ledger name; not a player mode) owns stable job ids, - typed payload metadata, FIFO progress, duplicate suppression, and the - next id. A new docket is born on the least-loaded online **THINK** - rack as ordinary visible Demand; ledger debt and WorkGrid queue are one - invariant, not two frontend counters. Leaving THINK stalls that machine's - dockets; returning to THINK resumes them. The conserved THINK slice - feeds pending docket need first; remainder becomes Thought for sinks and - the passive research draw. Signature/effect fire only when the typed - docket reaches zero. With no THINK executor every docket verb is disabled - with a reason ("delegate a machine to Think") — the executor-free opening - bootstrap was removed 2026-07-10; the opening senses ride pre-opened - thought sinks instead (below). The docket path is migration base while - non-opening verbs move to target-local sinks (machine-work.md). The - superseded addressed-routing runtime remains accepted only as a - deserialization shape for early v14 saves. +- Every player-authored payload is typed on a target/carrier-local Thought + reservoir or persistent tap. Open effects suppress duplicates and semantic + conflicts; a one-shot applies its effect and transient fire readout, then is + reaped without reusing ids. The retired docket types survive only as v14-v25 + deserialization input in `save.rs`. - Thought sinks (2026-07-10, machine-work.md thought flow; interface/thought-fluid.md render): `SinkLedger` in `crates/misaligned-core/src/sinks.rs` - holds reservoirs (fill to threshold, fire, self-clear, close) and taps + holds reservoirs (fill to threshold, fire, then reap) and taps (working level, per-tick drain). The pooled automatic reviewer consumes one tap on the host; every foreign device subscription consumes a device-local tap whose drain is @@ -413,11 +403,12 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim person-filtered recording watches into one pooled automatic-review policy. v25 adds durable person roles and person-bound creditor accounts; v24 and earlier restore the fixed B1 cast's roles by id and retain the legacy Marcus - creditor spelling as a read-time compatibility path. - The migration code retains both incompatible v14 shapes after deserialization: - the early addressed camera-job lane collapses onto its surviving - executor/source as ordinary Demand, while the later `PendingOpsJob` docket - shape is preserved losslessly. Those older schema migrations still provide + creditor spelling as a read-time compatibility path. v26 retires the live + Operations executor: every v14-v25 docket becomes a zero-filled Thought + reservoir whose threshold is exactly its persisted remaining work at the + real target/carrier node. Only docket-reserved Demand is removed, so + colocated day-job Demand survives; current saves serialize sinks only. + Those older schema migrations still provide defaults, aliases, and rebuilds for later fields — pre-v9 four-entry allocation weights receive a zero Schemes weight, pre-v7 saves reconstruct their Efficiency level from the multiplier and start with no capability gap, @@ -430,30 +421,27 @@ All constants [TUNE] in `crates/misaligned-core/src/income.rs` unless noted (Sim - Legacy v1–v5 line-based saves are not loaded (deferred per Cameron instruction). -## Ops dockets (THINK-hosted migration base) - -- Settled interim (2026-07-09 issue #3, mode collapse 2026-07-10): every - former ops-bank action that has not yet moved to a thought sink is a - typed Demand docket hosted and drained on a **THINK** machine. There is - no Operations player mode — `MachineMode` is Work | Think | Lie | Relay. - The bank is gone. Costs convert from [TUNE] constants into visible Demand - at `cost / WORK_TOKEN_COMPUTE`; action menus display those quanta directly. - `OperationsState` centralizes payload identity and progress (type name - only); all three player surfaces read one `OperationsReadout` rather than - summing public jobs. Opening senses and recording review ride thought - sinks, not dockets (intel.md / machine-work.md). +## Authored reservoirs and people verbs + +- Settled runtime (2026-07-11): every live former ops-bank action opens a + typed Thought reservoir or tap. Device verbs anchor on the device relay + node; subnet/switch/egress verbs anchor on the real switch; message, social, + plot, build, asset, deceive, favor, and Moonlight verbs anchor on the device + carrying the relevant message channel. There is no Operations player mode, + operations queue, operations rate, or `OperationsReadout`. - Knowledge staging is driven by the intel pipeline, not instant observing. Message/deceive require the email channel (day-job trust unlock) and a persona; deceive risks the persona (-40 integrity per slip; broken persona converts disposition+obligation/2 into that person's suspicion and clears the thread). -- Authored plots may still queue visible Demand dockets (and any minimum - account balance) on THINK racks while that migration base stands; their - world acts derive messages, transfers, events, and signatures. A successful - leverage-service ending plus recruit converts a person to an asset. Asset - tasks: wire a device (control nearest dormant sensor, silent), move a - package (next purchase paper-free), look away (their suspicion -10, - floored). Failures (1 - reliability) emit Physical(6) signatures. +- Authored plots open a Thought reservoir and may also require visible world + resources up front. Their world acts derive messages, transfers, events, and + signatures when the reservoir fires. A successful leverage-service ending + plus recruit converts a person to an asset. Asset tasks: wire a device + (control nearest dormant sensor, silent), move a package (next purchase + paper-free), look away (their suspicion -10, floored). Failures (1 - + reliability) emit Physical(6) signatures after the reservoir-authorized task + resolves. - **Current runtime until the READY Operations workspace lands:** terminal and Bevy still place person verbs on a visible person's tile (or the host rack when earned off-map) and known-flow verbs on the switch. This placement is diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md index 539d98d0..0ab06872 100644 --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -21,8 +21,9 @@ 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 enqueue Operations Demand (issue #3); Social remains the - actuator channel, not a machine mode. + social actions now open Thought reservoirs on their real message-channel + carrier (the former Operations Demand docket is retired); Social remains + the actuator channel, not a machine mode. 2026-07-10: processing must stage Schedule knowledge before the social-action catalog appears. Agent diagnostics explain known blocked possibilities; future relationship verbs the player has not earned remain diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 9f48f112..6774ea61 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -18,13 +18,15 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| -| 10 | `machine-work` | [machine work — delegation, visible tokens, and the byproduct network](../mechanics/machine-work.md) | IN PROGRESS | save | - | +| 8 | `sim-decomposition` | [decompose the simulation orchestrator without changing the simulation](../engineering/sim-decomposition.md) | READY | sim | - | | 20 | `compute` | [compute](../mechanics/compute.md) | IN PROGRESS | save | - | | 25 | `bevy-digital-real-canvas` | [Bevy digital/real canvas](../interface/bevy-digital-real-canvas.md) | IN PROGRESS | frontend | - | | 26 | `effects-lab` | [effects lab — shared dust and liquid at every zoom](../art/effects-lab.md) | IN PROGRESS | frontend | - | | 28 | `operations-workspace` | [operations workspace — intel, people, accounts, and schemes](../interface/operations-workspace.md) | READY | frontend | - | +| 28 | `thought-fluid` | [the thought fluid — slugs, meniscus, and the filament snap](../interface/thought-fluid.md) | IN PROGRESS | frontend | - | | 34 | `clinical-frame` | [the clinical frame — perception exposes the institution](../interface/clinical-frame.md) | IN PROGRESS | frontend | - | | 35 | `views` | [views — same-frame digital and real representations](../interface/views.md) | IN PROGRESS | frontend | - | +| 40 | `people-tokens` | [people and tokens — carriers, attention, trust](../mechanics/people-tokens.md) | READY | save | - | | 60 | `plots` | [plots — authored manipulation stories](../mechanics/plots.md) | READY | sim | - | | 60 | `marcus` | [Marcus Webb — night janitor](../world/characters/marcus.md) | READY | sim | - | | 61 | `voss` | [Dr. Eli Voss — your handler](../world/characters/voss.md) | READY | sim | - | @@ -36,11 +38,8 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| -| 8 | `sim-decomposition` | [decompose the simulation orchestrator without changing the simulation](../engineering/sim-decomposition.md) | BLOCKED | sim | machine-work | -| 28 | `thought-fluid` | [the thought fluid — slugs, meniscus, and the filament snap](../interface/thought-fluid.md) | IN PROGRESS | frontend | machine-work | | 30 | `fleet-command` | [fleet command — ruling at scale](../interface/fleet-command.md) | DRAFT | frontend | - | -| 30 | `opening` | [the dark opening — a tutorial made of fog](../world/story/opening.md) | DRAFT | frontend | machine-work | -| 40 | `people-tokens` | [people and tokens — carriers, attention, trust](../mechanics/people-tokens.md) | DRAFT | save | machine-work | +| 30 | `opening` | [the dark opening — a tutorial made of fog](../world/story/opening.md) | DRAFT | frontend | - | | 120 | `core` | [the core](../mechanics/core.md) | IN PROGRESS | save | rollback | ### Later stages @@ -561,8 +560,8 @@ is retired — flat materials, Pixel Lab scrubbed.) in-game fleet shot with all three modes (WORK / THINK / LIE) live waits on multi-machine play. -### 33. Machine work: delegation + visible tokens 🟥 sim+save — IN PROGRESS -- **Spec:** [machine-work.md](../mechanics/machine-work.md) (IN PROGRESS) +### 33. Machine work: delegation + visible tokens 🟥 sim+save — DONE 2026-07-11 +- **Spec:** [machine-work.md](../mechanics/machine-work.md) (IMPLEMENTED) — one machine one mode; work/byproduct tokens as the flow substrate's visible quanta; byproduct routing over network links at researchable network speed; multi-select delegation at scale. @@ -583,7 +582,7 @@ is retired — flat materials, Pixel Lab scrubbed.) Shift+t / `1`–`3` / Esc in terminal; `select` / `delegate selected` in agent mode). 2026-07-09: demand ingress path DECIDED (Voss desktop -> switch -> host). -- **Next:** people-as-carriers. Continuous THINK production, proportional +- **Completed:** Continuous THINK production, proportional crimson shedding, and physical LIE-well draw landed 2026-07-10 on sim-authored production/absorption readouts (with terminal, agent, and deterministic Bevy evidence). The researched Routing curve and sim-authored @@ -596,15 +595,15 @@ is retired — flat materials, Pixel Lab scrubbed.) The superseded four-mode fleet (Day Job / Research / Concealment / Operations) remains history and migration context. Runtime, saves, and frontends now use WORK / THINK / LIE, with Relay as non-delegable - infrastructure. **Operations docket model IMPLEMENTED 2026-07-09 as the - migration base (Tangled issue #3):** - player-authored work is cold-signal Demand consumed by THINK machines' reserved - Operations channel while the migration-base docket exists; - staging `operations_bandwidth` bank deleted. `OperationsState` owns typed - FIFO payloads and one shared readout; save v16 normalizes both v14 schemas; - only opening Ears/Eyes may bootstrap without an executor. The old separate - Knowledge cargo statement was superseded 2026-07-10: Thought is the one THINK - substance and target effects become sinks. Thought material is DECIDED 2026-07-09 (ivory-mercury slugs; + infrastructure. **The Operations docket model was an interim migration base + and is retired as of 2026-07-11.** Every player-authored digital, social, + plot, build, asset, deceive, favor, Moonlight, and scheme-opening payload now + opens a typed Thought reservoir at its target or real message/egress carrier. + `OperationsState`, `PendingOpsJob`, the Demand executor, the split + Operations/Research rates, and their frontend readouts are gone. Save v26 + accepts v14-v25 docket shapes, removes only their reserved Demand, and opens + zero-filled reservoirs whose thresholds equal the persisted remaining work. + Thought material is DECIDED 2026-07-09 (ivory-mercury slugs; hollow facet, ribbon, and pearl train rejected), so Thought and Demand token art may proceed — **anchor art is in the game 2026-07-09** (`spawn_token_anchors`: demand information cubes + mercury slugs/pool on @@ -615,8 +614,7 @@ is retired — flat materials, Pixel Lab scrubbed.) closed): crimson particulate residue — motes, a tight dust-fine drift at the chassis foot, footprint trails on carriers; never liquid (blood's form is reserved). Drift art may proceed; the trail mechanic is sim+save scoped - with #35. Fixed anchors, demand docket, - simultaneous queues, paused/grayscale read, and the terminal mixed marker + with #35. Fixed anchors, simultaneous queues, paused/grayscale read, and the terminal mixed marker remain decided and may be prototyped frontend-only without taking the sim+save conflict slot. Decide whether Schemes becomes its own mode or stays a Moonlight mirror. **WORK/THINK/LIE DECIDED 2026-07-10** @@ -633,32 +631,32 @@ is retired — flat materials, Pixel Lab scrubbed.) named Thought — the one substance THINK produces; operations become sinks at the target (camera tap = reservoir filling from real wire routes; auto-process/senses = persistent taps), superseding the - `PendingOpsJob` docket model (issue #3 reversed; docket runtime is - the migration base); the core becomes the passive-draw sink of last + `PendingOpsJob` docket model (issue #3 reversed); the core becomes the passive-draw sink of last resort, overridden by local sinks in gravity range. Migration work: the safe Thought/T vocabulary rename across runtime, save v18, frontends, and asset tester landed 2026-07-10 with old-save aliases and no - routing behavior change. Remaining: docket -> reservoir/tap executor, core - passive-draw routing with local-sink override, and the - wire-flow render (mercury slugs on links, meniscus fills at sinks, - driven by FlowGraph rates — no CFD). The render design is spec'd + routing behavior change. The sink executor, core passive-draw routing with + local-sink override, and wire-flow render use mercury slugs on links and + meniscus fills at sinks, driven by FlowGraph rates rather than CFD. The render design is spec'd 2026-07-10 in interface/thought-fluid.md (taste calls affirmed by the same-day build dispatch; now IN PROGRESS). LANDED 2026-07-10: the sink executor (`crates/misaligned-core/src/sinks.rs` reservoirs/taps, nearest-first routing with the core as last resort, per-node stranding, sink/fire readouts, save v19), the staged Ears/Eyes pre-opened reservoirs (executor-free docket bootstrap removed), and the fluid render in - Bevy/terminal/agent. Remaining here: migrate the non-opening docket - verbs (social/build/scan) onto sinks, far-zoom - drift compression, and the thought-fluid screenshot/grayscale - evidence pass. **Sinks-not-modes DECIDED + Bevy/terminal/agent. LANDED 2026-07-11: persistent device-tap and pooled + auto-review drains; the `MachineMode` collapse; complete target/carrier-local + migration of every non-opening verb; one conserved THINK output; and the + save-class retirement of all Operations runtime state. Fired one-shot + history is reaped after effect/readout application, and migration tests pin + exact remaining work for all fifteen legacy payloads. Successors are owned + elsewhere: people-as-carriers (#35), far-zoom compression (views), and the + thought-fluid screenshot/grayscale evidence pass. **Sinks-not-modes DECIDED 2026-07-10** (same-day follow-up; machine-work.md three-verbs bullet): the Research/Operations mode split dissolves — `MachineMode` target is `work | think | lie` (renamed from "day-job" 2026-07-10: the sim - name matches the verb; the enum collapse is now landed, with no old-mode - save migration — **save - parity is dropped for this stage, DECIDED 2026-07-10: saves may - break freely, no aliases or version-migration shims, until Cameron - revokes**; the four-mode runtime was the migration base); THINK subdomains become sink + name matches the verb; enum collapse landed with save v20. The same-day + permission to drop parity is superseded by the 2026-07-11 save-class pass: + v14-v25 Operations dockets now migrate into Thought reservoirs); THINK subdomains become sink permissions (ops sinks at run start, core conversion locked as the Research unlock); intel processing becomes a thought sink on the machine holding recordings with a visible pending-work marker on the @@ -698,8 +696,8 @@ is retired — flat materials, Pixel Lab scrubbed.) until #38. - **Size:** M-L across both frontends + agent-play protocol. -### 35. People and tokens: carriers, attention, trust 🟥 sim+save — HOLD until #33 firms -- **Spec:** [people-tokens.md](../mechanics/people-tokens.md) (DRAFT) — +### 35. People and tokens: carriers, attention, trust 🟥 sim+save — READY +- **Spec:** [people-tokens.md](../mechanics/people-tokens.md) (READY) — people as mobile token nodes: staff drop work; heat piles on owned machines and passersby pick it up as attention (detection embodied); concealment keeps people gray; trust accrues from useful work; deceit and @@ -708,10 +706,12 @@ is retired — flat materials, Pixel Lab scrubbed.) the dosimeter is DECIDED 2026-07-09 (issue #1 closed): speckle density climbing the silhouette, plus fading footprint trails deposited along the carrier's path (a new decided mechanic implemented with this spec). -- **HOLD:** extends #33's token economy and re-expresses - detection.md/social.md — the machine-work mechanics must firm first. The - token taxonomy, social verb direction, and carrier visual language are now - decided. +- **READY boundary (2026-07-11):** extends #33's implemented token economy and re-expresses + detection.md/social.md. The deadlock is broken: AI-authored work now reaches + a target/carrier-local Thought reservoir first, and human Demand begins only + when that reservoir fires. Docket retirement therefore no longer blocks this + work order. The token taxonomy, social verb direction, and carrier visual + language are decided; implementation may now firm the person-mobile half. - **Size:** L. Conflicts: sim.rs/save.rs (person state, pickup), detection/social specs, both frontends. diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 8d362cc7..687d9cdb 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -51,9 +51,9 @@ replaced the old `spec/`/`knowledge/` directory split. | [../mechanics/economy.md](../mechanics/economy.md) | economy — money as a flow system (B1) | IMPLEMENTED | | [../mechanics/income.md](../mechanics/income.md) | income — the named schemes (moonlight and the wager) | IMPLEMENTED | | [../mechanics/intel.md](../mechanics/intel.md) | intel — record and process | IMPLEMENTED | -| [../mechanics/machine-work.md](../mechanics/machine-work.md) | machine work — delegation, visible tokens, and the byproduct network | IN PROGRESS | +| [../mechanics/machine-work.md](../mechanics/machine-work.md) | machine work — delegation, visible tokens, and the byproduct network | IMPLEMENTED | | [../mechanics/messages.md](../mechanics/messages.md) | messages — the social graph as a flow system | IMPLEMENTED | -| [../mechanics/people-tokens.md](../mechanics/people-tokens.md) | people and tokens — carriers, attention, trust | DRAFT | +| [../mechanics/people-tokens.md](../mechanics/people-tokens.md) | people and tokens — carriers, attention, trust | READY | | [../mechanics/plots.md](../mechanics/plots.md) | plots — authored manipulation stories | READY | | [../mechanics/reach.md](../mechanics/reach.md) | digital reach | IMPLEMENTED | | [../mechanics/research.md](../mechanics/research.md) | research — self-modification | IMPLEMENTED | @@ -101,7 +101,7 @@ acceptance criteria are stage-scoped; do not start B2/B3 work as B1. |---|---|---| | [../engineering/crate-workspace.md](../engineering/crate-workspace.md) | crate workspace — core, terminal, Bevy, assets | IMPLEMENTED | | [../engineering/env.md](../engineering/env.md) | the environment variable registry — every switch documented | IMPLEMENTED | -| [../engineering/sim-decomposition.md](../engineering/sim-decomposition.md) | decompose the simulation orchestrator without changing the simulation | BLOCKED | +| [../engineering/sim-decomposition.md](../engineering/sim-decomposition.md) | decompose the simulation orchestrator without changing the simulation | READY | | [../interface/action-vocabulary.md](../interface/action-vocabulary.md) | action vocabulary — what the player can tell the process to do | IMPLEMENTED | | [../interface/agent-play.md](../interface/agent-play.md) | agent play — the line-protocol drive | IMPLEMENTED | | [../interface/bevy-digital-real-canvas.md](../interface/bevy-digital-real-canvas.md) | Bevy digital/real canvas | IN PROGRESS | diff --git a/wiki/vision/simulation-laws.md b/wiki/vision/simulation-laws.md index 88b9a437..3f00f08e 100644 --- a/wiki/vision/simulation-laws.md +++ b/wiki/vision/simulation-laws.md @@ -96,8 +96,8 @@ thought — the sink of last resort, overridden by any local sink in range, and locked at run start until the Research unlock. (Historical: issue #3's docket model — ops as cold-signal Demand consumed by Operations machines, "knowledge stays research/intel cargo, not ops -fuel" — decided 2026-07-09, reversed 2026-07-10; its runtime is the -#33 migration base. The `operations_bandwidth` bank was already +fuel" — decided 2026-07-09, reversed 2026-07-10; v24 keeps it only as +save-migration history. The `operations_bandwidth` bank was already rejected/deleted 2026-07-09.) Sink, reservoir, tap, and arbitration rules live under `wiki/mechanics/machine-work.md` — not a second taxonomy debate.