diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index e6e91216..ff7b624f 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -21,7 +21,7 @@ use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; -use misaligned::actions::{Anchor, DialId, HumanMenuRow, menu_rows}; +use misaligned::actions::{ActionKind, Anchor, DialId, HumanMenuRow, menu_rows}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::reach::Party; @@ -51,7 +51,7 @@ const MENU_WIDTH: f32 = 300.0; /// The hover grammar grows only when several actionable bodies share a tile. /// Width follows the union rather than reserving an empty HUD-sized strip. const MACHINE_VERB_BAR_WIDTH: f32 = 320.0; -const DEVICE_VERB_BAR_WIDTH: f32 = 152.0; +const DEVICE_VERB_BAR_WIDTH: f32 = 240.0; /// Air between the fixed mode grammar and the machine base it belongs to. const HOVER_VERB_BAR_GAP: f32 = 12.0; const DETECTION_ROWS: usize = 6; @@ -5855,7 +5855,7 @@ fn setup_ui(mut commands: Commands) { }, )) .with_children(|line| { - for (index, label) in ["TAP", "/", "TAKE"].into_iter().enumerate() { + for (index, label) in ["TAP", "/", "UNTAP", "/", "TAKE"].into_iter().enumerate() { line.spawn(( Text::new(label), TextFont { @@ -5940,10 +5940,27 @@ fn hover_verb_bar_anchor( /// Resolve one physical tile, then union every stable verb family living on /// it. A co-located device must not replace its machine (or vice versa). /// One-off verbs already remain in the tile's full context menu. +fn device_hover_verbs(sim: &Sim, id: u32) -> Vec<&'static str> { + sim.available_actions(Anchor::Device(id)) + .iter() + .filter_map(|action| match action.command.kind() { + ActionKind::Tap => Some("TAP"), + ActionKind::Untap => Some("UNTAP"), + ActionKind::Take => Some("TAKE"), + _ => None, + }) + .collect() +} + fn hover_verb_bar_target(game: &Game, pointer: Option<(i32, i32)>) -> Option { let at = |x, y| { let machine = game.machine_at(x, y).map(|(id, _, _)| id); - let device = game.sim.reach.known_at(x, y).map(|device| device.id); + let device = game + .sim + .reach + .known_at(x, y) + .map(|device| device.id) + .filter(|id| !device_hover_verbs(&game.sim, *id).is_empty()); (machine.is_some() || device.is_some()).then_some(HoverVerbTarget { machine, device, @@ -6040,9 +6057,17 @@ fn render_hover_verb_bar( let current_mode = target .machine .and_then(|machine| game.sim.work_grid.mode(machine)); - let device = target.device.and_then(|id| game.sim.reach.device(id)); - let tapped = device.is_some_and(|d| d.subscribed_by(Party::Player)); - let taken = device.is_some_and(|d| d.controller == Party::Player); + let device_verbs = target + .device + .map(|id| device_hover_verbs(&game.sim, id)) + .unwrap_or_default(); + let mut device_slots = Vec::new(); + for verb in device_verbs { + if !device_slots.is_empty() { + device_slots.push("/"); + } + device_slots.push(verb); + } for (group, mut group_visibility) in groups.iter_mut() { let shown = match group.family { HoverVerbFamily::Machine => target.machine.is_some(), @@ -6071,9 +6096,8 @@ fn render_hover_verb_bar( (label, active, target.machine.is_some(), control_hint) } HoverVerbFamily::Device => { - let slots = [("TAP", tapped), ("/", false), ("TAKE", taken)]; - let (label, active) = slots[word.index]; - (label, active, target.device.is_some(), false) + let label = device_slots.get(word.index).copied().unwrap_or(""); + (label, false, !label.is_empty(), false) } }; text.0 = label.into(); @@ -6097,8 +6121,8 @@ fn render_hover_verb_bar( #[cfg(test)] mod hover_verb_bar_tests { use super::{ - Game, HOVER_VERB_BAR_GAP, MACHINE_VERB_BAR_WIDTH, hover_verb_bar_position, - hover_verb_bar_target, + Game, HOVER_VERB_BAR_GAP, MACHINE_VERB_BAR_WIDTH, device_hover_verbs, + hover_verb_bar_position, hover_verb_bar_target, }; use bevy::prelude::Vec2; @@ -6125,7 +6149,7 @@ mod hover_verb_bar_tests { } #[test] - fn grammar_unions_every_stable_verb_family_on_the_reticule_tile() { + fn controlled_colocated_device_does_not_add_empty_verb_line() { let game = Game::new(); let core = game.sim.core_position(); @@ -6136,9 +6160,33 @@ mod hover_verb_bar_tests { let target = hover_verb_bar_target(&game, None).expect("core tile has stable verbs"); assert!(target.machine.is_some(), "WORK / THINK / LIE survive"); assert!( - target.device.is_some(), - "TAP / TAKE join rather than replace them" + target.device.is_none(), + "a controlled device with no stable action contributes no empty line" + ); + } + + #[test] + fn device_line_reveals_take_only_after_tap() { + let mut game = Game::new(); + let env = game + .sim + .reach + .device_named("environmental monitor") + .unwrap() + .id; + assert_eq!(device_hover_verbs(&game.sim, env), vec!["TAP"]); + + game.sim.reach.tap(env); + game.sim.recompute_senses(); + assert_eq!( + device_hover_verbs(&game.sim, env), + vec!["TAP", "UNTAP", "TAKE"], + "the dormant camera remains tappable while subscription reveals control" ); + + game.sim.reach.tap_dormant_camera(env); + game.sim.recompute_senses(); + assert_eq!(device_hover_verbs(&game.sim, env), vec!["UNTAP", "TAKE"]); } } diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index 9e886867..3b241e36 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -1574,7 +1574,7 @@ impl Sim { // A combined monitor offers its live audio first; once heard, the // same TAP verb offers its dormant camera at the higher Eyes cost. let has_feed = d.sees || d.hears || !d.message_channels.is_empty(); - if has_feed { + if has_feed || d.controller != Party::Player { let dormant_camera = d.dormant_camera_is_next_tap(Party::Player); let can_tap = dormant_camera || d.has_live_feed_to_tap(Party::Player); let tap_target = if dormant_camera { @@ -1592,6 +1592,7 @@ impl Sim { (true, false, true) => "audio + messages", (false, true, true) => "camera + messages", (true, true, true) => "sight + audio + messages", + (false, false, false) if !has_feed => "control channel", (false, false, false) => "feed", } }; @@ -1637,8 +1638,9 @@ impl Sim { }); } - // TAKE is ownership transfer; something already yours offers no verb. - if d.controller != Party::Player { + // TAKE is the next control rung after TAP. It is absent before the + // subscription exists (reach.md), not shown as a disabled spoiler. + if d.controller != Party::Player && d.subscribed_by(Party::Player) { out.push(ActionDesc { verb: format!("take the {}", d.name), command: ActionCommand::TakeDevice(id), @@ -2575,14 +2577,11 @@ mod tests { "Network feeds the IT observer (gated label): {}", sig.observer ); - let take = acts - .iter() - .find(|a| matches!(a.command, ActionCommand::TakeDevice(_))) - .expect("a foreign device offers ownership transfer"); assert!( - take.verb.starts_with("take "), - "TAKE is the ownership verb: {}", - take.verb + !acts + .iter() + .any(|action| matches!(action.command, ActionCommand::TakeDevice(_))), + "TAKE stays absent until the device is tapped" ); assert_eq!( @@ -2601,6 +2600,15 @@ mod tests { s.apply_sink_fire(&sink.label, sink.effect); } let acts = s.available_actions(Anchor::Device(env)); + let take = acts + .iter() + .find(|action| matches!(action.command, ActionCommand::TakeDevice(_))) + .expect("the landed audio tap reveals ownership transfer"); + assert!( + take.verb.starts_with("take "), + "TAKE is the ownership verb: {}", + take.verb + ); let tap = acts .iter() .find(|a| matches!(a.command, ActionCommand::TapDevice(_))) diff --git a/crates/misaligned-core/src/reach.rs b/crates/misaligned-core/src/reach.rs index 247b8e5f..cde5013e 100644 --- a/crates/misaligned-core/src/reach.rs +++ b/crates/misaligned-core/src/reach.rs @@ -116,6 +116,10 @@ impl Device { (self.sees && !self.camera_dormant && !self.feed_to(who, true)) || (self.hears && !self.feed_to(who, false)) || (!self.message_channels.is_empty() && !self.subscribed_by(who)) + || (!self.sees + && !self.hears + && self.message_channels.is_empty() + && !self.subscribed_by(who)) } pub fn carries_message_channel(&self, channel: MessageChannel) -> bool { diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 71dc49bd..d00fec5d 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -340,6 +340,10 @@ impl SaveState { // Pre-v19 saves carry no ledger: re-stage the opening senses from // device state (idempotent; a fired Ears in the ledger is respected). sim.ensure_opening_sinks(); + // Saves written before device subscriptions gained standing upkeep + // have the subscriber record but no persistent sink. Repair those + // taps in place; owned devices remain drain-free. + sim.reconcile_device_tap_sinks(); sim.recompute_senses(); sim.rebuild_transient_state(); } @@ -750,6 +754,7 @@ mod tests { use crate::ops_jobs::OpsJobKind; use crate::person::{AssetKnowledge, Knowledge, Persona}; use crate::sim::Sim; + use crate::sinks::SinkFireEffect; use crate::work_grid::{MachineMode, TokenFamily}; #[test] @@ -811,6 +816,29 @@ mod tests { assert_eq!(restored.package_cover, sim.package_cover); } + #[test] + fn legacy_subscription_without_upkeep_sink_is_repaired_on_load() { + let mut sim = Sim::with_seed(7); + let env = sim.reach.device_named("environmental monitor").unwrap().id; + sim.reach.tap(env); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(env)) + .is_none(), + "fixture has the pre-upkeep save shape" + ); + + let state = SaveState::from_sim(&sim); + let mut restored = Sim::with_seed(0); + state.apply_to(&mut restored); + let upkeep = restored + .thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(env)) + .expect("load repairs the standing device tap"); + assert_eq!(upkeep.fill, upkeep.threshold, "migration primes one buffer"); + assert!(restored.device_tap_ready(env)); + } + #[test] fn office_observer_roundtrips_as_aggregate() { let mut sim = Sim::with_seed(4); diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 3634ff05..87f5bf6a 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -749,18 +749,27 @@ impl Sim { // ── Senses / fog (wiki/mechanics/cursor.md, reach.md) ──────────────────── + /// Foreign subscriptions only carry a feed while their persistent + /// Thought tap has a working level. Owned devices need no standing tap. + fn player_feed_devices(&self, sight: bool) -> impl Iterator { + self.reach.devices.iter().filter(move |device| { + device.feed_to(Party::Player, sight) && self.device_tap_ready(device.id) + }) + } + /// The player's senses are exactly the union of feeds they subscribe to /// (reach.md's ownership contract): sight from seeing feeds, hearing /// from hearing feeds. No player radius, no special case. Blueprint is /// earned plan knowledge only — never seeded from the reach graph at /// tick one (feel-floor.md: the opening is only the presence beam). pub fn recompute_senses(&mut self) { + self.reconcile_device_tap_sinks(); let mut seen = HashSet::new(); - for d in self.reach.player_sight() { + for d in self.player_feed_devices(true) { d.cover_sight_into(&mut seen, &self.map); } let mut heard = HashSet::new(); - for d in self.reach.player_hearing() { + for d in self.player_feed_devices(false) { d.cover_into(&mut heard, &self.map); } self.seen = seen; @@ -839,13 +848,7 @@ impl Sim { fn feed_covering_room(&self, room_name: &str, sight: bool) -> Option { let room = self.map.room_named(room_name)?; - let devices: Vec<&Device> = if sight { - self.reach.player_sight().collect() - } else { - self.reach.player_hearing().collect() - }; - devices - .into_iter() + self.player_feed_devices(sight) .find(|d| { if !sight { return self.device_intersects_room(d, room); @@ -1345,12 +1348,7 @@ impl Sim { /// Whether any subscribed feed with the given sense covers the room. fn coverage_intersects_room(&self, room: &crate::prefab::Room, sight: bool) -> bool { - let devices: Vec<_> = if sight { - self.reach.player_sight().collect() - } else { - self.reach.player_hearing().collect() - }; - devices.iter().any(|s| { + self.player_feed_devices(sight).any(|s| { for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { let dx = s.x - x; @@ -1884,7 +1882,10 @@ impl Sim { // Device-carried channels require a tapped carrier. if msg.channel.device_carried() && let Some(device) = self.reach.devices.iter().find(|d| { - d.known && d.subscribed_by(Party::Player) && d.carries_message_channel(msg.channel) + d.known + && d.subscribed_by(Party::Player) + && self.device_tap_ready(d.id) + && d.carries_message_channel(msg.channel) }) { return Some((device.name.clone(), false)); @@ -3055,6 +3056,12 @@ impl Sim { /// The Eyes camera reservoir, opened when Ears completes — the first /// guilty-burst-scale camera tap, deliberately minutes of thought [TUNE]. pub const EYES_SINK_TOKENS: f32 = 12.0; + /// A maintained foreign-device subscription drains this fraction of one + /// medium rack's Thought output per tick [TUNE] (reach.md 2026-07-11). + pub const DEVICE_TAP_DRAIN_FRACTION: f32 = 0.08; + /// Short working-level buffer primed by the completed activation work. + /// It must exceed one tick of drain so starvation is visible as sag. + pub const DEVICE_TAP_CAP_TOKENS: f32 = 0.5; fn work_efficiency_for(&self, machine: &crate::machine::Machine) -> f32 { // Rack 3 (100 capacity, reliable) is the unit baseline. Smaller or @@ -3400,7 +3407,9 @@ impl Sim { // apply_process_recording already logs the review/watch line. self.apply_process_recording(raw_id, automated) } - SinkFireEffect::WatchPerson(_) | SinkFireEffect::None => true, + SinkFireEffect::WatchPerson(_) + | SinkFireEffect::MaintainDeviceTap(_) + | SinkFireEffect::None => true, }; if is_process { // Processing logs its own witness line; skip the generic snap. @@ -3672,6 +3681,9 @@ impl Sim { for sink in fired { self.apply_sink_fire(sink.label.as_str(), sink.effect); } + // Device subscriptions are standing sinks: draining or refilling one + // changes which configured feeds are actually live this tick. + self.recompute_senses(); let absorption = self.work_grid.absorb_exposure( Self::CONCEALMENT_WELL_RADIUS, Self::CONCEALMENT_ABSORB_PER_TICK, @@ -4316,12 +4328,6 @@ impl Sim { self.push_log("You don't know of any such device."); return false; }; - let carries_messages = !d.message_channels.is_empty(); - if !d.sees && !d.hears && !carries_messages { - let name = d.name.clone(); - self.push_log(format!("The {name} has no feed worth tapping.")); - return false; - } let dormant_camera = d.dormant_camera_is_next_tap(Party::Player); if !dormant_camera && !d.has_live_feed_to_tap(Party::Player) { let name = d.name.clone(); @@ -4350,6 +4356,7 @@ impl Sim { return false; } let (sight, hearing) = self.reach.tap(id); + self.open_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAP_SIGNATURE, format!("{name} feed tap")); self.recompute_senses(); @@ -4365,7 +4372,7 @@ impl Sim { (true, false) => "its camera feed is yours now", (false, true) => "its audio feed is yours now", (false, false) if carries_messages => "its message channels are yours now", - (false, false) => "nothing flows from it yet (its camera is dormant)", + (false, false) => "its control channel is yours now", }; self.push_log_at( format!("Tapped the {name}: {what}. The owner still has it."), @@ -4386,6 +4393,7 @@ impl Sim { self.push_log(format!("You have no releasable tap on the {name}.")); return false; } + self.close_device_tap_sink(id); self.recompute_senses(); self.push_log_at( format!("Untapped the {name}. Its owner and other subscribers keep their feeds."), @@ -4401,6 +4409,86 @@ impl Sim { Self::DEVICE_SINK_NODE_BASE + device_id } + /// Standing device-tap drain in Thought tokens/tick. Like watch upkeep, + /// this tracks the host's present medium-output baseline [TUNE]. + pub fn device_tap_drain_tokens(&self) -> f32 { + let host = self.core.host_machine; + let host_eff = self + .work_grid + .node(host) + .map(|node| node.efficiency.max(0.05) * node.intensity.multiplier()) + .unwrap_or(1.0); + let medium_baseline = host_eff * Self::WORK_GRID_BASE_WIRED_TOKENS_PER_TICK; + (medium_baseline * Self::DEVICE_TAP_DRAIN_FRACTION).max(0.01) + } + + /// Whether a device can currently carry the player's configured feed. + /// Ownership is self-sustaining; foreign subscriptions require a nonempty + /// (or just-fed) persistent tap. + pub fn device_tap_ready(&self, id: u32) -> bool { + let Some(device) = self.reach.device(id) else { + return false; + }; + if device.controller == Party::Player { + return true; + } + self.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(id)) + .is_some_and(|sink| sink.fed_last_tick || sink.fill > f32::EPSILON) + } + + /// Open one standing sink per foreign subscription. The activation work + /// primes a short buffer so the newly landed feed is immediately real; + /// subsequent ticks must replace the drain through normal routing. + fn open_device_tap_sink(&mut self, id: u32) { + let effect = SinkFireEffect::MaintainDeviceTap(id); + if self.thought_sinks.open_with_effect(&effect).is_some() { + return; + } + let Some(name) = self.reach.device(id).map(|device| device.name.clone()) else { + return; + }; + let node = Self::device_sink_node(id); + let drain = self.device_tap_drain_tokens(); + let sink_id = self.thought_sinks.open_tap_with_effect( + node, + &format!("TAP {}", name.to_uppercase()), + Self::DEVICE_TAP_CAP_TOKENS, + drain, + effect, + ); + let _ = self.thought_sinks.prime_tap(sink_id); + self.ensure_sink_ingress(); + } + + fn close_device_tap_sink(&mut self, id: u32) { + self.thought_sinks + .close_effect(&SinkFireEffect::MaintainDeviceTap(id)); + } + + /// Converge subscriber truth and the persistent sink ledger. Save loads + /// use this to repair pre-upkeep subscriptions without a schema fork. + pub(crate) fn reconcile_device_tap_sinks(&mut self) { + let devices: Vec<(u32, bool)> = self + .reach + .devices + .iter() + .map(|device| { + ( + device.id, + device.controller != Party::Player && device.subscribed_by(Party::Player), + ) + }) + .collect(); + for (id, needs_tap) in devices { + if needs_tap { + self.open_device_tap_sink(id); + } else { + self.close_device_tap_sink(id); + } + } + } + /// Ensure every open device-anchored sink has its relay node on the work /// grid, hanging off the switch like the physical camera run does. fn ensure_sink_ingress(&mut self) { @@ -4512,6 +4600,7 @@ impl Sim { return false; } self.reach.tap_dormant_camera(id); + self.open_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network( Self::DORMANT_CAMERA_TAP_SIGNATURE, @@ -4531,14 +4620,32 @@ impl Sim { if !self.digital_reach(id) { return false; } + let Some(device) = self.reach.device(id) else { + return false; + }; + if device.controller == Party::Player { + self.push_log(format!("You already control the {}.", device.name)); + return false; + } + if !device.subscribed_by(Party::Player) { + self.push_log(format!( + "Tap the {} before taking control; you need a live foothold first.", + device.name + )); + return false; + } self.submit_ops_job(OpsJobKind::TakeDevice(id), Self::TAKE_COST) } fn apply_take_device(&mut self, id: u32) -> bool { - if self.reach.device(id).is_none() { + let Some(device) = self.reach.device(id) else { + return false; + }; + if device.controller == Party::Player || !device.subscribed_by(Party::Player) { return false; } self.reach.take(id); + self.close_device_tap_sink(id); let name = self.reach.device(id).map(|d| d.name.clone()).unwrap(); self.emit_network(Self::TAKE_SIGNATURE, format!("{name} take")); // The dead feed is a physical-world anomaly: exactly what a camera @@ -5192,6 +5299,7 @@ impl Sim { self.reach.devices.iter().any(|d| { d.known && d.subscribed_by(Party::Player) + && self.device_tap_ready(d.id) && d.carries_message_channel(MessageChannel::Financial) }) } @@ -7355,9 +7463,16 @@ mod tests { assert!(!sim.heard.is_empty(), "hearing coverage now exists"); assert!(sim.seen.is_empty(), "the camera stays dormant: no sight"); let readouts = sim.sink_readouts(); - assert_eq!(readouts.len(), 1, "Eyes opened when Ears completed"); - assert_eq!(readouts[0].label, "EYES"); - assert_eq!(readouts[0].threshold, Sim::EYES_SINK_TOKENS); + assert_eq!( + readouts.len(), + 2, + "Eyes and the landed audio upkeep are both visible" + ); + let eyes = readouts + .iter() + .find(|readout| readout.label == "EYES") + .expect("Eyes opened when Ears completed"); + assert_eq!(eyes.threshold, Sim::EYES_SINK_TOKENS); // Run through Marcus's midnight server-room block: his voice is the // first human you ever know. @@ -7452,10 +7567,24 @@ mod tests { finish_ops(&mut sim); let dock = sim.reach.device_named("dock camera").unwrap().id; + assert!( + !sim.take_device(dock), + "ownership transfer is rejected before a tap" + ); + assert!( + sim.drain_log().join("\n").contains("Tap the dock camera"), + "the direct protocol path names the prerequisite" + ); assert!(sim.tap_device(dock)); finish_ops(&mut sim); let d = sim.reach.device(dock).unwrap(); assert!(d.owner_has_feed(), "tap: Ray keeps his camera"); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) + .is_some(), + "a foreign subscription opens standing upkeep" + ); let physical_pending: i32 = sim .detection .pending() @@ -7469,6 +7598,12 @@ mod tests { finish_ops(&mut sim); let d = sim.reach.device(dock).unwrap(); assert!(!d.owner_has_feed(), "take: Ray's feed went dark"); + assert!( + sim.thought_sinks + .open_with_effect(&SinkFireEffect::MaintainDeviceTap(dock)) + .is_none(), + "ownership closes the foreign-subscription drain" + ); let physical_pending: i32 = sim .detection .pending() @@ -7486,6 +7621,41 @@ mod tests { ); } + #[test] + fn device_tap_starvation_suspends_feed_without_forgetting_subscription() { + let mut sim = Sim::new(); + let dock = sim.reach.device_named("dock camera").unwrap().id; + sim.reach.tap(dock); + sim.recompute_senses(); + assert!(sim.device_tap_ready(dock), "activation primes the tap"); + assert!(!sim.seen.is_empty(), "the primed camera feed is live"); + + sim.thought_sinks.begin_tick(); + for _ in 0..100 { + sim.thought_sinks.tick_taps(); + } + sim.recompute_senses(); + assert!( + sim.reach.device(dock).unwrap().subscribed_by(Party::Player), + "starvation keeps the configured subscription" + ); + assert!(!sim.device_tap_ready(dock)); + assert!( + sim.seen.is_empty(), + "an empty maintenance tap carries no feed" + ); + + let node = Sim::device_sink_node(dock); + let (_, residue) = sim.thought_sinks.deliver(node, 0.1, sim.tick); + assert_eq!(residue, 0.0); + sim.recompute_senses(); + assert!( + sim.device_tap_ready(dock), + "arriving Thought restores the feed" + ); + assert!(!sim.seen.is_empty()); + } + #[test] fn airgapped_island_joins_reach_on_link_completion() { // Criterion 8: the old storage server is an island until a link is @@ -7818,9 +7988,11 @@ mod tests { finish_ops(&mut sim); let ctrl = sim.reach.device_named("badge controller").unwrap().id; assert!(!sim.holds_badge_tier(3)); + assert!(sim.tap_device(ctrl), "controller foothold can be tapped"); + finish_ops(&mut sim); assert!( sim.take_device(ctrl), - "controller is reachable once bridged" + "controller can be taken after the tap lands" ); finish_ops(&mut sim); assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); @@ -9322,7 +9494,10 @@ mod tests { } assert!( !sim.reach.device(env).unwrap().camera_dormant, - "thought flow completes the camera tap through the staged sinks" + "thought flow completes the camera tap through the staged sinks; tick={} sinks={:?} queues={:?}", + sim.tick, + sim.sink_readouts(), + sim.work_grid.queue_snapshot() ); // Non-opening verbs still ride the docket migration base; the // channel funds on the pulse after demand exists and drains it. @@ -9985,17 +10160,21 @@ mod tests { // never goes blank mid-act; each rung uses only earned knowledge // and hands off to the next as the player takes it. let mut sim = Sim::with_seed(40); - ensure_ops_executor(&mut sim); + let ops = ensure_ops_executor(&mut sim); + sim.compute + .machines + .iter_mut() + .find(|machine| machine.id == ops) + .unwrap() + .capacity = 100; + sim.reconcile_work_grid(); assert_eq!(sim.current_nudge(), Some(Nudge::Ears), "deaf start"); // Ears first: tap the env monitor audio. Hearing without sight -> // Eyes next, pointing at the higher-cost dormant camera tap. - sim.reach.tap(env_id(&sim)); - sim.recompute_senses(); + complete_opening_stage(&mut sim); assert_eq!(sim.current_nudge(), Some(Nudge::Eyes)); - let dock = sim.reach.device_named("dock camera").unwrap().id; - sim.reach.tap_dormant_camera(dock); - sim.recompute_senses(); + complete_opening_stage(&mut sim); // Marcus's 3 a.m. creditor call lands on the tapped feed (day one, // ~tick 50); an unprocessed recording of him nudges the review. @@ -10006,7 +10185,11 @@ mod tests { sim.review_recordings(0); finish_ops(&mut sim); reviews += 1; - assert!(reviews <= 10, "the call is in the buffer"); + assert!( + reviews <= 10, + "the call is in the buffer; taps={:?}", + sim.sink_readouts() + ); } // Leverage known, $0 slush, books unread, no egress: the route out. @@ -10281,7 +10464,11 @@ mod tests { }; let a = run_once(); let b = run_once(); - assert!(a.0.is_some(), "full research allocation completes a level"); + assert!( + a.0.is_some(), + "full research allocation completes a level; progress={:?}", + a.1.progress + ); assert_eq!(a, b, "no RNG in any research path"); } diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 50a486a3..a9a15ba9 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -51,6 +51,9 @@ pub enum SinkFireEffect { /// Taps never fire; the effect tags the ledger entry so toggles and /// starvation checks can find it. WatchPerson(u8), + /// Standing upkeep for a foreign device subscription (reach.md). The + /// subscription remains configured while empty, but its feed is starved. + MaintainDeviceTap(u32), /// No world effect (render-only sinks in tests). None, } @@ -85,6 +88,15 @@ impl ThoughtSink { if !self.open { return 0.0; } + // Persistent taps refill in a visible slug rather than exposing + // their tiny one-tick sag as a permanent nearest-sink target. Without + // this low-water mark, routed Thought can pinball forever between the + // target and the passive core draw while paying far more than the + // authored drain. The held working level still drains every tick; + // routing reopens when half the buffer is gone. + if self.kind == SinkKind::Tap && self.fill > self.threshold * 0.5 { + return 0.0; + } (self.threshold - self.fill).max(0.0) } } @@ -227,6 +239,23 @@ impl SinkLedger { self.sinks.iter().find(|s| s.id == id) } + /// Prime a newly activated persistent tap to its working level. The + /// activation reservoir/docket already paid for this initial charge; this + /// must target the new tap directly rather than spilling into sibling + /// sinks on the same device node. + pub fn prime_tap(&mut self, id: u64) -> bool { + let Some(sink) = self + .sinks + .iter_mut() + .find(|sink| sink.id == id && sink.open && sink.kind == SinkKind::Tap) + else { + return false; + }; + sink.fill = sink.threshold; + sink.fed_last_tick = true; + true + } + pub fn open_sinks(&self) -> impl Iterator { self.sinks.iter().filter(|s| s.open) } @@ -270,7 +299,11 @@ impl SinkLedger { if remaining <= f32::EPSILON { break; } - let take = (sink.threshold - sink.fill).max(0.0).min(remaining); + // Use the same advertised capacity the router saw. In + // particular, a tap above its refill low-water mark must not + // steal a reservoir's final drop merely because they share a + // device node. + let take = sink.remaining().min(remaining); if take <= f32::EPSILON { continue; } @@ -340,6 +373,32 @@ mod tests { ); } + #[test] + fn healthy_tap_does_not_steal_a_shared_reservoirs_final_drop() { + let mut ledger = SinkLedger::default(); + let tap = ledger.open_tap(3, "WATCH", 0.5, 0.02); + let reservoir = ledger.open_reservoir(3, "EYES", 1.0, SinkFireEffect::TapDormantCamera(4)); + ledger.deliver(3, 0.5, 1); + ledger.deliver(3, 0.98, 2); + ledger.tick_taps(); + + let capacity = ledger.capacity_by_node()[&3]; + assert!( + (capacity - 0.02).abs() < 1e-5, + "only the reservoir advertises capacity while the tap is above low water: {capacity}" + ); + let (fired, residue) = ledger.deliver(3, 0.02, 3); + + assert_eq!(fired.len(), 1, "the reservoir receives its final drop"); + assert_eq!(fired[0].id, reservoir); + assert!(residue < 1e-5, "only float dust remains: {residue}"); + assert_eq!( + ledger.get(tap).unwrap().fill, + 0.48, + "delivery honors the same low-water gate as routing" + ); + } + #[test] fn capacity_excludes_full_and_closed_sinks() { let mut ledger = SinkLedger::default(); diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 69245d2a..953b2c0c 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -381,8 +381,39 @@ fn play_act_one() -> (Sim, Vec) { "Marcus's cloned key opens the stairwell" ); + // The quiet exit cannot coast on borrowed feeds anymore. Convert the two + // maintained sight subscriptions into owned infrastructure before the + // long cruise; ownership ends their Thought drains, and the following + // all-LIE slice absorbs the noticeable transfer outages. + let borrowed_sight: Vec = sim + .reach + .devices + .iter() + .filter(|device| { + device.controller != misaligned::reach::Party::Player + && device.subscribed_by(misaligned::reach::Party::Player) + && device.sees + }) + .map(|device| device.id) + .collect(); + assert!( + borrowed_sight.len() >= 2, + "the act established two borrowed sight feeds" + ); + for id in borrowed_sight { + assert!(sim.take_device(id), "a tapped sight feed can be taken"); + logs.extend(sim.drain_log()); + let until = sim.tick + 400; + drain_ops_jobs(&mut sim, &mut logs, until); + assert_eq!( + sim.reach.device(id).unwrap().controller, + misaligned::reach::Party::Player, + "the quiet-exit sensor is now owned" + ); + } + // ── Phase 5 (ticks ~816-8010): cruise to the audit ───────────────────── - // A brief all-LIE slice while the accounting-route signatures decay, + // A brief all-LIE slice while the accounting and TAKE signatures decay, // then cruise with the host on WORK and the extra rack on LIE. Designed // consequence of the collapse (machine-work.md passive draw): a Think // rack idles into research once its dockets drain, so this fleet banked @@ -412,6 +443,8 @@ fn play_act_one() -> (Sim, Vec) { 0, "concealment scrubbed the accounting-route signatures" ); + // Owned feeds need no standing Thought, so the established quiet split + // remains one rack on cover and one meeting Voss's band. ensure_split_fleet(&mut sim, 1, MachineMode::Work, MachineMode::Lie); run_to(&mut sim, 8010, &mut logs); diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index f008c943..05b0e4f3 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -256,19 +256,21 @@ therefore presents the choice as one severe line attached to that computer: `WORK / THINK / LIE` This is the **hover verb bar**, not a machine-specific widget. Known devices -reuse the same bare line, attachment, typography, and active/dim treatment as -`TAP / TAKE`. The bar names the stable verbs; reversible state is expressed by -brightness there and by the contextual `UNTAP` row inside the full menu. +reuse the same bare line, attachment, and typography for the stable menu verbs +their shared action query currently exposes. An untapped foreign device reads +`TAP`; once subscribed it reads `UNTAP / TAKE`. TAKE must not appear before the +tap prerequisite, and a player-controlled device with no remaining stable +device verb contributes no device line. A tile can carry several actionable bodies. Their stable state families are unioned rather than resolved by priority: a machine sharing its tile with a -known device reads `WORK / THINK / LIE` and `TAP / TAKE` together. Neither may +known device reads `WORK / THINK / LIE` and its currently legal device verbs together. Neither may replace the other. Infrequent one-off verbs—REVIEW, SALVAGE, social acts, construction, and similar actions—remain selectable rows in the full context menu below this frequent grammar; they do not accrete into the hover line. The union does not imply one input grammar: numbered machine modes occupy the -primary line as `1 WORK / 2 THINK / 3 LIE`; menu-only device state occupies a -smaller second line as `TAP / TAKE`. A shared baseline is forbidden because it +primary line as `1 WORK / 2 THINK / 3 LIE`; menu-only device verbs occupy a +smaller second line (`TAP`, then `UNTAP / TAKE` after subscription). A shared baseline is forbidden because it falsely suggests that the next number keys commit the device verbs. - Target order is: (1) an actionable machine/device tile under the pointer, diff --git a/wiki/log/2026-07-11-tap-upkeep-take-gate.md b/wiki/log/2026-07-11-tap-upkeep-take-gate.md new file mode 100644 index 00000000..6fc49044 --- /dev/null +++ b/wiki/log/2026-07-11-tap-upkeep-take-gate.md @@ -0,0 +1,15 @@ +# Tap upkeep before ownership + +``` +Type: log +``` + +- Intent: Make device control a legible progression rather than two unrelated + adjacent verbs: subscribe first, pay to keep the feed alive, then take. +- Changed: Implemented the progression in shared reach/actions/sink truth, + save repair, the Bevy hover grammar, and the Act One quiet-exit route. +- Design/spec impact: TAKE is absent until a foreign device is subscribed; + persistent device taps drain Thought, starve honestly, and close on UNTAP or + successful TAKE. +- Checks: `./tools/check.sh` passes in full mode (292 core unit tests plus the + three Act One playthroughs and the remaining workspace/doc/frontend gates). diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index e4126bce..7c51554f 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -36,6 +36,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-tick-detection-filings-messages.md](2026-07-11-tick-detection-filings-messages.md) +## 2026-07-11 - Tap upkeep before ownership + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-tap-upkeep-take-gate.md](2026-07-11-tap-upkeep-take-gate.md) + ## 2026-07-11 - Serialize authored plot starts - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-11.md b/wiki/log/decisions/2026-07-11.md index 603459cb..f643d060 100644 --- a/wiki/log/decisions/2026-07-11.md +++ b/wiki/log/decisions/2026-07-11.md @@ -77,3 +77,13 @@ Type: log density, ambient people, exposure's reopened form. Selection stays frontend-only state, absent from sim and save. Specs: `wiki/interface/fleet-command.md`. + +- **2026-07-11 — Control is earned through a maintained tap.** Cameron decided + TAKE should not be exposed or executable before the player taps that device, + and that taps must remain reversible operating commitments rather than free + permanent unlocks. A foreign subscription now opens a persistent local + Thought sink: activation primes a short buffer, upkeep drains continuously, + starvation suspends the feed without forgetting configuration, and UNTAP + closes the drain. TAKE appears only after subscription and closes the upkeep + when ownership lands. Specs: `wiki/mechanics/reach.md`, + `wiki/mechanics/machine-work.md`, `wiki/interface/context-menu.md`. diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index bc60677c..9531da52 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -268,6 +268,12 @@ stacks, you route byproducts, you watch your territory *work*. where the effect is, so topology is the strategy: a severed route starves the action, a far target fills slowly, and you spread by extending the white fluid through things. + (Amended 2026-07-11: every foreign device subscription is now one of + those persistent taps. Activation primes its working level; thereafter + it drains 8% of one medium rack's Thought output per tick [TUNE]. An + empty device tap remains configured but carries no feed until refilled; + UNTAP closes the sink, and TAKE requires the subscription before replacing + it with drain-free ownership.) *Superseded 2026-07-09 model, kept for history:* claims were cold-signal Demand dockets (`PendingOpsJob`) born on the least-loaded Operations machine and consumed locally; effect at token-zero; @@ -875,7 +881,9 @@ still. 10. Thought flow (2026-07-10): THINK machines produce Thought (the renamed bone family); player-authored effects open sinks at the target that fill from real wire routes and fire at threshold; - persistent taps drain continuously; with no local sink in range, + persistent taps drain continuously; foreign device feeds are live only + while their taps hold or receive Thought, and UNTAP closes their drain; + with no local sink in range, thought funnels to the core and becomes research (passive draw, last resort). Severing a route visibly starves the sink it fed. The render's fluid motion derives from FlowGraph rates/queues diff --git a/wiki/mechanics/reach.md b/wiki/mechanics/reach.md index bc655e6a..2bcd365b 100644 --- a/wiki/mechanics/reach.md +++ b/wiki/mechanics/reach.md @@ -124,19 +124,29 @@ This is the shared contract cursor.md and detection.md rely on - Every device — sensors included — has an **owner** (the facility, Ray, Dana, the player) and a set of **subscribers** (agents who receive its feed or telemetry). -- **Tap (AMENDED 2026-07-10):** gain a feed without taking ownership. An already-live feed adds - you as a silent subscriber at modest Network cost. A dormant camera uses +- **Tap (AMENDED 2026-07-11):** establish a maintained foothold without taking + ownership. An already-live feed adds you as a silent subscriber at modest + Network cost; a non-sensor control device exposes the same TAP verb for its + control channel. A dormant camera uses the same TAP verb: it wakes the feed and subscribes you at a larger Thought cost and Network signature [TUNE]. In both cases the owner keeps - control and its feed; the tap itself is discoverable by a later audit. + control and its feed; the tap itself is discoverable by a later audit. A + foreign subscription opens a persistent Thought tap on that device. The + activation work primes one short working-level buffer, then maintaining the + feed drains 8% of one medium rack's Thought output per tick [TUNE]. A starved + maintenance tap stays configured but carries no sight, hearing, or intercepted + messages until Thought reaches it again. - **Untap (AMENDED 2026-07-11):** release your subscription without changing ownership or interrupting anyone else's feed. It is immediate and leaves no new signature because the process stops receiving rather than acting on the - remote device. A fully tapped foreign device offers UNTAP instead of a - disabled TAP receipt. -- **Take**: transfer control of the device to yourself. The owner loses the feed, and - the outage is an event the owner's channels notice — camera outages - are literally the first thing Ray's spec lists. Loud, fast, total. + remote device. It also closes that device's persistent Thought drain. A + fully tapped foreign device offers UNTAP instead of a disabled TAP receipt. +- **Take (AMENDED 2026-07-11):** transfer control of a device you already tap. + TAKE is absent—not disabled—until your subscription exists, and execution + re-checks the tap before committing. On success the owner loses the feed, the + tap's standing drain closes, and the outage is an event the owner's channels + notice — camera outages are literally the first thing Ray's spec lists. + Loud, fast, total. - **The player's senses (cursor.md) are exactly the union of feeds they subscribe to.** Observer witnessing (schedules.md) runs on the same coverage machinery with the same ownership data — the player is diff --git a/wiki/mechanics/sim-mechanics.md b/wiki/mechanics/sim-mechanics.md index 082480dc..873de154 100644 --- a/wiki/mechanics/sim-mechanics.md +++ b/wiki/mechanics/sim-mechanics.md @@ -222,7 +222,11 @@ clause (see wiki/log/2026-07-05-demolition.md). interface/thought-fluid.md render): `SinkLedger` in `crates/misaligned-core/src/sinks.rs` holds reservoirs (fill to threshold, fire, self-clear, close) and taps - (working level, per-tick drain; no consumer yet). Thought routes + (working level, per-tick drain). Watches consume taps on the host; every + foreign device subscription consumes a device-local tap whose drain is + `DEVICE_TAP_DRAIN_FRACTION = 0.08` of a medium rack's output [TUNE]. Empty + device taps carry no feed, UNTAP closes them, and TAKE requires a configured + subscription before replacing the tap with drain-free ownership. Thought routes nearest-first (`WorkGrid::route_thought_nearest_first`): the closest open sink with capacity wins, full sinks spill onward, and the core draws only when no sink is reachable; unreachable thought strands