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.