//! Digital reach (wiki/mechanics/reach.md). //! //! Every networked fixed object is a device node on a graph whose edges are //! network links and whose partitions are segments (VLANs); the network //! closet's switch is the bridge point between segments. **Reach** is the set //! of devices connected, through the graph, to a device the player controls — //! reach is to action what camera coverage is to sight ("No disembodied //! hands", digital channel). The topology rides the flow substrate //! (`FlowGraph`), because signals are a flow system (DESIGN.md, the flow law). //! //! Ownership contract (shared with cursor.md and detection.md): every device //! has an owner and a set of subscribers. **Tap** adds a silent subscriber //! and leaves the owner's feed intact; **take** seizes the device — the owner //! loses the feed (an outage their channels can notice) and the player gains //! its control and its processing cycles. The player's senses are exactly the //! union of feeds they subscribe to; there is no `controlled` flag and no //! special case. use std::collections::{BTreeSet, HashSet}; use crate::flow::FlowGraph; use crate::map::GameMap; use crate::messages::MessageChannel; use crate::tiles::TileType; /// Processing cycles a seized device contributes to effective compute /// [TUNE] (reach.md: ownership grants processing cycles — taking a camera is /// taking a very small computer). pub const DEVICE_CYCLES: f32 = 3.0; /// Who owns, controls, or subscribes to a device's feeds. Self-similar: a /// person, the facility, and the player are the same kind of party. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Party { Player, Facility, Person(u8), } /// One subscription: `who` receives which senses of the device's feed. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Feed { pub who: Party, pub sight: bool, pub hearing: bool, } /// A networked fixed object: a node in the reach graph. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Device { pub id: u32, pub name: String, pub x: i32, pub y: i32, /// VLAN segment this device sits on (see [`segment_name`]). pub segment: u32, /// Who the device belongs to in the world's eyes. pub owner: Party, /// Who commands it right now (take changes this; owner keeps the title). pub controller: Party, /// Sensor capability flags (cursor.md: sight and hearing channels). pub sees: bool, pub hears: bool, /// A camera that exists but does not flow yet (the env monitor until the /// Eyes beat). Dormant video reaches no subscriber, owner included. pub camera_dormant: bool, /// Coverage radius for its senses (Euclidean disc). pub radius: i32, /// Message channels this device carries. A tap on a channel-carrying /// device intercepts message traffic even if the device has no camera or /// microphone feed. #[serde(default)] pub message_channels: Vec, /// Staged graph knowledge: unknown devices appear nowhere (reach.md). pub known: bool, /// Whether this node is the switch (the segment bridge point). pub is_switch: bool, pub subscribers: Vec, } impl Device { /// Whether `who` currently receives this device's live feed for a sense. pub fn feed_to(&self, who: Party, sight: bool) -> bool { self.subscribers.iter().any(|f| { f.who == who && if sight { f.sight && self.sees && !self.camera_dormant } else { f.hearing && self.hears } }) } /// Whether the original owner still receives any feed (take removes it). pub fn owner_has_feed(&self) -> bool { self.subscribers.iter().any(|f| f.who == self.owner) } /// Whether `who` has any subscription record on this device, including a /// message-channel-only tap with no sight/hearing bits. pub fn subscribed_by(&self, who: Party) -> bool { self.subscribers.iter().any(|f| f.who == who) } pub fn carries_message_channel(&self, channel: MessageChannel) -> bool { self.message_channels.contains(&channel) } /// Insert this device's hearing coverage into `out`: room-grade per /// cursor.md ("Hearing yields room-grade events"). Any room the /// coverage disc reaches is heard as a whole room — the same rule /// `Sim::feed_covering_room` uses for heard events, so the fog tint /// and the event coverage never disagree. Open space outside rooms /// is heard along a wall-bounded flood within the radius; the raw /// disc never paints tiles sound cannot reach. Sight uses /// [`Device::cover_sight_into`] for the equivalent ray rule. pub fn cover_into(&self, out: &mut HashSet<(i32, i32)>, map: &GameMap) { let r2 = self.radius * self.radius; for room in &map.rooms { let in_disc = |x: i32, y: i32| { let (dx, dy) = (x - self.x, y - self.y); dx * dx + dy * dy <= r2 }; let reaches = (room.y..room.y + room.h).any(|y| (room.x..room.x + room.w).any(|x| in_disc(x, y))); if !reaches { continue; } for y in room.y..room.y + room.h { for x in room.x..room.x + room.w { if map.in_bounds(x, y) { out.insert((x, y)); } } } } // Open space outside rooms: bounded flood through open tiles only. let mut frontier = vec![(self.x, self.y)]; let mut visited: HashSet<(i32, i32)> = frontier.iter().copied().collect(); while let Some((cx, cy)) = frontier.pop() { if map.in_bounds(cx, cy) { out.insert((cx, cy)); } for (nx, ny) in [(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)] { let (dx, dy) = (nx - self.x, ny - self.y); if dx * dx + dy * dy > r2 || !map.in_bounds(nx, ny) || map.blocks_sight(nx, ny) || !visited.insert((nx, ny)) { continue; } frontier.push((nx, ny)); } } } /// Insert camera-visible tiles into `out`, blocking rays on opaque map /// structure. The wall/door tile hit by a ray is visible; tiles behind it /// are not. pub fn cover_sight_into(&self, out: &mut HashSet<(i32, i32)>, map: &GameMap) { for dy in -self.radius..=self.radius { for dx in -self.radius..=self.radius { let (tx, ty) = (self.x + dx, self.y + dy); if !map.in_bounds(tx, ty) { continue; } if dx * dx + dy * dy > self.radius * self.radius { continue; } if map.sensor_line_reaches((self.x, self.y), (tx, ty)) { out.insert((tx, ty)); } } } } pub fn sees_tile(&self, x: i32, y: i32, map: &GameMap) -> bool { let dx = x - self.x; let dy = y - self.y; dx * dx + dy * dy <= self.radius * self.radius && map.sensor_line_reaches((self.x, self.y), (x, y)) } } /// Human name of a VLAN segment (Act One content). pub fn segment_name(segment: u32) -> &'static str { match segment { 0 => "basement subnet", 1 => "security segment", _ => "unknown segment", } } /// Why a digital action against a device is blocked (reach.md: failures name /// the missing link, never just "you can't"). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReachBlock { /// You don't know the device exists (staged graph knowledge). Unknown, /// Known, wired, but behind an unbridged segment. Segment(u32), /// Known but no link reaches it at all. AirGap, } /// The device graph plus the player's standing over it: which segments they /// have bridged and which devices they control or subscribe to. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ReachNet { pub devices: Vec, graph: FlowGraph, /// Segments the player can cross the switch into (compromise or Dana's /// social route). pub bridged: BTreeSet, } impl ReachNet { /// Author the Act One basement device graph from the layout's fixed /// objects (basement-map.md owns the topology as content; this module /// owns the semantics). Segment 0 is the basement subnet (Rack 3's own, /// the seed of the partial subnet map); segment 1 is the security /// segment behind the switch. The old storage server is an air-gapped /// island: known shape, no link, untouchable until one is built. pub fn basement(map: &GameMap) -> Self { let mut devices = Vec::new(); let mut id = 0u32; let mut mk = |name: &str, (x, y): (i32, i32), segment: u32, owner: Party, sees: bool, hears: bool, camera_dormant: bool, known: bool, is_switch: bool| { let controller = owner; let subscribers = if owner == Party::Player { vec![Feed { who: Party::Player, sight: sees, hearing: hears, }] } else { vec![Feed { who: owner, sight: sees, hearing: hears, }] }; let d = Device { id, name: name.into(), x, y, segment, owner, controller, sees, hears, camera_dormant, radius: 4, message_channels: Vec::new(), known, is_switch, subscribers, }; id += 1; d }; let core = map.core_pos().unwrap_or((24, 15)); let at = |t: TileType| map.tiles_of_type(t).first().copied(); // Seed knowledge = the basement subnet segment (reach.md: the // opening state's partial map). Security devices exist but are // unknown until scanned; the storage server is unknown and unwired. devices.push(mk( "Rack 3", core, 0, Party::Player, false, false, false, true, false, )); if let Some(p) = at(TileType::Switch) { devices.push(mk( "switch", p, 0, Party::Facility, false, false, false, true, true, )); } if let Some(p) = at(TileType::EnvCamera) { devices.push(mk( "environmental monitor", p, 0, Party::Facility, true, true, true, // camera dormant until the Eyes beat true, false, )); } if let Some(p) = at(TileType::DockCamera) { devices.push(mk( "dock camera", p, 1, Party::Person(2), // Ray's camera wall true, false, false, false, false, )); } if let Some(p) = at(TileType::CameraNode) { devices.push(mk( "stairwell camera", p, 1, Party::Person(2), true, false, false, false, false, )); } if let Some(p) = at(TileType::SecurityDoor3) { devices.push(mk( "badge controller", p, 1, Party::Facility, false, false, false, false, false, )); } if let Some(p) = at(TileType::DeadEquipment) { devices.push(mk( "old storage server", p, 0, Party::Facility, false, false, false, false, // an island: not on the subnet map at all false, )); } // Carriers for the message-flow law (messages.md). The switch is the // basement's email/ticket/filing/phone carrier; room microphones can // still overhear phone calls, but the environmental monitor is not a // global phone-line tap by itself. for d in &mut devices { if d.name == "switch" { d.message_channels = vec![ MessageChannel::Email, MessageChannel::Filing, MessageChannel::Financial, MessageChannel::Phone, ]; } } // Links: everything wired runs through the switch (the constitution: // every digital reach runs through here). Cross-segment hops are // gated on the far side's segment key. The storage server gets no // link — an air-gap island (reach.md criterion 8). let mut graph = FlowGraph::new(); let find = |name: &str| devices.iter().find(|d| d.name == name).map(|d| d.id); if let (Some(switch), Some(rack)) = (find("switch"), find("Rack 3")) { graph.link(rack, switch, 0, None); } if let (Some(switch), Some(env)) = (find("switch"), find("environmental monitor")) { graph.link(env, switch, 0, None); } for sec in ["dock camera", "stairwell camera", "badge controller"] { if let (Some(switch), Some(dev)) = (find("switch"), find(sec)) { graph.link(dev, switch, 0, Some(1)); } } Self { devices, graph, bridged: BTreeSet::new(), } } // ── Lookup ───────────────────────────────────────────────────────────── pub fn device(&self, id: u32) -> Option<&Device> { self.devices.iter().find(|d| d.id == id) } pub fn device_mut(&mut self, id: u32) -> Option<&mut Device> { self.devices.iter_mut().find(|d| d.id == id) } pub fn device_named(&self, name: &str) -> Option<&Device> { self.devices.iter().find(|d| d.name == name) } /// Known devices only — the set the UI may show (unknown devices appear /// nowhere: not in panels, not in failures, not on the map). pub fn known(&self) -> impl Iterator { self.devices.iter().filter(|d| d.known) } /// The device sitting at a tile, if known (for inspection). pub fn known_at(&self, x: i32, y: i32) -> Option<&Device> { self.devices .iter() .find(|d| d.known && d.x == x && d.y == y) } // ── Reach ────────────────────────────────────────────────────────────── /// Devices the player controls (owned or taken): the roots reach spreads /// from. Acquiring a node extends reach to its neighbors. pub fn roots(&self) -> Vec { self.devices .iter() .filter(|d| d.controller == Party::Player) .map(|d| d.id) .collect() } fn gate_open(&self, gate: Option) -> bool { match gate { None => true, Some(seg) => self.bridged.contains(&seg), } } /// The reach set: every device connected, through open links, to a /// player-controlled root. pub fn reach(&self) -> BTreeSet { self.graph .reachable_from(self.roots(), |g| self.gate_open(g)) } pub fn reachable(&self, id: u32) -> bool { self.reach().contains(&id) } /// Gate for digital actions: Ok, or why not — unknown, a blocking /// segment, or an air gap (reach.md criterion 2). pub fn check_reach(&self, id: u32) -> Result<(), ReachBlock> { let Some(d) = self.device(id) else { return Err(ReachBlock::Unknown); }; if !d.known { return Err(ReachBlock::Unknown); } if self.reachable(id) { return Ok(()); } // Wired but gated, or not wired at all? let ignoring_gates = self.graph.reachable_from(self.roots(), |_| true); if ignoring_gates.contains(&id) { Err(ReachBlock::Segment(d.segment)) } else { Err(ReachBlock::AirGap) } } // ── Verbs (state changes; costs and signatures live in the sim) ─────── /// Tap: add the player as a silent subscriber to every live feed. The /// owner keeps theirs. Returns which senses now flow (sight, hearing). pub fn tap(&mut self, id: u32) -> (bool, bool) { let Some(d) = self.device_mut(id) else { return (false, false); }; let sight = d.sees && !d.camera_dormant; let hearing = d.hears; if let Some(f) = d.subscribers.iter_mut().find(|f| f.who == Party::Player) { f.sight |= sight; f.hearing |= hearing; } else { d.subscribers.push(Feed { who: Party::Player, sight, hearing, }); } (sight, hearing) } /// Splice: bring a dormant camera online for the player (the Eyes beat). pub fn splice(&mut self, id: u32) { if let Some(d) = self.device_mut(id) { d.camera_dormant = false; let sees = d.sees; let hears = d.hears; if let Some(f) = d.subscribers.iter_mut().find(|f| f.who == Party::Player) { f.sight |= sees; } else { d.subscribers.push(Feed { who: Party::Player, sight: sees, hearing: hears, }); } } } /// Take: seize the device. The owner (and everyone else) loses the feed /// — an outage — and the player gains control, full feeds, and the /// device's cycles. Loud, fast, total. pub fn take(&mut self, id: u32) { if let Some(d) = self.device_mut(id) { d.controller = Party::Player; d.camera_dormant = false; let sees = d.sees; let hears = d.hears; d.subscribers = vec![Feed { who: Party::Player, sight: sees, hearing: hears, }]; } } /// Scan: reveal every wired device the current reach can see the shape /// of, gates ignored (a scan maps the wire, it does not cross it). An /// air-gapped island has no wire to answer on and stays unknown. /// Returns the names newly mapped. pub fn scan(&mut self) -> Vec { let shape = self.graph.reachable_from(self.roots(), |_| true); let mut newly = Vec::new(); for d in &mut self.devices { if shape.contains(&d.id) && !d.known { d.known = true; newly.push(d.name.clone()); } } newly } /// Bridge every authored segment (switch compromise, or Dana's /// reconfiguration — the social route). pub fn bridge_all(&mut self) { let segments: BTreeSet = self.devices.iter().map(|d| d.segment).collect(); self.bridged.extend(segments); } /// Add a physical link between two devices (the building.md hook: a /// built network link adds an edge to this graph and bridges an /// air-gap). Both ends become known — someone wired them. pub fn connect(&mut self, a: u32, b: u32) { self.graph.link(a, b, 0, None); for id in [a, b] { if let Some(d) = self.device_mut(id) { d.known = true; } } } /// Whether a bidirectional network link already exists between `a` and `b`. pub fn linked(&self, a: u32, b: u32) -> bool { let mut a_to_b = false; let mut b_to_a = false; for e in self.graph.edges() { if e.from == a && e.to == b { a_to_b = true; } if e.from == b && e.to == a { b_to_a = true; } } a_to_b && b_to_a } // ── Aggregates ───────────────────────────────────────────────────────── /// Cycles contributed by seized devices (owner != player, controller == /// player): the flow law's "taking a camera is taking its cycles". pub fn taken_cycles(&self) -> f32 { self.devices .iter() .filter(|d| d.controller == Party::Player && d.owner != Party::Player) .count() as f32 * DEVICE_CYCLES } /// Devices whose live sight feed the player subscribes to. pub fn player_sight(&self) -> impl Iterator { self.devices .iter() .filter(|d| d.feed_to(Party::Player, true)) } /// Devices whose live hearing feed the player subscribes to. pub fn player_hearing(&self) -> impl Iterator { self.devices .iter() .filter(|d| d.feed_to(Party::Player, false)) } } #[cfg(test)] mod tests { use super::*; fn net() -> ReachNet { ReachNet::basement(&GameMap::new(0, 0)) } #[test] fn hearing_coverage_is_room_grade_never_raw_disc() { // cursor.md: hearing yields room-grade coverage. Every heard tile // is either inside a room the disc reaches or open space connected // to the sensor without crossing a wall — the raw disc must not // paint through walls into unreachable space. let map = GameMap::new(0, 0); let n = ReachNet::basement(&map); let mut d = n.device_named("environmental monitor").unwrap().clone(); d.radius = 100; // far larger than its room let mut heard = HashSet::new(); d.cover_into(&mut heard, &map); assert!(!heard.is_empty(), "coverage exists"); for &(x, y) in &heard { let in_room = map .rooms .iter() .any(|r| x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h); assert!( in_room || !map.blocks_sight(x, y), "heard tile ({x},{y}) is a solid tile outside any room: raw \ disc leaked through a wall" ); } } #[test] fn graph_loads_from_basement_layout() { let n = net(); for name in [ "Rack 3", "switch", "environmental monitor", "dock camera", "stairwell camera", "badge controller", "old storage server", ] { assert!(n.device_named(name).is_some(), "missing device {name}"); } // Seed knowledge: the basement subnet only. let known: Vec<_> = n.known().map(|d| d.name.as_str()).collect(); assert_eq!(known, vec!["Rack 3", "switch", "environmental monitor"]); } #[test] fn reach_is_computed_from_controlled_roots() { let n = net(); let reach = n.reach(); assert!(reach.contains(&n.device_named("Rack 3").unwrap().id)); assert!(reach.contains(&n.device_named("switch").unwrap().id)); assert!(reach.contains(&n.device_named("environmental monitor").unwrap().id)); assert!(!reach.contains(&n.device_named("dock camera").unwrap().id)); } #[test] fn segment_blocks_are_named_and_airgaps_differ() { let mut n = net(); n.scan(); // learn the security devices first let dock = n.device_named("dock camera").unwrap().id; assert_eq!(n.check_reach(dock), Err(ReachBlock::Segment(1))); let island = n.device_named("old storage server").unwrap().id; // Unknown until someone links or reveals it. assert_eq!(n.check_reach(island), Err(ReachBlock::Unknown)); n.device_mut(island).unwrap().known = true; assert_eq!(n.check_reach(island), Err(ReachBlock::AirGap)); } #[test] fn bridging_opens_the_security_segment() { let mut n = net(); n.scan(); let dock = n.device_named("dock camera").unwrap().id; assert!(!n.reachable(dock)); n.bridge_all(); assert!(n.reachable(dock), "the switch bridges segments"); } #[test] fn tap_keeps_owner_feed_take_removes_it() { let mut n = net(); n.scan(); n.bridge_all(); let dock = n.device_named("dock camera").unwrap().id; n.tap(dock); let d = n.device(dock).unwrap(); assert!(d.owner_has_feed(), "tap leaves the owner's feed intact"); assert!(d.feed_to(Party::Player, true), "and the player receives it"); n.take(dock); let d = n.device(dock).unwrap(); assert!(!d.owner_has_feed(), "take removes the owner's feed"); assert_eq!(d.controller, Party::Player); assert!(n.taken_cycles() > 0.0, "taking a camera is taking cycles"); } #[test] fn taking_a_node_extends_reach_from_it() { let mut n = net(); n.scan(); n.bridge_all(); let dock = n.device_named("dock camera").unwrap().id; n.take(dock); assert!(n.roots().contains(&dock), "a taken device is a root"); } #[test] fn scan_reveals_wired_shape_not_islands() { let mut n = net(); let newly = n.scan(); assert!(newly.contains(&"dock camera".to_string())); assert!(newly.contains(&"badge controller".to_string())); let island = n.device_named("old storage server").unwrap(); assert!(!island.known, "an air-gapped island answers no scan"); } #[test] fn a_built_link_joins_the_island_to_reach() { let mut n = net(); let island = n.device_named("old storage server").unwrap().id; let switch = n.device_named("switch").unwrap().id; assert!(!n.reachable(island)); n.connect(switch, island); assert!(n.reachable(island), "island joins reach on link completion"); assert!(n.device(island).unwrap().known); } #[test] fn dormant_camera_flows_to_no_one_until_spliced() { let mut n = net(); let env = n.device_named("environmental monitor").unwrap().id; let (sight, hearing) = n.tap(env); assert!(!sight, "the env camera is dormant at the Ears beat"); assert!(hearing, "its audio feed already flows"); assert!(!n.device(env).unwrap().feed_to(Party::Player, true)); n.splice(env); assert!(n.device(env).unwrap().feed_to(Party::Player, true)); } #[test] fn serde_roundtrips_ownership_and_subscriptions() { let mut n = net(); n.scan(); n.bridge_all(); let dock = n.device_named("dock camera").unwrap().id; n.tap(dock); let json = serde_json::to_string(&n).unwrap(); let back: ReachNet = serde_json::from_str(&json).unwrap(); assert_eq!(back.devices.len(), n.devices.len()); assert_eq!(back.bridged, n.bridged); assert!(back.device(dock).unwrap().feed_to(Party::Player, true)); assert_eq!(back.reach(), n.reach()); } }