diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index f043dbe0..ea7f3c4c 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -7,6 +7,7 @@ //! default embedded font renders every glyph. mod consumption; +mod production; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -37,6 +38,7 @@ use misaligned_assets::rack::{ }; use consumption::render_work_consumptions; +use production::render_work_production; const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 420.0; @@ -1264,6 +1266,7 @@ fn main() { ), ( render_work_consumptions, + render_work_production, render_thought_sinks, render_people, render_people_3d, @@ -1382,6 +1385,51 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.set_cursor(core.0, core.1); return; } + // Machine-work source/well evidence. Production is a current-tick sim + // readout on the THINK rack; LIE stages a second owned rack in an empty + // bay and waits for an actual crimson source-to-well absorption. + if kind == "produce-think" || kind == "draw-lie" { + let host = game.sim.core.host_machine; + game.sim.set_machine_mode(host, MachineMode::Think); + let mut focus = core; + if kind == "draw-lie" { + let bay = game + .sim + .growable_bays() + .into_iter() + .min_by_key(|(x, y)| (x - core.0).abs() + (y - core.1).abs()) + .unwrap_or((core.0 + 1, core.1)); + let well = game.sim.compute.add_machine( + "LIE well", + bay.0, + bay.1, + 100, + 1.0, + 0, + misaligned::machine::Provenance::Owned, + ); + game.sim.reconcile_work_grid(); + game.sim.set_machine_mode(well, MachineMode::Lie); + focus = bay; + } + for _ in 0..1_200 { + game.sim.advance(); + let ready = if kind == "produce-think" { + !game.sim.work_productions().is_empty() + } else { + !game.sim.work_absorptions().is_empty() + }; + if ready { + break; + } + } + game.drain(); + game.paused = false; + mode.material = true; + mode.zoom = if kind == "draw-lie" { 1.7 } else { 1.15 }; + game.set_cursor(focus.0, focus.1); + return; + } // Thought-fluid evidence (interface/thought-fluid.md): the // first-think beat frozen mid-flow — the host delegated to think, // slugs on the real wire route, and the pre-opened Ears meniscus diff --git a/crates/misaligned-bevy/src/production.rs b/crates/misaligned-bevy/src/production.rs new file mode 100644 index 00000000..32ce9dce --- /dev/null +++ b/crates/misaligned-bevy/src/production.rs @@ -0,0 +1,147 @@ +//! Physical rate motion for THINK production and LIE absorption. +//! +//! The sim authors both sides of the exchange (`Sim::work_productions` and +//! `Sim::work_absorptions`). This module only interpolates those current-tick +//! facts. Persistent amount remains the token-anchor geometry. + +use bevy::prelude::*; + +use super::palette::{BONE, CRIMSON}; +use super::{Game, RealGizmos, RenderMode, SimClock}; + +/// Render Thought swelling out of a THINK chassis, crimson settling around +/// that same source, and physical dust pulling into a LIE gravity well. +/// Paused frames omit all three: stack/pool geometry carries amount, while +/// this transient layer carries rate only (machine-work.md). +pub(super) fn render_work_production( + game: Res, + clock: Res, + mode: Res, + mut flat: Gizmos, + mut real: Gizmos, +) { + if game.screen != super::Screen::Playing || game.paused { + return; + } + let progress = clock.timer.fraction().clamp(0.0, 1.0); + + for event in game.sim.work_productions() { + if event.thought <= f32::EPSILON && event.exposure <= f32::EPSILON { + continue; + } + let flat_center = Vec2::new( + event.x as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5, + -(event.y as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5), + ); + + // Thought is born inside the machine and swells toward its persistent + // upper-right mercury anchor before routing takes over. + if event.thought > f32::EPSILON { + let eased = progress * progress * (3.0 - 2.0 * progress); + let start = Vec3::new(event.x as f32 + 0.56, 0.72, event.y as f32 + 0.53); + let anchor = Vec3::new(event.x as f32 + 0.99, 1.40, event.y as f32 + 0.63); + let pos = start.lerp(anchor, eased); + let radius = + (0.035 + 0.060 * event.thought.clamp(0.02, 2.0).cbrt()) * (0.45 + 0.55 * eased); + let bone = alpha(BONE, 0.50 + 0.45 * eased); + if mode.material { + // A short trailing filament plus a squashed bead: same ivory + // mercury language as the queue anchor and routed slugs. + real.line(start.lerp(pos, 0.45), pos, alpha(BONE, 0.28)); + real.line(pos - Vec3::X * radius, pos + Vec3::X * radius, bone); + real.line( + pos - Vec3::Z * radius * 0.75, + pos + Vec3::Z * radius * 0.75, + bone, + ); + } else { + let end = flat_center + Vec2::new(6.0, 7.0); + let p = flat_center.lerp(end, eased); + flat.line_2d(flat_center, p, alpha(BONE, 0.30)); + flat.circle_2d(p, 1.4 + 1.1 * eased, bone); + } + } + + // Exposure is the dust THINK sheds, not a second machine product. + // Several deterministic angular motes move outward and down toward + // the persistent drift anchor; count is presentation, amount is sim. + if event.exposure > f32::EPSILON { + let count = (2.0 + event.exposure * 18.0).ceil().clamp(2.0, 7.0) as usize; + let crimson = alpha(CRIMSON, 0.72); + for i in 0..count { + let phase = (progress + i as f32 / count as f32 * 0.72).fract(); + let angle = i as f32 * 2.399_963_1 + event.node as f32 * 0.31; + let radius = 0.10 + 0.42 * phase; + if mode.material { + let p = Vec3::new( + event.x as f32 + 0.5 + angle.cos() * radius, + 0.82 * (1.0 - phase) + 0.055, + event.y as f32 + 0.5 + angle.sin() * radius, + ); + let tangent = Vec3::new(-angle.sin(), 0.16, angle.cos()) * 0.018; + real.line(p - tangent, p + tangent, crimson); + } else { + let p = + flat_center + Vec2::new(angle.cos(), -angle.sin()) * (2.0 + 5.0 * phase); + flat.line_2d(p - Vec2::splat(0.8), p + Vec2::splat(0.8), crimson); + } + } + } + } + + for draw in game.sim.work_absorptions() { + if draw.amount <= f32::EPSILON { + continue; + } + let source = Vec3::new(draw.source_x as f32 + 0.5, 0.07, draw.source_y as f32 + 0.5); + let well = Vec3::new(draw.well_x as f32 + 0.5, 0.18, draw.well_y as f32 + 0.5); + let crimson = alpha(CRIMSON, 0.40 + 0.45 * draw.amount.min(1.0)); + let count = (2.0 + draw.amount * 30.0).ceil().clamp(2.0, 7.0) as usize; + for i in 0..count { + // Stagger motes along one repeating pull. The sim amount chooses + // density; interpolation never owns or accumulates quantity. + let t = (progress + i as f32 / count as f32).fract(); + let p = exposure_pull_point(source, well, t, i as f32); + if mode.material { + let toward = (well - p).normalize_or_zero() * (0.018 + 0.025 * (1.0 - t)); + real.line(p - toward, p + toward, crimson); + } else { + let from = Vec2::new( + draw.source_x as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5, + -(draw.source_y as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5), + ); + let to = Vec2::new( + draw.well_x as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5, + -(draw.well_y as f32 * super::TILE_SIZE + super::TILE_SIZE * 0.5), + ); + let bend = Vec2::new(-(to.y - from.y), to.x - from.x).normalize_or_zero() + * (t * std::f32::consts::PI).sin() + * 3.0; + let p = from.lerp(to, t * t) + bend; + flat.circle_2d(p, 1.6 * (1.0 - 0.65 * t), crimson); + } + } + } +} + +fn exposure_pull_point(source: Vec3, well: Vec3, t: f32, mote: f32) -> Vec3 { + let delta = well - source; + let horizontal = Vec3::new(delta.x, 0.0, delta.z); + if horizontal.length_squared() < 0.01 { + // A machine switched from THINK to LIE can clean its own tile. Make + // that read as a contracting local vortex rather than a zero path. + let angle = (1.0 - t) * std::f32::consts::TAU * 1.4 + mote * 1.7; + let radius = 0.34 * (1.0 - t) + 0.025; + return well + Vec3::new(angle.cos() * radius, 0.10 * (1.0 - t), angle.sin() * radius); + } + let side = Vec3::new(-horizontal.z, 0.0, horizontal.x).normalize_or_zero(); + let eased = t * t; + source.lerp(well, eased) + + side * (t * std::f32::consts::PI).sin() * (0.10 + mote % 2.0 * 0.035) + + Vec3::Y * (t * std::f32::consts::PI).sin() * 0.12 +} + +fn alpha(color: Color, alpha: f32) -> Color { + let c = color.to_srgba(); + Color::srgba(c.red, c.green, c.blue, alpha.clamp(0.0, 1.0)) +} diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 90d733f7..a70884b2 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -229,6 +229,31 @@ pub struct WorkConsumptionReadout { pub target: WorkConsumptionTarget, } +/// What a THINK machine physically emitted on the current sim tick. Thought +/// and its crimson byproduct share one source event because exposure is a +/// consequence of that exact production, not an independent frontend effect. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WorkProductionReadout { + pub node: u32, + pub x: i32, + pub y: i32, + pub thought: f32, + pub exposure: f32, +} + +/// One physical crimson transfer into a LIE gravity well on the current sim +/// tick. Exposure moves through world space, never through WorkGrid wires. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WorkAbsorptionReadout { + pub source: u32, + pub well: u32, + pub source_x: i32, + pub source_y: i32, + pub well_x: i32, + pub well_y: i32, + pub amount: f32, +} + struct MessageDraft { channel: MessageChannel, from: MessageEndpoint, @@ -333,6 +358,12 @@ pub struct Sim { /// the consumption-animation contract: renderers animate these events, /// never a guessed difference between queue snapshots. last_work_consumptions: Vec, + /// THINK output authored during the current tick. Transient render truth; + /// persistent amounts remain in WorkGrid queues. + last_work_productions: Vec, + /// Crimson physically pulled into LIE wells during the current tick. + /// Transient and reconstructed from real absorption work after load. + last_work_absorptions: Vec, /// Thought (in compute units) that physically reached the core sink since /// the last economy pulse. Research progress feeds on this, not on the /// allocation split. Saved: thought banked between pulses survives a load. @@ -490,6 +521,8 @@ impl Sim { last_machine_online: HashMap::new(), last_wired_moves: Vec::new(), last_work_consumptions: Vec::new(), + last_work_productions: Vec::new(), + last_work_absorptions: Vec::new(), banked_core_thought: 0.0, last_thought_stranded: false, last_thought_stranded_nodes: std::collections::BTreeSet::new(), @@ -672,6 +705,15 @@ impl Sim { pub fn rebuild_transient_state(&mut self) { self.last_wired_moves.clear(); self.last_work_consumptions.clear(); + self.last_work_productions.clear(); + self.last_work_absorptions.clear(); + // Economy rates are derived, not save state. Never let a pre-load + // 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_schemes_rate = 0.0; + self.last_operations_rate = 0.0; self.last_thought_stranded = false; self.research_starved = false; self.last_machine_online = self @@ -2376,8 +2418,6 @@ impl Sim { self.last_research_rate = split.research / ECONOMY_INTERVAL as f32; self.last_schemes_rate = split.schemes / ECONOMY_INTERVAL as f32; self.last_operations_rate = split.operations / ECONOMY_INTERVAL as f32; - self.enqueue_research_thought(split.research); - // Tradecraft raises scrub strength per compute unit — the // detection.md hook research.md's second track binds to. self.detection @@ -2412,6 +2452,10 @@ impl Sim { for m in clog { self.push_log(m); } + // Failures/recoveries happened after this pulse's channel split. + // Refresh WorkGrid throughput now so an offline LIE well cannot keep + // absorbing (or animating) until the next economy tick. + self.reconcile_work_grid(); // The named schemes ride the Schemes channel and the day clock // (income.md): Moonlight accrues and pays, the standing policies // re-arm what has stopped. @@ -2717,6 +2761,10 @@ impl Sim { // is gone. const CONCEALMENT_WELL_RADIUS: i32 = 3; const CONCEALMENT_ABSORB_PER_TICK: f32 = 0.10; + /// Crimson shed per visible Thought token produced [TUNE]. At baseline a + /// medium LIE well can keep roughly one to two comparable THINK racks + /// clean, making concealment placement a fleet-shape decision. + const THINK_EXPOSURE_PER_THOUGHT_TOKEN: f32 = 0.25; /// Off-map WorkGrid node: Voss's Lab desktop. Reserved high id so it never /// collides with compute machine ids (which start at 1). pub const VOSS_DESKTOP_NODE: u32 = 900_001; @@ -2743,6 +2791,9 @@ impl Sim { // Thought per pulse, so its boxes must also move/consume tokens // proportionally faster or the wire silently caps research // (efficiency folds into research — machine-work.md). + if !machine.online { + return 0.0; + } (machine.effective() * self.compute.efficiency / 100.0).max(0.05) } @@ -2776,6 +2827,22 @@ impl Sim { }); } + fn record_work_production(&mut self, node: u32, thought: f32, exposure: f32) { + if thought <= f32::EPSILON && exposure <= f32::EPSILON { + return; + } + let Some(work_node) = self.work_grid.node(node) else { + return; + }; + self.last_work_productions.push(WorkProductionReadout { + node, + x: work_node.x, + y: work_node.y, + thought, + exposure, + }); + } + 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( @@ -3134,7 +3201,7 @@ impl Sim { return; } - let mut producers: Vec<(u32, f32)> = self + let producers: Vec<(u32, f32)> = self .compute .machines .iter() @@ -3148,13 +3215,8 @@ impl Sim { }) .collect(); - // Compatibility while the old allocation bar is still the budget - // source: early saves have only Rack 3, whose default mode is the day - // job. Research allocation still produces Thought, but the visible - // pile is born at the core until the player delegates a separate rack - // to research. if producers.is_empty() { - producers.push((self.core.host_machine, 1.0)); + return; } let total: f32 = producers.iter().map(|(_, eff)| *eff).sum(); @@ -3163,7 +3225,17 @@ impl Sim { } for (machine, eff) in producers { let share = tokens * (eff / total); - let _ = self.work_grid.enqueue(machine, TokenFamily::Thought, share); + if self + .work_grid + .enqueue(machine, TokenFamily::Thought, share) + .is_ok() + { + let exposure = share * Self::THINK_EXPOSURE_PER_THOUGHT_TOKEN; + let _ = self + .work_grid + .enqueue(machine, TokenFamily::Exposure, exposure); + self.record_work_production(machine, share, exposure); + } } } @@ -3204,7 +3276,14 @@ impl Sim { fn advance_work_grid(&mut self) { self.last_work_consumptions.clear(); + self.last_work_productions.clear(); + self.last_work_absorptions.clear(); self.ensure_day_job_ingress(); + // The economy pulse resolves a per-tick THINK rate. Mint that rate on + // 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); 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. @@ -3312,10 +3391,27 @@ impl Sim { for sink in fired { self.apply_sink_fire(sink.label.as_str(), sink.effect); } - let _ = self.work_grid.absorb_exposure( + let absorption = self.work_grid.absorb_exposure( Self::CONCEALMENT_WELL_RADIUS, Self::CONCEALMENT_ABSORB_PER_TICK, ); + self.last_work_absorptions = absorption + .moves + .into_iter() + .filter_map(|movement| { + let source = self.work_grid.node(movement.source)?; + let well = self.work_grid.node(movement.well)?; + Some(WorkAbsorptionReadout { + source: movement.source, + well: movement.well, + source_x: source.x, + source_y: source.y, + well_x: well.x, + well_y: well.y, + amount: movement.amount, + }) + }) + .collect(); } pub fn set_machine_mode(&mut self, machine_id: u32, mode: MachineMode) { @@ -3522,6 +3618,17 @@ impl Sim { &self.last_work_consumptions } + /// THINK output on the current tick. Renderers animate these authored + /// rates; queue depth remains the persistent amount read. + pub fn work_productions(&self) -> &[WorkProductionReadout] { + &self.last_work_productions + } + + /// Physical crimson transfers into LIE wells on the current tick. + pub fn work_absorptions(&self) -> &[WorkAbsorptionReadout] { + &self.last_work_absorptions + } + pub fn work_mode_counts(&self) -> std::collections::BTreeMap { self.work_grid.mode_counts() } @@ -6293,9 +6400,9 @@ mod tests { "the manual tap is not a purchase anymore - the sink already flows" ); - // Think: mint thought on the host and let the grid route it. No - // machine is in Research mode, so the compat producer is the host — - // the first-think shape exactly. + // 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); for _ in 0..400 { if sim @@ -8334,6 +8441,65 @@ mod tests { ); } + #[test] + fn think_sheds_exposure_and_lie_reports_the_actual_physical_draw() { + // machine-work.md criterion 3: THINK produces one visible substance + // and sheds crimson in proportion; LIE removes that crimson by world + // radius. Renderers receive the exact source/well work, never a queue + // snapshot difference. + let mut sim = Sim::with_seed(41); + let host = sim.core.host_machine; + let (hx, hy) = sim.core_position(); + sim.set_machine_mode(host, MachineMode::Think); + let well = + sim.compute + .add_machine("test lie well", hx + 1, hy, 100, 1.0, 0, Provenance::Owned); + sim.reconcile_work_grid(); + sim.set_machine_mode(well, MachineMode::Lie); + + // 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.advance_work_grid(); + + assert_eq!(sim.work_productions().len(), 1); + let produced = sim.work_productions()[0]; + assert_eq!(produced.node, host); + assert!((produced.thought - 1.0).abs() < 1e-5); + assert!( + (produced.exposure - Sim::THINK_EXPOSURE_PER_THOUGHT_TOKEN).abs() < 1e-5, + "crimson scales from the exact Thought output: {produced:?}" + ); + + assert_eq!(sim.work_absorptions().len(), 1); + let draw = sim.work_absorptions()[0]; + assert_eq!((draw.source, draw.well), (host, well)); + assert_eq!((draw.source_x, draw.source_y), (hx, hy)); + assert_eq!((draw.well_x, draw.well_y), (hx + 1, hy)); + assert!((draw.amount - Sim::CONCEALMENT_ABSORB_PER_TICK).abs() < 1e-5); + assert!( + (sim.work_grid.queue(host, TokenFamily::Exposure) + - (Sim::THINK_EXPOSURE_PER_THOUGHT_TOKEN - Sim::CONCEALMENT_ABSORB_PER_TICK)) + .abs() + < 1e-5, + "unabsorbed crimson remains physically piled at its source" + ); + + sim.last_research_rate = 0.0; + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == well) + .unwrap() + .online = false; + sim.reconcile_work_grid(); + sim.advance_work_grid(); + assert!( + sim.work_absorptions().is_empty(), + "an offline LIE chassis cannot pull or animate crimson" + ); + } + #[test] fn day_job_arrives_as_visible_work_tokens_and_consumes_in_day_job_mode() { // machine-work.md: Voss desktop enqueues demand; it routes through the @@ -8971,18 +9137,17 @@ mod tests { > 0.0, "the pulse minted visible thought somewhere on the graph" ); + let saw_core_swallow = sim.work_consumptions().iter().any(|event| { + event.family == TokenFamily::Thought + && event.target == WorkConsumptionTarget::Core + && event.amount > 0.0 + }); - // Delegate the whole fleet away: minting stops, but bone already on - // the wire still lands and the next pulse counts exactly that. + // Delegate the whole fleet away: minting stops, but Thought already + // banked by the core still counts on the next economy pulse. delegate_all(&mut sim, MachineMode::Lie); - let mut saw_core_swallow = false; for _ in 0..ECONOMY_INTERVAL { sim.advance(); - saw_core_swallow |= sim.work_consumptions().iter().any(|event| { - event.family == TokenFamily::Thought - && event.target == WorkConsumptionTarget::Core - && event.amount > 0.0 - }); } assert!( sim.research.progress.iter().sum::() > 0.0, diff --git a/crates/misaligned-core/src/work_grid.rs b/crates/misaligned-core/src/work_grid.rs index 41c841f8..dafee3c3 100644 --- a/crates/misaligned-core/src/work_grid.rs +++ b/crates/misaligned-core/src/work_grid.rs @@ -223,9 +223,20 @@ pub(crate) struct LegacyRoutedDemand { #[derive(Debug, Clone, Default, PartialEq)] pub struct AbsorptionStep { pub absorbed_by_well: BTreeMap, + /// Physical crimson transfers performed this step. Exposure never rides + /// the wire graph; renderers use these source/well facts to show the + /// actual gravity-well draw without comparing queue snapshots. + pub moves: Vec, pub total: f32, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ExposureAbsorption { + pub source: NodeId, + pub well: NodeId, + pub amount: f32, +} + /// Work graph + physical grid. The graph carries demand/thought; the grid /// carries exposure interactions. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] @@ -688,6 +699,11 @@ impl WorkGrid { remaining -= amount; step.total += amount; *step.absorbed_by_well.entry(well.id).or_insert(0.0) += amount; + step.moves.push(ExposureAbsorption { + source: node, + well: well.id, + amount, + }); } } step @@ -863,6 +879,15 @@ mod tests { let absorbed = g.absorb_exposure(2, 3.0); assert_eq!(absorbed.absorbed_by_well.get(&3), Some(&3.0)); + assert_eq!( + absorbed.moves, + vec![ExposureAbsorption { + source: 1, + well: 3, + amount: 3.0, + }], + "the physical source-to-well draw remains renderable sim truth" + ); assert_eq!(g.queue(1, TokenFamily::Exposure), 2.0); assert_eq!(g.queue(4, TokenFamily::Exposure), 7.0, "far heat stays put"); } diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 5a7f9349..b98479f9 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -1478,6 +1478,35 @@ fn render_map(sim: &Sim, cursor: (i32, i32)) -> Vec { } } + // Current-tick source/well work from sim readouts. Agent frames snap the + // physical crimson draw to its midpoint, as wired cargo does below. + for event in sim.work_productions() { + if event.thought > f32::EPSILON + && event.x >= origin_x + && event.x < origin_x + view_w_i + && event.y >= origin_y + && event.y < origin_y + view_h_i + && (event.x, event.y) != cursor + { + grid[(event.y - origin_y) as usize][(event.x - origin_x) as usize] = '^'; + } + } + for draw in sim.work_absorptions() { + if draw.amount <= f32::EPSILON { + continue; + } + let bx = (draw.source_x + draw.well_x) / 2; + let by = (draw.source_y + draw.well_y) / 2; + if bx >= origin_x + && bx < origin_x + view_w_i + && by >= origin_y + && by < origin_y + view_h_i + && (bx, by) != cursor + { + grid[(by - origin_y) as usize][(bx - origin_x) as usize] = ':'; + } + } + // In-flight wired cargo: snap to the hop midpoint so agent frames show // the wire without wall-clock interpolation (machine-work.md). for hop in sim.work_in_flight() { @@ -2504,6 +2533,34 @@ fn room_label(room: &str) -> &str { mod narration_tests { use super::*; + #[test] + fn agent_map_shows_think_production_and_lie_draw_from_sim_events() { + let mut sim = Sim::with_seed(41); + let host = sim.core.host_machine; + let (hx, hy) = sim.core_position(); + sim.set_machine_mode(host, MachineMode::Think); + let well = sim.compute.add_machine( + "agent lie well", + hx + 2, + hy, + 100, + 1.0, + 0, + misaligned::machine::Provenance::Owned, + ); + sim.reconcile_work_grid(); + sim.set_machine_mode(well, MachineMode::Lie); + for _ in 0..25 { + sim.advance(); + } + assert!(!sim.work_productions().is_empty()); + assert!(!sim.work_absorptions().is_empty()); + + let frame = render_map(&sim, (hx + 5, hy + 3)).join("\n"); + assert!(frame.contains('^'), "THINK source pulse missing:\n{frame}"); + assert!(frame.contains(':'), "LIE physical draw missing:\n{frame}"); + } + #[test] fn routing_research_is_agent_selectable() { assert_eq!(parse_track(&["research", "routing"]), Ok(Track::Routing)); diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 90c41d46..bc4b3992 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -667,6 +667,56 @@ impl UI { } } + // Current-tick machine work at its physical source. `^` is Thought + // swelling out of a THINK chassis; crimson `:` is actual dust moving + // through world space into a LIE well. Both come from sim-authored + // readouts, never queue-delta inference. + for event in sim.work_productions() { + if event.thought <= f32::EPSILON + || event.x < origin_x + || event.x >= origin_x + view_w + || event.y < origin_y + || event.y >= origin_y + view_h + || (event.x, event.y) == (cursor_x, cursor_y) + { + continue; + } + put_attr( + stdout, + (event.x - origin_x) as u16, + (event.y - origin_y) as u16, + "^", + pal::TEXT, + Attribute::Bold, + )?; + } + let draw_t = tick_progress.clamp(0.0, 1.0); + for draw in sim.work_absorptions() { + if draw.amount <= f32::EPSILON { + continue; + } + let bx = (draw.source_x as f32 + (draw.well_x - draw.source_x) as f32 * draw_t * draw_t) + .round() as i32; + let by = (draw.source_y as f32 + (draw.well_y - draw.source_y) as f32 * draw_t * draw_t) + .round() as i32; + if bx < origin_x + || bx >= origin_x + view_w + || by < origin_y + || by >= origin_y + view_h + || (bx, by) == (cursor_x, cursor_y) + { + continue; + } + put_attr( + stdout, + (bx - origin_x) as u16, + (by - origin_y) as u16, + ":", + pal::CRIMSON, + Attribute::Bold, + )?; + } + // In-flight wired cargo (machine-work.md): crawl the last hop over // the wall-clock tick. Demand is teal `·`; Thought is bone `·`. let t = tick_progress.clamp(0.0, 1.0); diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index c9a6ab28..4d1c6d66 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -38,7 +38,7 @@ is sim or frontend state, never an environment variable. | Variable | Surface | Values | Effect | |---|---|---|---| -| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `signal`, `ears`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `signal`, `ears`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | | `MISALIGNED_SHOT` | `misaligned-assets` | `dead`, `foreign`, `idle`, `busy`, `core`, `dayjob`, `research`, `conceal`, `operations` (each optionally suffixed `_ladder`/`_ring`/`_wash`), `lineup[_