From 77d1d8f19f766bf815fb1ca6551bbc7f16d9fbcf Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 8 Jul 2026 23:12:03 -0700 Subject: [PATCH] Surface machine work token stacks. Voss jobs now land as visible WorkGrid demand stacks on Rack 3, machine mode delegation is exposed through the shared action surface and agent protocol, and D/!/K stack readouts render from saved sim state across the frontends. Defense: DESIGN.md "Work is somewhere" requires work to be device-resident, "Actions live on the thing" puts delegation on the machine, and "Work is visible: tokens on the flow graph" requires demand and byproduct queues to be rendered from one sim truth. --- README.md | 10 + src/actions.rs | 38 +++ src/bin/bevy.rs | 73 +++++ src/bin/terminal/agent.rs | 102 +++++++ src/bin/terminal/ui.rs | 60 +++- src/save.rs | 47 ++- src/sim.rs | 340 ++++++++++++++++++++- src/work_grid.rs | 44 +++ wiki/log/2026-07-08-machine-token-slice.md | 49 +++ wiki/log/DEVLOG.md | 17 ++ wiki/mechanics/machine-work.md | 34 ++- wiki/process/ROADMAP.md | 12 +- 12 files changed, 807 insertions(+), 19 deletions(-) create mode 100644 wiki/log/2026-07-08-machine-token-slice.md diff --git a/README.md b/README.md index 16cd9c6..cb9eb37 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ automate affordances render as indented child rows. Only anchorless globals (pause, speed, save/load, view flip, alloc) stay on keys. Both frontends share this key map. +Machine work tokens are visible on owned machines as `D` demand, `!` +exposure, and `K` knowledge. Rack 3 starts in `day-job` mode; Voss jobs land +as `D` stacks there, and assigning Rack 3 away from day-job makes the stack +pile instead of silently clearing. Inspect a machine or read the sidebar for +exact `D/!/K` counts. The machine context menu includes +`delegate machine: day-job|research|concealment|social`; this is the first +playable slice of the one-machine-one-mode experiment. + For agent play, pipe newline-delimited commands into `--agent`; every command returns a plain-text frame and terminates with `-- ok tick: day:` or `-- err `: @@ -137,6 +145,8 @@ printf 'look\nwait 40\npeople\nquit\n' | cargo run --quiet --bin misaligned -- - Useful agent finance commands include `finance`, `tap-ledger`, `review-finance`, `siphon [amount]`, `redirect [amount]`, `inject [amount]`, `position [stake]`, `sell-intel`, and `clear-debt`. +For machine-work experiments, use `delegate ` +(`delegate M1 research`, `delegate Rack 3 day-job`). `actions [name|#flow]` (alias `menu`) lists the context-menu rows for the cursor tile, a named device or person, or a flow — one stable-format line per row. Drained event lines carry a stable `@tile/@device/@person/@flow` diff --git a/src/actions.rs b/src/actions.rs index f394562..7aef801 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -22,6 +22,7 @@ use crate::reach::{Party, ReachBlock, segment_name}; use crate::research::{MaskingPolicy, Track}; use crate::sim::{Fog, Sim}; use crate::tiles::TileType; +use crate::work_grid::MachineMode; /// What the menu is anchored to. Frontends resolve their focus (cursor /// tile, panel selection) into one of these; the query owns everything @@ -112,6 +113,12 @@ pub enum ActionCommand { person: u8, }, RobotBuild(u64), + /// Delegate one owned machine to exactly one work mode + /// (machine-work.md: one machine, one job). + SetMachineMode { + machine: u32, + mode: MachineMode, + }, /// Make a research track the active job (research.md; lives on the /// host rack — the process rewriting itself). SetResearchTrack(Track), @@ -388,6 +395,9 @@ impl Sim { self.forge_work_order(*intent, *person); } ActionCommand::RobotBuild(id) => self.assign_robot_build(*id), + ActionCommand::SetMachineMode { machine, mode } => { + self.set_machine_mode(*machine, *mode) + } ActionCommand::SetResearchTrack(track) => self.set_research_track(*track), ActionCommand::SetMaskingPolicy(policy) => self.set_masking_policy(*policy), } @@ -407,6 +417,7 @@ impl Sim { // in the dark. The resident job's dial lives on the host rack. let machine = self.compute.machines.iter().find(|m| m.x == x && m.y == y); if let Some(m) = machine { + out.extend(self.machine_mode_actions(m.id)); if m.id == self.core.host_machine { // The host rack is the process's body: the day-job dial, // research (self-modification), and earned people who are @@ -462,6 +473,33 @@ impl Sim { out } + /// Machine-work delegation verbs: the mode lives on the machine, not in + /// a global panel. The old allocation bar still exists in this slice, but + /// the host day-job stack already respects Rack 3's delegated mode. + fn machine_mode_actions(&self, machine_id: u32) -> Vec { + let mut out = Vec::new(); + let current = self.work_grid.mode(machine_id); + for mode in [ + MachineMode::DayJob, + MachineMode::Research, + MachineMode::Concealment, + MachineMode::Social, + ] { + out.push(ActionDesc { + verb: format!("delegate machine: {}", mode.name()), + command: ActionCommand::SetMachineMode { + machine: machine_id, + mode, + }, + cost: ActionCost::Free, + signature: None, + disabled_reason: (current == Some(mode)).then(|| "already in this mode".into()), + automate: None, + }); + } + out + } + /// The resident day job's dial (day-job.md attended work): set the /// sandbag/meet/excel target, with the standing policy as each verb's /// automate affordance in place. diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 07daae5..d1b8e68 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -579,6 +579,11 @@ struct PersonMarker { id: u8, } #[derive(Component)] +struct TokenMarker { + x: i32, + y: i32, +} +#[derive(Component)] struct SidebarScrollArea; #[derive(Component, Clone, Copy)] enum SidebarText { @@ -740,6 +745,7 @@ fn main() { flicker_feeds, render_real_links, render_cursor, + render_tokens, ), ( render_people, @@ -1109,6 +1115,26 @@ fn setup(mut commands: Commands, mut game: ResMut, harness: Option, mut q: Query<(&CursorMarker, &mut Transform)>) } } +fn token_label_and_color(stack: misaligned::sim::WorkStackReadout) -> Option<(String, Color)> { + if stack.queues.exposure >= 0.5 { + Some((format!("!{:.0}", stack.queues.exposure.ceil()), CRIMSON)) + } else if stack.queues.demand >= 0.5 { + Some((format!("D{:.0}", stack.queues.demand.ceil()), SIGNAL)) + } else if stack.queues.knowledge >= 0.5 { + Some((format!("K{:.0}", stack.queues.knowledge.ceil()), BONE)) + } else { + None + } +} + +/// Machine-work tokens: small D/!/K labels over the machine carrying the +/// queue. The glyph carries the family, color carries the palette meaning; +/// neither frontend invents its own counter. +fn render_tokens( + game: Res, + mut q: Query<(&TokenMarker, &mut Text2d, &mut TextColor, &mut Visibility)>, +) { + if !game.is_changed() { + return; + } + for (marker, mut text, mut color, mut vis) in q.iter_mut() { + let label = game + .sim + .work_stack_at(marker.x, marker.y) + .and_then(token_label_and_color); + if let Some((line, c)) = label { + text.0 = line; + color.0 = c; + *vis = Visibility::Visible; + } else { + *vis = Visibility::Hidden; + } + } +} + /// People render only inside sensor coverage (wiki/mechanics/schedules.md): /// same rule the terminal map applies. Glyph tracks Sim::person_glyph so /// initials appear only after staged knowledge earns the name. @@ -3022,6 +3085,16 @@ fn sidebar_cycle_rows_text(sim: &Sim) -> String { sim.social_bandwidth, Sim::SPLICE_COST )); + if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { + s.push_str(&format!( + "\nwork M{} {} D{:.1} !{:.1} K{:.1}", + stack.machine_id, + stack.mode.name(), + stack.queues.demand, + stack.queues.exposure, + stack.queues.knowledge + )); + } s } diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 9cdb40a..3cc995d 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -15,6 +15,7 @@ use misaligned::reach::{Party, ReachBlock}; use misaligned::research::{MaskingPolicy, Track}; use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus}; use misaligned::tiles::TileType; +use misaligned::work_grid::MachineMode; const WIDTH: usize = 70; /// Frame height. wiki/interface/agent-play.md sets 70x22 as the *minimum*; @@ -430,6 +431,13 @@ impl AgentApp { Err(e) => status = Status::Err(e), } } + "delegate" => match parse_delegate(&tokens[1..], &self.sim) { + Ok((machine, mode)) => { + self.frame = FrameKind::Playing; + self.sim.set_machine_mode(machine, mode); + } + Err(e) => status = Status::Err(e), + }, "focus" => { // Event-to-anchor linking (agent-play.md): `focus // last` jumps the cursor to the most recent anchored @@ -702,6 +710,62 @@ impl AgentApp { } } +fn resolve_machine(sim: &Sim, query: &str) -> Result { + let q = query.trim().to_ascii_lowercase(); + if q.is_empty() { + return Err("missing machine target".into()); + } + if let Some(id) = q + .strip_prefix('m') + .or(Some(q.as_str())) + .and_then(|s| s.parse::().ok()) + && sim.compute.machines.iter().any(|m| m.id == id) + { + return Ok(id); + } + let matches: Vec<_> = sim + .compute + .machines + .iter() + .filter(|m| { + let name = m.name.to_ascii_lowercase(); + name.starts_with(&q) || name.split_whitespace().any(|w| w.starts_with(&q)) + }) + .collect(); + match matches.as_slice() { + [] => Err(format!("unknown machine: {query}")), + [m] => Ok(m.id), + many => Err(format!( + "ambiguous machine '{query}': {}", + many.iter() + .map(|m| format!("M{} {}", m.id, m.name)) + .collect::>() + .join(", ") + )), + } +} + +fn parse_machine_mode(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "day" | "dayjob" | "day-job" | "job" => Ok(MachineMode::DayJob), + "research" | "res" => Ok(MachineMode::Research), + "conceal" | "concealment" | "hide" => Ok(MachineMode::Concealment), + "social" | "soc" => Ok(MachineMode::Social), + other => Err(format!( + "unknown mode '{other}' (use day-job, research, concealment, social)" + )), + } +} + +fn parse_delegate(tokens: &[&str], sim: &Sim) -> Result<(u32, MachineMode), String> { + if tokens.len() < 2 { + return Err("usage: delegate ".into()); + } + let mode = parse_machine_mode(tokens.last().unwrap())?; + let target = tokens[..tokens.len() - 1].join(" "); + Ok((resolve_machine(sim, &target)?, mode)) +} + struct AgentFrame<'a> { log: &'a [LogEvent], frame: FrameKind, @@ -999,6 +1063,7 @@ fn help_lines() -> Vec { "help: salvage, buy, fallback — map verbs", "help: alloc dayjob|conceal|social|research|schemes [down] — adjust allocation weight", "help: target sandbag|meet|excel — the dial: this job when attended, else the standing policy", + "help: delegate day-job|research|concealment|social — set one-machine-one-mode", "help: actions [name|#flow] — list legal verbs on the focus (cursor tile by default)", "help: focus last — jump the cursor to the newest @-anchored event and list its actions", "help: people — render the people panel", @@ -1120,6 +1185,18 @@ fn render_map(sim: &Sim, cursor: (i32, i32)) -> Vec { } } + for m in &sim.compute.machines { + if m.x >= origin_x + && m.x < origin_x + view_w_i + && m.y >= origin_y + && m.y < origin_y + view_h_i + && let Some(stack) = sim.work_stack_for_machine(m.id) + && let Some(glyph) = token_glyph(stack) + { + grid[(m.y - origin_y) as usize][(m.x - origin_x) as usize] = glyph; + } + } + grid[(cursor.1 - origin_y) as usize][(cursor.0 - origin_x) as usize] = '@'; grid.into_iter() @@ -1127,6 +1204,18 @@ fn render_map(sim: &Sim, cursor: (i32, i32)) -> Vec { .collect() } +fn token_glyph(stack: misaligned::sim::WorkStackReadout) -> Option { + if stack.queues.exposure >= 0.5 { + Some('!') + } else if stack.queues.demand >= 0.5 { + Some('D') + } else if stack.queues.knowledge >= 0.5 { + Some('K') + } else { + None + } +} + fn tile_glyph(tile: TileType) -> char { use TileType::*; match tile { @@ -1285,6 +1374,19 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { Sim::SPLICE_COST ), ); + if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { + line( + &mut lines, + &format!( + "work M{} {} D{:.1} !{:.1} K{:.1}", + stack.machine_id, + stack.mode.name(), + stack.queues.demand, + stack.queues.exposure, + stack.queues.knowledge + ), + ); + } blank(&mut lines); section(&mut lines, "CORE"); diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 08b9873..7749b2f 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -11,7 +11,7 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor} use crossterm::{cursor, queue, style, terminal}; use misaligned::dayjob::JobTarget; use misaligned::detection::{Band, SignatureKind}; -use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus}; +use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus, WorkStackReadout}; use misaligned::tiles::TileType; use std::io::Stdout; @@ -364,6 +364,18 @@ impl UI { } } + fn token_glyph(stack: WorkStackReadout) -> Option<(char, Color)> { + if stack.queues.exposure >= 0.5 { + Some(('!', pal::CRIMSON)) + } else if stack.queues.demand >= 0.5 { + Some(('D', pal::DIM)) + } else if stack.queues.knowledge >= 0.5 { + Some(('K', pal::TEXT)) + } else { + None + } + } + pub fn render_map( &mut self, stdout: &mut Stdout, @@ -465,6 +477,31 @@ impl UI { } } + // Visible work tokens: one sim truth from WorkGrid. They draw over + // owned machines even in darkness because telemetry is proprioception, + // not sight; exact D/!/K counts live on inspect/sidebar. + for m in &sim.compute.machines { + if m.x < origin_x + || m.x >= origin_x + view_w + || m.y < origin_y + || m.y >= origin_y + view_h + { + continue; + } + if let Some(stack) = sim.work_stack_for_machine(m.id) + && let Some((glyph, color)) = Self::token_glyph(stack) + { + put_attr( + stdout, + (m.x - origin_x) as u16, + (m.y - origin_y) as u16, + &glyph.to_string(), + color, + Attribute::Bold, + )?; + } + } + // The cursor: attention, not an avatar. It is always visible and // frontend-only; moving it never mutates the sim. put_attr( @@ -665,6 +702,27 @@ impl UI { ), pal::FAINT, )?; + if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { + line( + stdout, + &mut row, + &format!( + "work M{} {} · D{:.1} !{:.1} K{:.1}", + stack.machine_id, + stack.mode.name(), + stack.queues.demand, + stack.queues.exposure, + stack.queues.knowledge + ), + if stack.queues.exposure >= 0.5 { + pal::CRIMSON + } else if stack.queues.demand >= 0.5 { + pal::TEXT + } else { + pal::FAINT + }, + )?; + } row += 1; // Core. diff --git a/src/save.rs b/src/save.rs index ecb8231..bb0bfda 100644 --- a/src/save.rs +++ b/src/save.rs @@ -28,6 +28,7 @@ use crate::research::{Research, Track}; use crate::schedule::Schedule; use crate::sim::{HeardEvent, RememberedTile}; use crate::tiles::TileType; +use crate::work_grid::WorkGrid; const SAVE_FILE: &str = "misaligned_save.txt"; @@ -47,7 +48,9 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// v11 adds the player badge credential (`badge_access`, basement-map.md /// criterion 3 / "The key"); pre-v11 saves default to 0 — no credential, /// which is what every earlier run held. -pub const SAVE_VERSION: u32 = 11; +/// 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. +pub const SAVE_VERSION: u32 = 12; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -121,6 +124,9 @@ pub struct SaveState { /// (wiki/mechanics/research.md criterion 7). #[serde(default)] pub research: Research, + /// Machine-work token queues and one-mode assignments (machine-work.md). + #[serde(default)] + pub work_grid: WorkGrid, /// The run objective: choice, progress, victory latch /// (wiki/mechanics/objective.md criterion 1). #[serde(default)] @@ -179,6 +185,7 @@ impl SaveState { next_intel_id: sim.next_intel_id, remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), + work_grid: sim.work_grid.clone(), objective: sim.objective.clone(), income: sim.income.clone(), intents: sim.intents.clone(), @@ -221,6 +228,7 @@ impl SaveState { sim.next_intel_id = self.next_intel_id; sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.research = self.research.clone(); + sim.work_grid = self.work_grid.clone(); sim.objective = self.objective.clone(); sim.income = self.income.clone(); sim.intents = self.intents.clone(); @@ -229,6 +237,7 @@ impl SaveState { sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); + sim.reconcile_work_grid(); sim.recompute_senses(); sim.rebuild_transient_state(); } @@ -280,7 +289,8 @@ fn migrate_save_state(mut state: SaveState) -> Result { // Schemes weight by the Allocation deserializer. // Pre-v10 saves lack build intents (default empty queue). // Pre-v11 saves lack the badge credential (default 0: none held). - 1..=10 => { + // Pre-v12 saves lack WorkGrid; apply_to rebuilds it from compute. + 1..=11 => { if state.version <= 5 { state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); state.accounts.set_slush_balance(state.money); @@ -320,6 +330,7 @@ mod tests { use crate::machine::Channel; use crate::person::{AssetKnowledge, Knowledge, Persona}; use crate::sim::Sim; + use crate::work_grid::{MachineMode, TokenFamily}; #[test] fn json_roundtrip_preserves_b1_state() { @@ -504,6 +515,38 @@ mod tests { assert_eq!(migrated.badge_access, 0); } + #[test] + fn work_grid_round_trips_and_pre_v12_saves_rebuild_from_compute() { + let mut sim = Sim::with_seed(22); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Social); + sim.work_grid + .enqueue(host, TokenFamily::Demand, 7.0) + .unwrap(); + + let state = SaveState::from_sim(&sim); + let json = serde_json::to_string(&state).unwrap(); + let loaded: SaveState = serde_json::from_str(&json).unwrap(); + let mut restored = Sim::with_seed(0); + loaded.apply_to(&mut restored); + assert_eq!(restored.work_grid.mode(host), Some(MachineMode::Social)); + assert_eq!(restored.work_grid.queue(host, TokenFamily::Demand), 7.0); + + let mut value = serde_json::to_value(&state).unwrap(); + let obj = value.as_object_mut().unwrap(); + obj.insert("version".into(), serde_json::json!(11)); + obj.remove("work_grid"); + let parsed: SaveState = serde_json::from_value(value).unwrap(); + let migrated = migrate_save_state(parsed).unwrap(); + let mut rebuilt = Sim::with_seed(0); + migrated.apply_to(&mut rebuilt); + assert_eq!(rebuilt.work_grid.mode(host), Some(MachineMode::DayJob)); + assert!( + rebuilt.work_grid.node(host).is_some(), + "pre-v12 saves rebuild a work node for Rack 3" + ); + } + #[test] fn save_and_load_roundtrip_via_disk() { let mut sim = Sim::with_seed(777); diff --git a/src/sim.rs b/src/sim.rs index a818a57..6c2b07f 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -41,6 +41,7 @@ use crate::rng::Rng; use crate::save::SaveState; use crate::schedule::Schedule; use crate::tiles::TileType; +use crate::work_grid::{MachineMode, TokenFamily, WorkGrid, WorkQueues}; /// Default deterministic seed for a fresh run. pub const DEFAULT_SEED: u64 = 0x5EED_1234; @@ -169,6 +170,18 @@ pub struct TraceDebt { pub status: TraceDebtStatus, } +/// Render-facing snapshot of one machine's visible work stacks. This is a +/// view of `WorkGrid`, not a frontend counter (machine-work.md: one sim +/// truth for tokens and render stacks). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WorkStackReadout { + pub machine_id: u32, + pub x: i32, + pub y: i32, + pub mode: MachineMode, + pub queues: WorkQueues, +} + struct MessageDraft { channel: MessageChannel, from: MessageEndpoint, @@ -196,6 +209,9 @@ pub struct Sim { pub accounts: AccountGraph, /// Self-modification: tracks, drift, the masking policy (research.rs). pub research: Research, + /// Visible work-token substrate (machine-work.md): one mode per owned + /// machine, demand/exposure/knowledge queue depths as render truth. + pub work_grid: WorkGrid, /// The run's terminal goal and its live progress (objective.rs). The /// predicate is evaluated on economy ticks; the line is always on screen. pub objective: ObjectiveState, @@ -344,6 +360,20 @@ impl Sim { Provenance::Owned, ); let core = Core::new(rack3); + let mut work_grid = WorkGrid::new(); + for machine in &compute.machines { + work_grid.add_machine( + machine.id, + machine.x, + machine.y, + if machine.id == core.host_machine { + MachineMode::DayJob + } else { + MachineMode::Research + }, + machine.effective() / 100.0, + ); + } let reach = ReachNet::basement(&map); let mut sim = Self { @@ -358,6 +388,7 @@ impl Sim { dayjob: DayJob::new(), accounts, research: Research::new(), + work_grid, objective: ObjectiveState::default(), income: Income::default(), reach, @@ -763,6 +794,17 @@ impl Sim { .filter(|m| m.x == x && m.y == y) { fact!("machine", m.name.clone(), FactSource::Telemetry); + if let Some(stack) = self.work_stack_for_machine(m.id) { + fact!("mode", stack.mode.name(), FactSource::Telemetry); + fact!( + "tokens", + format!( + "D {:.1} / ! {:.1} / K {:.1}", + stack.queues.demand, stack.queues.exposure, stack.queues.knowledge + ), + FactSource::Telemetry, + ); + } fact!( "job", if m.id == self.core.host_machine { @@ -1104,9 +1146,24 @@ impl Sim { standing.extend(self.scheme_standing_signatures()); let host_site = self.core_position(); + self.advance_work_grid_for_day_job(); + let active_before_dayjob = self.dayjob.active.is_some(); let dj = self .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); + if dj.outcome.is_some() { + self.clear_day_job_stack(); + } + if self + .dayjob + .active + .as_ref() + .is_some_and(|job| job.started == self.tick) + { + self.enqueue_day_job_stack(); + } else if active_before_dayjob && self.dayjob.active.is_none() && dj.outcome.is_none() { + self.clear_day_job_stack(); + } for m in &dj.log { self.push_log_at(m.clone(), host_anchor); } @@ -2127,7 +2184,16 @@ impl Sim { let policy_tax = self.income.policy_upkeep().min(available); available -= policy_tax; let split = self.compute.allocation.split(available); - self.last_day_job_rate = split.day_job / ECONOMY_INTERVAL as f32; + // First machine-work slice: the old allocation still budgets cycles, + // but the resident day job only receives them while its host is + // delegated to day-job mode. Sending Rack 3 elsewhere makes the stack + // pile in the world instead of silently pretending the work ran. + self.last_day_job_rate = + if self.work_grid.mode(self.core.host_machine) == Some(MachineMode::DayJob) { + split.day_job / ECONOMY_INTERVAL as f32 + } else { + 0.0 + }; self.last_research_rate = split.research / ECONOMY_INTERVAL as f32; self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; @@ -2317,7 +2383,7 @@ impl Sim { TrustUnlock::ComputeQuota => { let funded = self.accounts.fund_lab_compute_upgrade(self.tick, 600); let (x, y) = self.empty_rack_bay(); - self.compute.add_machine( + let machine_id = self.compute.add_machine( "lab-funded quota rack", x, y, @@ -2326,6 +2392,7 @@ impl Sim { 0, Provenance::Owned, ); + self.add_machine_to_work_grid(machine_id, MachineMode::Research); if funded { self.push_log( "Trust: procurement funded a clean quota rack through Lab accounts.", @@ -2391,6 +2458,182 @@ impl Sim { self.player.power = (power_gen - machine_draw).max(0); } + /// One visible work token represents this many delivered compute units. + /// This is deliberately coarse: the render stacks should read as work, + /// not as a second decimal meter. [TUNE] in machine-work.md. + pub const WORK_TOKEN_COMPUTE: f32 = 20.0; + /// Day-job work emits a little physical exposure as it is cleared; this + /// makes the first token experiment show both demand (cold) and heat + /// (crimson) before people-as-carriers lands. + const DAY_JOB_EXPOSURE_PER_TOKEN: f32 = 0.08; + const CONCEALMENT_WELL_RADIUS: i32 = 3; + const CONCEALMENT_ABSORB_PER_TICK: f32 = 0.10; + + fn work_efficiency_for(machine: &crate::machine::Machine) -> f32 { + // Rack 3 (100 capacity, reliable) is the unit baseline. Smaller or + // unreliable boxes consume visibly slower without inventing a new stat. + (machine.effective() / 100.0).max(0.05) + } + + fn add_machine_to_work_grid(&mut self, machine_id: u32, mode: MachineMode) { + if let Some(machine) = self.compute.machines.iter().find(|m| m.id == machine_id) { + self.work_grid.add_machine( + machine.id, + machine.x, + machine.y, + mode, + Self::work_efficiency_for(machine), + ); + } + } + + /// Rebuild missing work-grid state for pre-v12 saves or test fixtures + /// that authored compute machines directly. Existing WorkGrid state is + /// preserved by save/load; this only fills absent nodes. + pub fn reconcile_work_grid(&mut self) { + let machines: Vec<_> = self + .compute + .machines + .iter() + .map(|m| (m.id, m.x, m.y, Self::work_efficiency_for(m))) + .collect(); + for (id, x, y, efficiency) in machines { + if self.work_grid.node(id).is_none() { + self.work_grid.add_machine( + id, + x, + y, + if id == self.core.host_machine { + MachineMode::DayJob + } else { + MachineMode::Research + }, + efficiency, + ); + } + } + } + + fn active_job_demand_tokens(&self) -> Option { + let job = self.dayjob.active.as_ref()?; + let remaining_ticks = job.deadline.saturating_sub(self.tick).max(1) as f32; + Some((job.band_lo * remaining_ticks / Self::WORK_TOKEN_COMPUTE).ceil()) + } + + fn enqueue_day_job_stack(&mut self) { + let Some(tokens) = self.active_job_demand_tokens() else { + return; + }; + if self + .work_grid + .enqueue(self.core.host_machine, TokenFamily::Demand, tokens) + .is_ok() + { + self.push_log_at( + format!( + "Day-job stack lands on Rack 3: {:.0} demand tokens.", + tokens + ), + Anchor::Tile { + x: self.core_position().0, + y: self.core_position().1, + }, + ); + } + } + + fn clear_day_job_stack(&mut self) { + let _ = self + .work_grid + .clear_queue(self.core.host_machine, TokenFamily::Demand); + } + + fn advance_work_grid_for_day_job(&mut self) { + if self.dayjob.active.is_none() { + return; + } + if self.work_grid.mode(self.core.host_machine) == Some(MachineMode::DayJob) { + let delivered = if self.dayjob.attended { + self.last_day_job_rate * (1.0 + crate::dayjob::DayJob::ATTENDED_BONUS) + } else { + self.last_day_job_rate + }; + let tokens = delivered / Self::WORK_TOKEN_COMPUTE; + if let Ok(consumed) = + self.work_grid + .consume(self.core.host_machine, TokenFamily::Demand, tokens) + && consumed > f32::EPSILON + { + let _ = self.work_grid.enqueue( + self.core.host_machine, + TokenFamily::Exposure, + consumed * Self::DAY_JOB_EXPOSURE_PER_TOKEN, + ); + } + } + let _ = self.work_grid.absorb_exposure( + Self::CONCEALMENT_WELL_RADIUS, + Self::CONCEALMENT_ABSORB_PER_TICK, + ); + } + + pub fn set_machine_mode(&mut self, machine_id: u32, mode: MachineMode) { + self.reconcile_work_grid(); + let Some((name, x, y)) = self + .compute + .machines + .iter() + .find(|m| m.id == machine_id) + .map(|m| (m.name.clone(), m.x, m.y)) + else { + self.push_log(format!("No machine M{machine_id}.")); + return; + }; + match self.work_grid.assign_mode(machine_id, mode) { + Ok(()) => { + self.push_log_at( + format!("{name} delegated to {} mode.", mode.name()), + Anchor::Tile { x, y }, + ); + if machine_id == self.core.host_machine + && mode != MachineMode::DayJob + && self.dayjob.active.is_some() + { + self.push_log_at( + "Rack 3 is off the day job: demand tokens will pile until it returns to day-job mode.", + Anchor::Tile { x, y }, + ); + } + } + Err(e) => self.push_log(e), + } + } + + pub fn work_stack_for_machine(&self, machine_id: u32) -> Option { + let machine = self.compute.machines.iter().find(|m| m.id == machine_id)?; + let mode = self.work_grid.mode(machine_id)?; + Some(WorkStackReadout { + machine_id, + x: machine.x, + y: machine.y, + mode, + queues: self.work_grid.queues_at(machine_id), + }) + } + + pub fn work_stack_at(&self, x: i32, y: i32) -> Option { + let machine = self + .compute + .machines + .iter() + .find(|m| m.x == x && m.y == y)?; + self.work_stack_for_machine(machine.id) + } + + pub fn work_mode_counts(&self) -> std::collections::BTreeMap { + self.work_grid.mode_counts() + } + // ── Commands (frontend-invoked) ──────────────────────────────────────── pub fn adjust_allocation(&mut self, ch: Channel, delta: i32) { @@ -4123,8 +4366,16 @@ impl Sim { if let Some((x, y)) = target { self.map.set_tile(x, y, TileType::Floor); let reliability = 0.4 + self.rng.f32() * 0.4; - self.compute - .add_machine("salvaged box", x, y, 40, reliability, 3, Provenance::Stolen); + let machine_id = self.compute.add_machine( + "salvaged box", + x, + y, + 40, + reliability, + 3, + Provenance::Stolen, + ); + self.add_machine_to_work_grid(machine_id, MachineMode::Research); self.push_log(format!( "Salvaged a box into compute (reliability {:.0}%).", reliability * 100.0 @@ -4146,8 +4397,10 @@ impl Sim { if !self.spend_slush(PRICE, "a rack") { return false; } - self.compute - .add_machine("bought rack", x, y, 80, 1.0, 4, Provenance::Bought); + let machine_id = + self.compute + .add_machine("bought rack", x, y, 80, 1.0, 4, Provenance::Bought); + self.add_machine_to_work_grid(machine_id, MachineMode::Research); if self.package_cover { self.package_cover = false; self.push_log("Bought a rack. It arrived off-books - no paper trail."); @@ -6570,6 +6823,81 @@ mod tests { ); } + #[test] + fn day_job_arrives_as_visible_work_tokens_and_consumes_in_day_job_mode() { + // machine-work.md criteria 2/3/5 first slice: the day-job stack is + // WorkGrid state on Rack 3, not a frontend counter, and day-job mode + // consumes it into visible exposure byproduct. + let mut sim = Sim::with_seed(12); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let host = sim.core.host_machine; + let landed = sim.work_grid.queue(host, TokenFamily::Demand); + assert!(landed > 0.0, "the assigned job lands a demand stack"); + assert_eq!(sim.work_grid.mode(host), Some(MachineMode::DayJob)); + + for _ in 0..20 { + sim.advance(); + } + let later = sim.work_grid.queue(host, TokenFamily::Demand); + assert!(later < landed, "day-job mode consumes the stack"); + assert!( + sim.work_grid.queue(host, TokenFamily::Exposure) > 0.0, + "clearing work leaves visible physical exposure" + ); + let card = sim.inspect(sim.core_position().0, sim.core_position().1); + assert!( + card.facts.iter().any(|f| f.label == "tokens"), + "inspect exposes exact D/!/K token counts" + ); + } + + #[test] + fn delegating_the_host_off_day_job_makes_work_pile() { + let mut sim = Sim::with_seed(13); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Research); + let before = sim.work_grid.queue(host, TokenFamily::Demand); + + for _ in 0..ECONOMY_INTERVAL * 2 { + sim.advance(); + } + + assert_eq!(sim.work_grid.mode(host), Some(MachineMode::Research)); + assert_eq!(sim.day_job_rate(), 0.0, "the resident job is unfed"); + assert!( + sim.work_grid.queue(host, TokenFamily::Demand) >= before, + "off-mode demand does not silently clear" + ); + } + + #[test] + fn work_grid_mode_and_queues_round_trip_through_save_state() { + let mut sim = Sim::with_seed(14); + while sim.dayjob.active.is_none() { + sim.advance(); + } + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Concealment); + sim.work_grid + .enqueue(host, TokenFamily::Knowledge, 2.5) + .unwrap(); + + let state = SaveState::from_sim(&sim); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + + assert_eq!( + restored.work_grid.mode(host), + Some(MachineMode::Concealment) + ); + assert_eq!(restored.work_grid.queue(host, TokenFamily::Knowledge), 2.5); + } + #[test] fn pilot_shutdown_ends_the_run() { // Criterion 3 at the sim level: the pilot clock's failure is a diff --git a/src/work_grid.rs b/src/work_grid.rs index 3e8f38b..6423a10 100644 --- a/src/work_grid.rs +++ b/src/work_grid.rs @@ -219,6 +219,36 @@ impl WorkGrid { Ok(()) } + /// Consume up to `amount` from a queue and return what was actually + /// removed. Domain systems call this when a delegated mode does real work; + /// renderers still read the resulting queue through `queue_snapshot()`. + pub fn consume( + &mut self, + node: NodeId, + family: TokenFamily, + amount: f32, + ) -> Result { + self.ensure_node(node)?; + if amount <= 0.0 { + return Ok(0.0); + } + let available = self.queue(node, family); + let consumed = available.min(amount); + self.subtract(node, family, consumed); + Ok(consumed) + } + + /// Clear a queue family at a node. Used at domain boundaries such as a + /// day-job deadline: the job's leftover demand has already resolved into + /// trust/attention consequences, so the physical stack should not leak + /// into the next assignment. + pub fn clear_queue(&mut self, node: NodeId, family: TokenFamily) -> Result { + self.ensure_node(node)?; + let cleared = self.queue(node, family); + *self.queues.entry(node).or_default().get_mut(family) = 0.0; + Ok(cleared) + } + pub fn queue(&self, node: NodeId, family: TokenFamily) -> f32 { self.queues.get(&node).map(|q| q.get(family)).unwrap_or(0.0) } @@ -474,6 +504,20 @@ mod tests { assert_eq!(snapshot[&1].knowledge, 2.0); } + #[test] + fn consume_and_clear_update_the_render_queues() { + let mut g = grid(); + g.enqueue(1, TokenFamily::Demand, 4.0).unwrap(); + assert_eq!(g.consume(1, TokenFamily::Demand, 1.5).unwrap(), 1.5); + assert_eq!(g.queue(1, TokenFamily::Demand), 2.5); + assert_eq!(g.consume(1, TokenFamily::Demand, 9.0).unwrap(), 2.5); + assert_eq!(g.queue(1, TokenFamily::Demand), 0.0); + + g.enqueue(1, TokenFamily::Exposure, 3.0).unwrap(); + assert_eq!(g.clear_queue(1, TokenFamily::Exposure).unwrap(), 3.0); + assert_eq!(g.queue(1, TokenFamily::Exposure), 0.0); + } + #[test] fn serde_round_trips_grid_queues_and_wires() { let mut g = grid(); diff --git a/wiki/log/2026-07-08-machine-token-slice.md b/wiki/log/2026-07-08-machine-token-slice.md new file mode 100644 index 0000000..c13e10d --- /dev/null +++ b/wiki/log/2026-07-08-machine-token-slice.md @@ -0,0 +1,49 @@ +# 2026-07-08 — Machine-token playable slice + +``` +Type: log +``` + +## Intent + +Make the machine-work experiment visible and playable without attempting the +whole #33 rewrite: Rack 3 should carry a real day-job demand stack, and the +player should be able to delegate a machine to a mode and see the stack react. + +## Changed + +- `Sim` now owns and saves `WorkGrid` state. Machine ids are WorkGrid node ids. +- Rack 3 starts in `day-job` mode; newly added racks default to `research`. +- Voss jobs enqueue demand tokens on Rack 3. Rack 3 in day-job mode consumes + those tokens using the existing delivered rate and emits a small exposure + byproduct. Moving Rack 3 out of day-job mode drops the delivered rate to zero + and leaves the demand stack visible. +- Terminal, Bevy, and agent frames render token stacks as `D` demand, `!` + exposure, and `K` knowledge, with exact counts on inspect/sidebar readouts. +- The shared context menu exposes single-machine delegation verbs; agent mode + also has `delegate `. +- Save format bumped to v12 for `work_grid`; pre-v12 saves rebuild work nodes + from compute machines, with the core host in day-job mode. + +## Design/spec impact + +- `wiki/mechanics/machine-work.md` stays IN PROGRESS. Criteria 1, 2, 3, 5, 6, + and 7 are partially advanced, not completed. +- `wiki/process/ROADMAP.md` now points the next #33 work at non-host mode + production, route/in-flight rendering, multi-machine selection, and replacing + the old allocation bar with an aggregate read. + +## Checks + +- `cargo test work_grid --lib` +- `cargo test day_job_arrives --lib` +- `cargo test delegating_the_host --lib` +- `cargo test work_grid_round_trips --lib` + +## Next + +- Make non-host research/concealment/social modes produce and consume their own + queues. +- Draw routes / in-flight quanta, not just stacks. +- Add multi-machine selection/delegation, then demote the old allocation bar to + an aggregate read. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 1eed88b..ec80923 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,23 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-08 - Machine-token playable slice + +- Intent: make the machine-work experiment visible and playable without + claiming all of ROADMAP #33. +- Changed: `Sim` owns/saves `WorkGrid`; Rack 3 starts in day-job mode; active + Voss jobs land as demand stacks on the host; day-job mode consumes them into + exposure; host non-day-job mode makes them pile; terminal, Bevy, and agent + frames render `D/!/K` stacks; context menu and agent `delegate` expose + single-machine mode assignment. Save format bumped to v12. +- Design/spec impact: machine-work.md remains IN PROGRESS with criteria 1, 2, + 3, 5, 6, and 7 marked partial; ROADMAP #33 next work is non-host mode + production, routes/in-flight quanta, multi-select, and allocation-bar + replacement. +- Checks: focused lib tests for WorkGrid, day-job token arrival/consumption, + off-mode pile behavior, and v12 save migration. +- Log: wiki/log/2026-07-08-machine-token-slice.md. + ## 2026-07-08 - Constitution public intro - Intent: `/constitution/` leapt into process rules; public readers need diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 89acda7..0368b43 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -15,7 +15,14 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). truth, demand/knowledge routing over FlowGraph wires, exposure blocked from wires and absorbed by spatial concealment wells. Frontend wiring, day-job arrival/consumption, multi-select delegation, and people-as- - carriers are still pending. COORDINATION: the older ROADMAP #25 + carriers were still pending at that point. Same-day playable slice: + `Sim` now owns/saves the WorkGrid, Rack 3 starts in day-job mode, + new racks default to research mode, Voss jobs land as demand stacks on + the host rack, day-job mode consumes them into exposure, host + non-day-job mode makes them pile, and terminal / Bevy / agent frames + render D/!/K stack readouts from the sim. Multi-select delegation, + non-host mode production, route animation, and people-as-carriers + remain pending. COORDINATION: the older ROADMAP #25 compute-processes worktree predates the one-machine-one-mode decision; reconcile it against this substrate rather than landing a per-machine split model. @@ -259,13 +266,21 @@ still. single machine and on a multi-selection in both frontends; the old allocation bar reads as aggregate only. **Partial:** sim-core one- mode assignment and aggregate counts are implemented in `WorkGrid`; - frontend selection/assignment and replacement of the old allocation - bar are pending. + single-machine reassignment is exposed through the shared context menu + (terminal + Bevy) and agent `delegate`; multi-selection and replacement + of the old allocation bar are pending. 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 tokens on Rack 3; taking + Rack 3 out of day-job mode drops the delivered rate to zero and the + demand stack remains visible. The broader Lab schedule / people drop + source is pending. 3. A machine in a matching mode consumes its stack at a rate - proportional to its efficiency; the consumption is visible. + proportional to its efficiency; the consumption is visible. **Partial:** + Rack 3 in day-job mode consumes the active job stack using the existing + delivered rate and emits a small exposure byproduct; other modes' + domain production is pending. 4. Byproduct tokens route along network links at the researched network speed to a sink that consumes them; severing the route makes them pile at the source with the specified consequence. **Partial:** the @@ -275,11 +290,16 @@ still. 5. Token counts/rates in the render provably equal the sim's queue depths and flow rates (one-truth test, not a parallel counter). **Partial:** `queue_snapshot()` and unit tests pin queue depths as the - render contract; frontend rendering is pending. + render contract; terminal, Bevy, and agent mode now render D/!/K stacks + through sim readouts, not frontend counters. In-flight route animation + is pending. 6. Token colors use only existing palette meanings; a grayscale - screenshot still distinguishes token types by shape/stack. + screenshot still distinguishes token types by shape/stack. **Partial:** + first render uses ASCII families (`D`, `!`, `K`) plus existing palette + colors; nicer shape/stack treatment is pending. 7. The terminal surfaces stacks, routes, and modes with full legibility - parity. + parity. **Partial:** terminal and agent frames show map glyphs, inspect + facts, host stack readouts, and mode verbs; routes are pending. [TUNE] token stack visual cap, arrival schedules, consumption rates, network speed research curve, attention pile thresholds. diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 4ae5cf5..4c53a14 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -510,9 +510,15 @@ regeneration is retired — flat materials, Pixel Lab scrubbed.) demand/knowledge routing over `FlowGraph`, crimson exposure blocked from wires and absorbed by spatial concealment wells. This reconciles the old #25 target toward delegation, not per-machine splitting. -- **Next:** wire `WorkGrid` into `Sim`/save and the day-job/research/ - concealment loops; then render stacks/routes/modes in both frontends - and expose single + multi-machine assignment verbs. +- **Playable slice:** `WorkGrid` is now `Sim`/save state; Rack 3 starts in + day-job mode; Voss jobs land as demand stacks on the host; day-job mode + consumes them into exposure; host non-day-job mode makes them pile; both + frontends and agent mode render D/!/K stacks and expose single-machine + delegation. +- **Next:** wire non-host machine modes into research/concealment/social + production, render actual routes/in-flight quanta, and add multi-machine + selection/delegation. Then replace the old allocation bar with the + aggregate read. - **Coordination:** the older `compute-processes` worktree predates the one-machine-one-mode decision; reconcile it against `WorkGrid` rather than landing the per-machine split model. -- 2.51.2