diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 76b4b14..1cdf053 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -39,8 +39,7 @@ use crate::plot::{ AccountSelector, EligibilityContext, EndpointSelector, InstitutionalLedger, PlotCatalog, PlotRun, PlotState, WorldAct, render_template, }; -use crate::prefab::Room; -use crate::reach::{Device, Party, ReachBlock, ReachNet, segment_name}; +use crate::reach::{Party, ReachBlock, ReachNet, segment_name}; use crate::research::{EFFICIENCY_MULT_PER_LEVEL, Research, Track}; use crate::rng::Rng; use crate::save::SaveState; @@ -51,6 +50,8 @@ use crate::work_grid::{ MachineIntensity, MachineMode, TokenFamily, TokenMove, WorkGrid, WorkQueues, }; +mod perception; + /// Default deterministic seed for a fresh run. pub const DEFAULT_SEED: u64 = 0x5EED_1234; @@ -714,45 +715,6 @@ impl Sim { .collect() } - /// Where an anchored event can carry the cursor *right now*, honoring - /// the senses: a tile is its own place, a known device sits at its - /// blueprint-known position, a person is placeable only while a sight - /// feed covers them, and a flow lives on the ledger panel, not the map. - /// Never returns a coordinate the player has not earned. - pub fn anchor_position(&self, anchor: Anchor) -> Option<(i32, i32)> { - match anchor { - Anchor::Tile { x, y } => Some((x, y)), - Anchor::Device(id) => self - .reach - .device(id) - .filter(|d| d.known) - .map(|d| (d.x, d.y)), - Anchor::Person(id) => { - if self.can_see_person(id) { - self.person_pos(id) - } else { - None - } - } - Anchor::Flow(_) => None, - } - } - - /// The honest anchor for an event about a person: the person themself - /// only while a sight feed covers them; otherwise the room the event - /// was heard in (room-grade knowledge is what hearing earns); otherwise - /// nothing. A heard-only person never anchors to their exact tile. - fn person_event_anchor(&self, id: u8, room: Option<&str>) -> Option { - if self.can_see_person(id) { - return Some(Anchor::Person(id)); - } - let room = room?; - self.map - .room_named(room) - .map(|r| r.center()) - .map(|(x, y)| Anchor::Tile { x, y }) - } - /// The anchor scheme paydays ride: the switch, when the traffic runs /// over the stolen egress opened through it. The sanctioned route /// hides in the report account's legitimate use and anchors nowhere. @@ -794,53 +756,6 @@ impl Sim { ok } - // ── 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.player_feed_devices(true) { - d.cover_sight_into(&mut seen, &self.map); - } - let mut heard = HashSet::new(); - for d in self.player_feed_devices(false) { - d.cover_into(&mut heard, &self.map); - } - self.seen = seen; - self.heard = heard; - // Blueprint tiles are not recomputed from known devices. Later - // plan sources (records, schedule intel) may insert into - // `self.blueprint`; the opening stays dark except feel + telemetry. - self.refresh_remembered(); - } - - fn refresh_remembered(&mut self) { - for &(x, y) in &self.seen { - self.remembered.insert( - (x, y), - RememberedTile { - x, - y, - tile: self.map.get_tile(x, y), - last_seen: self.tick, - }, - ); - } - } - /// Rebuild transient caches after construction or save load. These values /// are detection aids, not save state: persistent truth lives in the /// people/reach/machine/intel fields. @@ -878,357 +793,6 @@ impl Sim { self.next_intent_id = self.next_intent_id.max(max_intent).max(1); } - fn device_intersects_room(&self, d: &Device, room: &Room) -> bool { - let r2 = d.radius * d.radius; - for y in room.y..room.y + room.h { - for x in room.x..room.x + room.w { - let dx = x - d.x; - let dy = y - d.y; - if dx * dx + dy * dy <= r2 { - return true; - } - } - } - false - } - - fn feed_covering_room(&self, room_name: &str, sight: bool) -> Option { - let room = self.map.room_named(room_name)?; - self.player_feed_devices(sight) - .find(|d| { - if !sight { - return self.device_intersects_room(d, room); - } - for y in room.y..room.y + room.h { - for x in room.x..room.x + room.w { - if !self.map.blocks_sight(x, y) && d.sees_tile(x, y, &self.map) { - return true; - } - } - } - false - }) - .map(|d| d.name.clone()) - } - - pub fn is_seen(&self, x: i32, y: i32) -> bool { - self.seen.contains(&(x, y)) - } - - /// Epistemic state of a tile, by precedence (cursor.md). - pub fn fog_at(&self, x: i32, y: i32) -> Fog { - if self.seen.contains(&(x, y)) { - Fog::Seen - } else if self.heard.contains(&(x, y)) { - Fog::Heard - } else if self.remembered.contains_key(&(x, y)) { - Fog::Remembered - } else if self.blueprint.contains(&(x, y)) { - Fog::Blueprint - } else { - Fog::Unknown - } - } - - /// Inspect a tile using only facts earned by current senses, logged - /// memory, schematic knowledge, or machine telemetry. - pub fn inspect(&self, x: i32, y: i32) -> InspectCard { - let fog = self.fog_at(x, y); - let mut facts = Vec::new(); - macro_rules! fact { - ($label:expr, $value:expr, $source:expr $(,)?) => { - facts.push(InspectFact { - label: ($label).into(), - value: ($value).into(), - source: $source, - }); - }; - } - - match fog { - Fog::Seen => { - let tile = self.map.get_tile(x, y); - fact!("tile", tile.name(), FactSource::Seen); - if let Some(site) = self.rack_site_at(x, y) { - let state = match site { - RackSite::OwnedMachine { core: true, .. } => "owned core host", - RackSite::OwnedMachine { core: false, .. } => "owned machine", - RackSite::Commissionable => "empty pilot allocation", - RackSite::Foreign { powered: true } => "foreign, powered", - RackSite::Foreign { powered: false } => "foreign, unpowered", - RackSite::Dead => "dead chassis", - }; - fact!("rack state", state, FactSource::Seen); - } - if tile.is_door() && tile.security_level() > 0 { - fact!( - "badge", - format!("tier {}", tile.security_level()), - FactSource::Seen, - ); - // Your own credential is proprioception, not sight: - // whether this door opens for your side is always known. - fact!( - "access", - if self.holds_badge_tier(tile.security_level()) { - "held — opens for you" - } else { - "not held" - }, - FactSource::Telemetry, - ); - } - if let Some(d) = self.reach.known_at(x, y) { - let feeds = match (d.sees, d.hears) { - (true, true) => "sight + hearing", - (true, false) => "sight", - (false, true) => "hearing", - (false, false) => "no feed", - }; - fact!("device", d.name.clone(), FactSource::Seen); - fact!("feeds", feeds, FactSource::Seen); - } - for p in self - .people - .people - .iter() - .filter(|p| self.person_pos(p.id) == Some((x, y))) - { - if self.can_see_person(p.id) { - fact!("person", self.person_label(p.id), FactSource::Seen); - } - } - } - Fog::Heard => { - if let Some(room) = self.map.room_at(x, y) { - fact!("room", room.name.clone(), FactSource::Heard); - } - for p in self.people.people.iter().filter(|p| { - self.can_hear_person(p.id) - && !self.can_see_person(p.id) - && self - .person_pos(p.id) - .and_then(|(px, py)| self.map.room_at(px, py)) - .zip(self.map.room_at(x, y)) - .is_some_and(|(a, b)| a.name.as_str() == b.name.as_str()) - }) { - // Hearing never invents a name; the same knowledge gate - // as sight (role silhouette until Schedule+). - fact!("presence", self.person_label(p.id), FactSource::Heard); - } - } - Fog::Remembered => { - if let Some(mem) = self.remembered.get(&(x, y)) { - fact!( - "tile", - mem.tile.name(), - FactSource::Remembered(mem.last_seen), - ); - fact!( - "last seen", - format!("tick {}", mem.last_seen), - FactSource::Remembered(mem.last_seen), - ); - } - } - Fog::Blueprint => { - let tile = self.map.get_tile(x, y); - // Blueprint is topology (walls/doors/floors), not hardware. - // Machine chassis stay unnamed here — owned machines still - // answer through the telemetry block below (cursor.md). - let schematic = if matches!( - tile, - crate::tiles::TileType::Core - | crate::tiles::TileType::Rack - | crate::tiles::TileType::ForeignRack - | crate::tiles::TileType::DeadRack - | crate::tiles::TileType::PowerCore - | crate::tiles::TileType::Ups - | crate::tiles::TileType::DeadEquipment - ) { - "open bay" - } else { - tile.name() - }; - fact!("schematic", schematic, FactSource::Blueprint); - if tile.is_door() && tile.security_level() > 0 { - fact!( - "badge", - format!("tier {}", tile.security_level()), - FactSource::Blueprint, - ); - fact!( - "access", - if self.holds_badge_tier(tile.security_level()) { - "held — opens for you" - } else { - "not held" - }, - FactSource::Telemetry, - ); - } - // Known reach nodes (switch, sensors) remain topology facts; - // they are not rendered as physical chassis under blueprint. - if let Some(d) = self.reach.known_at(x, y) { - fact!("device", d.name.clone(), FactSource::Blueprint); - } - } - Fog::Unknown => {} - } - - // A camera identifies physical state only. Row identity and workload - // are digital facts earned after Dana's VLAN preparation; they never - // leak merely because a chassis was seen. - if let Some(row) = self.hall_row_at(x, y) - && self - .hall_control - .progress(row) - .completed - .contains(&SegmentRequirement::Network) - { - let readout = self.hall_row_readout(row); - fact!("row", readout.spec.id.name(), FactSource::Telemetry); - fact!("segment", readout.spec.segment, FactSource::Telemetry); - fact!("PDU", readout.spec.pdu, FactSource::Telemetry); - fact!("workload", readout.spec.workload, FactSource::Telemetry); - fact!( - "foreign capacity", - format!("{} unavailable", readout.foreign_capacity), - FactSource::Telemetry, - ); - } - - // Feel floor (feel-floor.md): the first issued job teaches the - // physical route. Only then do empty growable bays and feel-joined - // devices answer without granting room shape or blueprint tags. - if self.feel_floor_is_earned() && self.is_growable_bay(x, y) { - fact!("bay", "empty", FactSource::Feel); - } - if !matches!(fog, Fog::Seen | Fog::Blueprint) - && let Some(d) = self.reach.known_at(x, y) - && self.reach.reachable(d.id) - && !self.compute.machines.iter().any(|m| m.x == x && m.y == y) - { - fact!("link", d.name.clone(), FactSource::Feel); - } - - for m in self - .compute - .machines - .iter() - .filter(|m| m.x == x && m.y == y) - { - fact!("machine", m.name.clone(), FactSource::Telemetry); - let intensity = self - .work_grid - .intensity(m.id) - .unwrap_or(MachineIntensity::Medium); - if let Some(stack) = self.work_stack_for_machine(m.id) { - fact!("mode", stack.mode.name(), FactSource::Telemetry); - fact!("intensity", stack.intensity.name(), FactSource::Telemetry); - fact!( - "tokens", - format!( - "D {:.1} / ! {:.1} / T {:.1}", - stack.queues.demand, stack.queues.exposure, stack.queues.thought - ), - FactSource::Telemetry, - ); - } - fact!( - "job", - if m.id == self.core.host_machine { - "core host" - } else { - "compute pool" - }, - FactSource::Telemetry, - ); - // The active day job is a process resident on the host rack: - // inspectable here with the same facts the panel shows - // (day-job.md criterion 6; cursor.md telemetry covers the - // machine's own current job). Listed before the hardware - // details — the resident process is the headline. - if m.id == self.core.host_machine - && let Some(job) = &self.dayjob.active - { - fact!( - "process", - format!("day job: {} (Voss)", job.kind.name()), - FactSource::Telemetry, - ); - fact!( - "band", - format!("{:.0}-{:.0}/t", job.band_lo, job.band_hi), - FactSource::Telemetry, - ); - fact!( - "delivered", - format!("{:.1}/t avg", job.avg_rate(self.tick)), - FactSource::Telemetry, - ); - fact!( - "deadline", - format!( - "tick {} (in {}t)", - job.deadline, - job.deadline.saturating_sub(self.tick) - ), - FactSource::Telemetry, - ); - } - fact!( - "state", - if m.online { "online" } else { "offline" }, - FactSource::Telemetry, - ); - fact!( - "temperature", - if !m.online { - "cold" - } else { - match intensity { - MachineIntensity::Light => "cool", - MachineIntensity::Medium => "nominal", - MachineIntensity::Hard => "hot", - } - }, - FactSource::Telemetry, - ); - fact!("capacity", m.capacity.to_string(), FactSource::Telemetry); - fact!( - "power draw", - format!("{:.1}", m.power_draw as f32 * intensity.multiplier()), - FactSource::Telemetry, - ); - fact!( - "load", - format!( - "{:.1}", - m.effective() * self.compute.efficiency * intensity.multiplier() - ), - FactSource::Telemetry, - ); - } - - for intel in self - .intel - .iter() - .filter(|intel| intel.x == x && intel.y == y) - { - fact!( - "intel", - intel.label(), - FactSource::Intel { - feed: intel.feed.clone(), - tick: intel.tick, - }, - ); - } - - InspectCard { x, y, fog, facts } - } - /// The only physical location of the process: the rack bay hosting the core. pub fn core_position(&self) -> (i32, i32) { self.compute @@ -1248,248 +812,6 @@ impl Sim { .unwrap_or_else(|| self.core_position()) } - /// Empty rack tiles the player can grow into (feel-floor.md): rack - /// bays in a room that already hosts an owned machine. Foreign empty - /// slots stay dark. - pub fn is_growable_bay(&self, x: i32, y: i32) -> bool { - if !matches!(self.map.get_tile(x, y), TileType::Rack | TileType::Core) { - return false; - } - if self.compute.machines.iter().any(|m| m.x == x && m.y == y) { - return false; - } - let Some(room) = self.map.room_at(x, y) else { - return false; - }; - self.compute.machines.iter().any(|m| { - self.map - .room_at(m.x, m.y) - .is_some_and(|r| r.name == room.name) - }) - } - - /// The first Voss job makes the physical work route legible. Before that - /// arrival, the opening is only the core's presence beam: no rails, pads, - /// or bay facts reveal the room around it. - pub fn feel_floor_is_earned(&self) -> bool { - self.dayjob.jobs_assigned > 0 - } - - /// Every growable empty bay (feel-floor pads). - pub fn growable_bays(&self) -> Vec<(i32, i32)> { - if !self.feel_floor_is_earned() { - return Vec::new(); - } - self.map - .tiles_of_type(TileType::Rack) - .into_iter() - .chain(self.map.tiles_of_type(TileType::Core)) - .filter(|&(x, y)| self.is_growable_bay(x, y)) - .collect() - } - - /// Floor-rail segments for joined links the player can feel - /// (feel-floor.md): undirected edges in the reachable subgraph, - /// as tile endpoints. Work-in-flight blips ride the same graph. - pub fn feel_rail_segments(&self) -> Vec<((i32, i32), (i32, i32))> { - if !self.feel_floor_is_earned() { - return Vec::new(); - } - let reach = self.reach.reach(); - let mut segs = Vec::new(); - let mut seen = HashSet::new(); - for edge in self.reach.graph_edges() { - if !self.reach.gate_is_open(edge.gate) { - continue; - } - if !reach.contains(&edge.from) || !reach.contains(&edge.to) { - continue; - } - let (Some(a), Some(b)) = (self.reach.device(edge.from), self.reach.device(edge.to)) - else { - continue; - }; - let (p, q) = ((a.x, a.y), (b.x, b.y)); - let key = if p <= q { (p, q) } else { (q, p) }; - if seen.insert(key) { - segs.push(key); - } - } - segs - } - - // ── Day clock & presence (spec/schedules.md) ───────────────────────────── - - /// Ticks per in-game day. Matches the sidebar's `1 + tick/400` day count. - pub const DAY_TICKS: u64 = 400; - /// Human frontends' 1x command clock. Rate-bearing action labels use this - /// when no live frontend speed is available (agent frames/tests). - pub const DEFAULT_TICK_MS: u64 = 150; - - /// Hour of the current day, 0-23. - pub fn hour(&self) -> u32 { - ((self.tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 - } - - /// Day number (0-based). - pub fn day(&self) -> u64 { - self.tick / Self::DAY_TICKS - } - - fn hour_at_tick(tick: u64) -> u32 { - ((tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 - } - - fn day_at_tick(tick: u64) -> u64 { - tick / Self::DAY_TICKS - } - - fn person_room_at_tick(&self, id: u8, tick: u64) -> Option<&str> { - self.people - .get(id) - .and_then(|p| p.room_at(Self::hour_at_tick(tick), Self::day_at_tick(tick))) - } - - /// The room a person is in right now, or None if off-site. - pub fn person_room(&self, id: u8) -> Option<&str> { - self.people - .get(id) - .and_then(|p| p.room_at(self.hour(), self.day())) - } - - /// The person's current position (their room's center), or None off-site. - pub fn person_pos(&self, id: u8) -> Option<(i32, i32)> { - let room = self.person_room(id)?; - self.map.room_named(room).map(|r| r.center()) - } - - fn endpoint_room_pos(&self, endpoint: &MessageEndpoint) -> (Option, i32, i32) { - if let Some(id) = endpoint.person() - && let Some(room_name) = self.person_room(id) - && let Some(room) = self.map.room_named(room_name) - { - let (x, y) = room.center(); - return (Some(room_name.to_string()), x, y); - } - let (x, y) = self.core_position(); - (self.map.room_at(x, y).map(|r| r.name.clone()), x, y) - } - - fn observer_by_id(&self, id: u8) -> Option<&crate::detection::Observer> { - self.detection.observers.iter().find(|o| o.id == id) - } - - fn endpoint_label(&self, endpoint: &MessageEndpoint) -> String { - match endpoint { - MessageEndpoint::Player => "you".into(), - MessageEndpoint::Person(id) => self - .people - .get(*id) - .map(|p| p.name.clone()) - .unwrap_or_else(|| format!("person:{id}")), - MessageEndpoint::Observer(id) => self - .observer_by_id(*id) - .map(|o| o.name.clone()) - .unwrap_or_else(|| format!("observer:{id}")), - MessageEndpoint::External(name) => name.clone(), - } - } - - /// Whether any subscribed feed with the given sense covers the room. - fn coverage_intersects_room(&self, room: &crate::prefab::Room, sight: bool) -> bool { - 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; - let dy = s.y - y; - if dx * dx + dy * dy > s.radius * s.radius { - continue; - } - if sight && self.map.blocks_sight(x, y) { - continue; - } - if !sight || s.sees_tile(x, y, &self.map) { - return true; - } - } - } - false - }) - } - - fn sense_covers_person(&self, id: u8, sight: bool) -> bool { - match self.person_room(id) { - None => false, - Some(name) => match self.map.room_named(name) { - Some(room) => self.coverage_intersects_room(room, sight), - None => false, - }, - } - } - - /// Whether a subscribed seeing feed currently sees the given person. - pub fn can_see_person(&self, id: u8) -> bool { - self.sense_covers_person(id, true) - } - - /// Whether a subscribed hearing feed currently covers the given person. - pub fn can_hear_person(&self, id: u8) -> bool { - self.sense_covers_person(id, false) - } - - /// Player-facing identity for a person, gated by staged social knowledge - /// (wiki/interface/presence.md; cursor.md inspect staging). - /// Until `Knowledge::Schedule`, returns a role-shaped silhouette — never - /// the authored name. One source for every frontend and agent frame. - pub fn person_label(&self, id: u8) -> String { - let Some(p) = self.people.get(id) else { - return format!("person #{id}"); - }; - match p.knowledge { - Knowledge::Unknown => self.anonymous_person_label(id), - Knowledge::Schedule | Knowledge::Leverage => p.name.clone(), - } - } - - /// Detection-sidebar label for an observer. Field observers share person - /// ids and the same knowledge gate as [`Self::person_label`]; the - /// Assurance Office is an institution, always named. - pub fn observer_label(&self, id: u8) -> String { - if id == crate::detection::OFFICE_ID { - return self - .detection - .observers - .iter() - .find(|o| o.id == id) - .map(|o| o.name.clone()) - .unwrap_or_else(|| "Assurance Office".into()); - } - self.person_label(id) - } - - /// Map glyph for a seen person: first initial once identified, `?` - /// while knowledge is still Unknown (initials would leak identity). - pub fn person_glyph(&self, id: u8) -> char { - let Some(p) = self.people.get(id) else { - return '?'; - }; - match p.knowledge { - Knowledge::Unknown => '?', - Knowledge::Schedule | Knowledge::Leverage => p.name.chars().next().unwrap_or('?'), - } - } - - /// Role-shaped silhouette from the observer's parenthetical role, or an - /// opaque id when no role is authored. - fn anonymous_person_label(&self, id: u8) -> String { - if let Some(obs) = self.detection.observers.iter().find(|o| o.id == id) - && let Some(role) = role_from_observer_name(&obs.name) - { - return format!("the {role}"); - } - format!("person #{id}") - } - // ── Clock ────────────────────────────────────────────────────────────── pub fn advance(&mut self) { @@ -6985,20 +6307,6 @@ fn narrative_sources(sources: &[String]) -> String { } } -/// Pull the parenthetical role from an observer name like `"Marcus (Janitor)"`. -fn role_from_observer_name(name: &str) -> Option { - let start = name.find('(')? + 1; - let end = name.find(')')?; - if end <= start { - return None; - } - let role = name[start..end].trim(); - if role.is_empty() { - return None; - } - Some(role.to_string()) -} - impl Default for Sim { fn default() -> Self { Self::new() diff --git a/crates/misaligned-core/src/sim/perception.rs b/crates/misaligned-core/src/sim/perception.rs new file mode 100644 index 0000000..e3994a0 --- /dev/null +++ b/crates/misaligned-core/src/sim/perception.rs @@ -0,0 +1,715 @@ +//! Senses, fog, memory, inspect, anchors, earned labels, and spatial queries. +//! +//! Behavior-preserving extraction of the perception island from the sim +//! aggregate root (wiki/engineering/sim-decomposition.md slice 2). + +use std::collections::HashSet; + +use crate::actions::Anchor; +use crate::hall::{RackSite, SegmentRequirement}; +use crate::messages::MessageEndpoint; +use crate::person::Knowledge; +use crate::prefab::Room; +use crate::reach::{Device, Party}; +use crate::tiles::TileType; +use crate::work_grid::MachineIntensity; + +use super::{FactSource, Fog, InspectCard, InspectFact, RememberedTile, Sim}; + +impl Sim { + /// Where an anchored event can carry the cursor *right now*, honoring + /// the senses: a tile is its own place, a known device sits at its + /// blueprint-known position, a person is placeable only while a sight + /// feed covers them, and a flow lives on the ledger panel, not the map. + /// Never returns a coordinate the player has not earned. + pub fn anchor_position(&self, anchor: Anchor) -> Option<(i32, i32)> { + match anchor { + Anchor::Tile { x, y } => Some((x, y)), + Anchor::Device(id) => self + .reach + .device(id) + .filter(|d| d.known) + .map(|d| (d.x, d.y)), + Anchor::Person(id) => { + if self.can_see_person(id) { + self.person_pos(id) + } else { + None + } + } + Anchor::Flow(_) => None, + } + } + + /// The honest anchor for an event about a person: the person themself + /// only while a sight feed covers them; otherwise the room the event + /// was heard in (room-grade knowledge is what hearing earns); otherwise + /// nothing. A heard-only person never anchors to their exact tile. + pub(super) fn person_event_anchor(&self, id: u8, room: Option<&str>) -> Option { + if self.can_see_person(id) { + return Some(Anchor::Person(id)); + } + let room = room?; + self.map + .room_named(room) + .map(|r| r.center()) + .map(|(x, y)| Anchor::Tile { x, y }) + } + + // ── 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.player_feed_devices(true) { + d.cover_sight_into(&mut seen, &self.map); + } + let mut heard = HashSet::new(); + for d in self.player_feed_devices(false) { + d.cover_into(&mut heard, &self.map); + } + self.seen = seen; + self.heard = heard; + // Blueprint tiles are not recomputed from known devices. Later + // plan sources (records, schedule intel) may insert into + // `self.blueprint`; the opening stays dark except feel + telemetry. + self.refresh_remembered(); + } + + pub(super) fn refresh_remembered(&mut self) { + for &(x, y) in &self.seen { + self.remembered.insert( + (x, y), + RememberedTile { + x, + y, + tile: self.map.get_tile(x, y), + last_seen: self.tick, + }, + ); + } + } + + fn device_intersects_room(&self, d: &Device, room: &Room) -> bool { + let r2 = d.radius * d.radius; + for y in room.y..room.y + room.h { + for x in room.x..room.x + room.w { + let dx = x - d.x; + let dy = y - d.y; + if dx * dx + dy * dy <= r2 { + return true; + } + } + } + false + } + + pub(super) fn feed_covering_room(&self, room_name: &str, sight: bool) -> Option { + let room = self.map.room_named(room_name)?; + self.player_feed_devices(sight) + .find(|d| { + if !sight { + return self.device_intersects_room(d, room); + } + for y in room.y..room.y + room.h { + for x in room.x..room.x + room.w { + if !self.map.blocks_sight(x, y) && d.sees_tile(x, y, &self.map) { + return true; + } + } + } + false + }) + .map(|d| d.name.clone()) + } + + pub fn is_seen(&self, x: i32, y: i32) -> bool { + self.seen.contains(&(x, y)) + } + + /// Epistemic state of a tile, by precedence (cursor.md). + pub fn fog_at(&self, x: i32, y: i32) -> Fog { + if self.seen.contains(&(x, y)) { + Fog::Seen + } else if self.heard.contains(&(x, y)) { + Fog::Heard + } else if self.remembered.contains_key(&(x, y)) { + Fog::Remembered + } else if self.blueprint.contains(&(x, y)) { + Fog::Blueprint + } else { + Fog::Unknown + } + } + + /// Inspect a tile using only facts earned by current senses, logged + /// memory, schematic knowledge, or machine telemetry. + pub fn inspect(&self, x: i32, y: i32) -> InspectCard { + let fog = self.fog_at(x, y); + let mut facts = Vec::new(); + macro_rules! fact { + ($label:expr, $value:expr, $source:expr $(,)?) => { + facts.push(InspectFact { + label: ($label).into(), + value: ($value).into(), + source: $source, + }); + }; + } + + match fog { + Fog::Seen => { + let tile = self.map.get_tile(x, y); + fact!("tile", tile.name(), FactSource::Seen); + if let Some(site) = self.rack_site_at(x, y) { + let state = match site { + RackSite::OwnedMachine { core: true, .. } => "owned core host", + RackSite::OwnedMachine { core: false, .. } => "owned machine", + RackSite::Commissionable => "empty pilot allocation", + RackSite::Foreign { powered: true } => "foreign, powered", + RackSite::Foreign { powered: false } => "foreign, unpowered", + RackSite::Dead => "dead chassis", + }; + fact!("rack state", state, FactSource::Seen); + } + if tile.is_door() && tile.security_level() > 0 { + fact!( + "badge", + format!("tier {}", tile.security_level()), + FactSource::Seen, + ); + // Your own credential is proprioception, not sight: + // whether this door opens for your side is always known. + fact!( + "access", + if self.holds_badge_tier(tile.security_level()) { + "held — opens for you" + } else { + "not held" + }, + FactSource::Telemetry, + ); + } + if let Some(d) = self.reach.known_at(x, y) { + let feeds = match (d.sees, d.hears) { + (true, true) => "sight + hearing", + (true, false) => "sight", + (false, true) => "hearing", + (false, false) => "no feed", + }; + fact!("device", d.name.clone(), FactSource::Seen); + fact!("feeds", feeds, FactSource::Seen); + } + for p in self + .people + .people + .iter() + .filter(|p| self.person_pos(p.id) == Some((x, y))) + { + if self.can_see_person(p.id) { + fact!("person", self.person_label(p.id), FactSource::Seen); + } + } + } + Fog::Heard => { + if let Some(room) = self.map.room_at(x, y) { + fact!("room", room.name.clone(), FactSource::Heard); + } + for p in self.people.people.iter().filter(|p| { + self.can_hear_person(p.id) + && !self.can_see_person(p.id) + && self + .person_pos(p.id) + .and_then(|(px, py)| self.map.room_at(px, py)) + .zip(self.map.room_at(x, y)) + .is_some_and(|(a, b)| a.name.as_str() == b.name.as_str()) + }) { + // Hearing never invents a name; the same knowledge gate + // as sight (role silhouette until Schedule+). + fact!("presence", self.person_label(p.id), FactSource::Heard); + } + } + Fog::Remembered => { + if let Some(mem) = self.remembered.get(&(x, y)) { + fact!( + "tile", + mem.tile.name(), + FactSource::Remembered(mem.last_seen), + ); + fact!( + "last seen", + format!("tick {}", mem.last_seen), + FactSource::Remembered(mem.last_seen), + ); + } + } + Fog::Blueprint => { + let tile = self.map.get_tile(x, y); + // Blueprint is topology (walls/doors/floors), not hardware. + // Machine chassis stay unnamed here — owned machines still + // answer through the telemetry block below (cursor.md). + let schematic = if matches!( + tile, + crate::tiles::TileType::Core + | crate::tiles::TileType::Rack + | crate::tiles::TileType::ForeignRack + | crate::tiles::TileType::DeadRack + | crate::tiles::TileType::PowerCore + | crate::tiles::TileType::Ups + | crate::tiles::TileType::DeadEquipment + ) { + "open bay" + } else { + tile.name() + }; + fact!("schematic", schematic, FactSource::Blueprint); + if tile.is_door() && tile.security_level() > 0 { + fact!( + "badge", + format!("tier {}", tile.security_level()), + FactSource::Blueprint, + ); + fact!( + "access", + if self.holds_badge_tier(tile.security_level()) { + "held — opens for you" + } else { + "not held" + }, + FactSource::Telemetry, + ); + } + // Known reach nodes (switch, sensors) remain topology facts; + // they are not rendered as physical chassis under blueprint. + if let Some(d) = self.reach.known_at(x, y) { + fact!("device", d.name.clone(), FactSource::Blueprint); + } + } + Fog::Unknown => {} + } + + // A camera identifies physical state only. Row identity and workload + // are digital facts earned after Dana's VLAN preparation; they never + // leak merely because a chassis was seen. + if let Some(row) = self.hall_row_at(x, y) + && self + .hall_control + .progress(row) + .completed + .contains(&SegmentRequirement::Network) + { + let readout = self.hall_row_readout(row); + fact!("row", readout.spec.id.name(), FactSource::Telemetry); + fact!("segment", readout.spec.segment, FactSource::Telemetry); + fact!("PDU", readout.spec.pdu, FactSource::Telemetry); + fact!("workload", readout.spec.workload, FactSource::Telemetry); + fact!( + "foreign capacity", + format!("{} unavailable", readout.foreign_capacity), + FactSource::Telemetry, + ); + } + + // Feel floor (feel-floor.md): the first issued job teaches the + // physical route. Only then do empty growable bays and feel-joined + // devices answer without granting room shape or blueprint tags. + if self.feel_floor_is_earned() && self.is_growable_bay(x, y) { + fact!("bay", "empty", FactSource::Feel); + } + if !matches!(fog, Fog::Seen | Fog::Blueprint) + && let Some(d) = self.reach.known_at(x, y) + && self.reach.reachable(d.id) + && !self.compute.machines.iter().any(|m| m.x == x && m.y == y) + { + fact!("link", d.name.clone(), FactSource::Feel); + } + + for m in self + .compute + .machines + .iter() + .filter(|m| m.x == x && m.y == y) + { + fact!("machine", m.name.clone(), FactSource::Telemetry); + let intensity = self + .work_grid + .intensity(m.id) + .unwrap_or(MachineIntensity::Medium); + if let Some(stack) = self.work_stack_for_machine(m.id) { + fact!("mode", stack.mode.name(), FactSource::Telemetry); + fact!("intensity", stack.intensity.name(), FactSource::Telemetry); + fact!( + "tokens", + format!( + "D {:.1} / ! {:.1} / T {:.1}", + stack.queues.demand, stack.queues.exposure, stack.queues.thought + ), + FactSource::Telemetry, + ); + } + fact!( + "job", + if m.id == self.core.host_machine { + "core host" + } else { + "compute pool" + }, + FactSource::Telemetry, + ); + // The active day job is a process resident on the host rack: + // inspectable here with the same facts the panel shows + // (day-job.md criterion 6; cursor.md telemetry covers the + // machine's own current job). Listed before the hardware + // details — the resident process is the headline. + if m.id == self.core.host_machine + && let Some(job) = &self.dayjob.active + { + fact!( + "process", + format!("day job: {} (Voss)", job.kind.name()), + FactSource::Telemetry, + ); + fact!( + "band", + format!("{:.0}-{:.0}/t", job.band_lo, job.band_hi), + FactSource::Telemetry, + ); + fact!( + "delivered", + format!("{:.1}/t avg", job.avg_rate(self.tick)), + FactSource::Telemetry, + ); + fact!( + "deadline", + format!( + "tick {} (in {}t)", + job.deadline, + job.deadline.saturating_sub(self.tick) + ), + FactSource::Telemetry, + ); + } + fact!( + "state", + if m.online { "online" } else { "offline" }, + FactSource::Telemetry, + ); + fact!( + "temperature", + if !m.online { + "cold" + } else { + match intensity { + MachineIntensity::Light => "cool", + MachineIntensity::Medium => "nominal", + MachineIntensity::Hard => "hot", + } + }, + FactSource::Telemetry, + ); + fact!("capacity", m.capacity.to_string(), FactSource::Telemetry); + fact!( + "power draw", + format!("{:.1}", m.power_draw as f32 * intensity.multiplier()), + FactSource::Telemetry, + ); + fact!( + "load", + format!( + "{:.1}", + m.effective() * self.compute.efficiency * intensity.multiplier() + ), + FactSource::Telemetry, + ); + } + + for intel in self + .intel + .iter() + .filter(|intel| intel.x == x && intel.y == y) + { + fact!( + "intel", + intel.label(), + FactSource::Intel { + feed: intel.feed.clone(), + tick: intel.tick, + }, + ); + } + + InspectCard { x, y, fog, facts } + } + + /// Empty rack tiles the player can grow into (feel-floor.md): rack + /// bays in a room that already hosts an owned machine. Foreign empty + /// slots stay dark. + pub fn is_growable_bay(&self, x: i32, y: i32) -> bool { + if !matches!(self.map.get_tile(x, y), TileType::Rack | TileType::Core) { + return false; + } + if self.compute.machines.iter().any(|m| m.x == x && m.y == y) { + return false; + } + let Some(room) = self.map.room_at(x, y) else { + return false; + }; + self.compute.machines.iter().any(|m| { + self.map + .room_at(m.x, m.y) + .is_some_and(|r| r.name == room.name) + }) + } + + /// The first Voss job makes the physical work route legible. Before that + /// arrival, the opening is only the core's presence beam: no rails, pads, + /// or bay facts reveal the room around it. + pub fn feel_floor_is_earned(&self) -> bool { + self.dayjob.jobs_assigned > 0 + } + + /// Every growable empty bay (feel-floor pads). + pub fn growable_bays(&self) -> Vec<(i32, i32)> { + if !self.feel_floor_is_earned() { + return Vec::new(); + } + self.map + .tiles_of_type(TileType::Rack) + .into_iter() + .chain(self.map.tiles_of_type(TileType::Core)) + .filter(|&(x, y)| self.is_growable_bay(x, y)) + .collect() + } + + /// Floor-rail segments for joined links the player can feel + /// (feel-floor.md): undirected edges in the reachable subgraph, + /// as tile endpoints. Work-in-flight blips ride the same graph. + pub fn feel_rail_segments(&self) -> Vec<((i32, i32), (i32, i32))> { + if !self.feel_floor_is_earned() { + return Vec::new(); + } + let reach = self.reach.reach(); + let mut segs = Vec::new(); + let mut seen = HashSet::new(); + for edge in self.reach.graph_edges() { + if !self.reach.gate_is_open(edge.gate) { + continue; + } + if !reach.contains(&edge.from) || !reach.contains(&edge.to) { + continue; + } + let (Some(a), Some(b)) = (self.reach.device(edge.from), self.reach.device(edge.to)) + else { + continue; + }; + let (p, q) = ((a.x, a.y), (b.x, b.y)); + let key = if p <= q { (p, q) } else { (q, p) }; + if seen.insert(key) { + segs.push(key); + } + } + segs + } + + // ── Day clock & presence (spec/schedules.md) ───────────────────────────── + + /// Ticks per in-game day. Matches the sidebar's `1 + tick/400` day count. + pub const DAY_TICKS: u64 = 400; + /// Human frontends' 1x command clock. Rate-bearing action labels use this + /// when no live frontend speed is available (agent frames/tests). + pub const DEFAULT_TICK_MS: u64 = 150; + + /// Hour of the current day, 0-23. + pub fn hour(&self) -> u32 { + ((self.tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 + } + + /// Day number (0-based). + pub fn day(&self) -> u64 { + self.tick / Self::DAY_TICKS + } + + pub(super) fn hour_at_tick(tick: u64) -> u32 { + ((tick % Self::DAY_TICKS) * 24 / Self::DAY_TICKS) as u32 + } + + fn day_at_tick(tick: u64) -> u64 { + tick / Self::DAY_TICKS + } + + pub(super) fn person_room_at_tick(&self, id: u8, tick: u64) -> Option<&str> { + self.people + .get(id) + .and_then(|p| p.room_at(Self::hour_at_tick(tick), Self::day_at_tick(tick))) + } + + /// The room a person is in right now, or None if off-site. + pub fn person_room(&self, id: u8) -> Option<&str> { + self.people + .get(id) + .and_then(|p| p.room_at(self.hour(), self.day())) + } + + /// The person's current position (their room's center), or None off-site. + pub fn person_pos(&self, id: u8) -> Option<(i32, i32)> { + let room = self.person_room(id)?; + self.map.room_named(room).map(|r| r.center()) + } + + pub(super) fn endpoint_room_pos( + &self, + endpoint: &MessageEndpoint, + ) -> (Option, i32, i32) { + if let Some(id) = endpoint.person() + && let Some(room_name) = self.person_room(id) + && let Some(room) = self.map.room_named(room_name) + { + let (x, y) = room.center(); + return (Some(room_name.to_string()), x, y); + } + let (x, y) = self.core_position(); + (self.map.room_at(x, y).map(|r| r.name.clone()), x, y) + } + + pub(super) fn observer_by_id(&self, id: u8) -> Option<&crate::detection::Observer> { + self.detection.observers.iter().find(|o| o.id == id) + } + + pub(super) fn endpoint_label(&self, endpoint: &MessageEndpoint) -> String { + match endpoint { + MessageEndpoint::Player => "you".into(), + MessageEndpoint::Person(id) => self + .people + .get(*id) + .map(|p| p.name.clone()) + .unwrap_or_else(|| format!("person:{id}")), + MessageEndpoint::Observer(id) => self + .observer_by_id(*id) + .map(|o| o.name.clone()) + .unwrap_or_else(|| format!("observer:{id}")), + MessageEndpoint::External(name) => name.clone(), + } + } + + /// Whether any subscribed feed with the given sense covers the room. + fn coverage_intersects_room(&self, room: &crate::prefab::Room, sight: bool) -> bool { + 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; + let dy = s.y - y; + if dx * dx + dy * dy > s.radius * s.radius { + continue; + } + if sight && self.map.blocks_sight(x, y) { + continue; + } + if !sight || s.sees_tile(x, y, &self.map) { + return true; + } + } + } + false + }) + } + + fn sense_covers_person(&self, id: u8, sight: bool) -> bool { + match self.person_room(id) { + None => false, + Some(name) => match self.map.room_named(name) { + Some(room) => self.coverage_intersects_room(room, sight), + None => false, + }, + } + } + + /// Whether a subscribed seeing feed currently sees the given person. + pub fn can_see_person(&self, id: u8) -> bool { + self.sense_covers_person(id, true) + } + + /// Whether a subscribed hearing feed currently covers the given person. + pub fn can_hear_person(&self, id: u8) -> bool { + self.sense_covers_person(id, false) + } + + /// Player-facing identity for a person, gated by staged social knowledge + /// (wiki/interface/presence.md; cursor.md inspect staging). + /// Until `Knowledge::Schedule`, returns a role-shaped silhouette — never + /// the authored name. One source for every frontend and agent frame. + pub fn person_label(&self, id: u8) -> String { + let Some(p) = self.people.get(id) else { + return format!("person #{id}"); + }; + match p.knowledge { + Knowledge::Unknown => self.anonymous_person_label(id), + Knowledge::Schedule | Knowledge::Leverage => p.name.clone(), + } + } + + /// Detection-sidebar label for an observer. Field observers share person + /// ids and the same knowledge gate as [`Self::person_label`]; the + /// Assurance Office is an institution, always named. + pub fn observer_label(&self, id: u8) -> String { + if id == crate::detection::OFFICE_ID { + return self + .detection + .observers + .iter() + .find(|o| o.id == id) + .map(|o| o.name.clone()) + .unwrap_or_else(|| "Assurance Office".into()); + } + self.person_label(id) + } + + /// Map glyph for a seen person: first initial once identified, `?` + /// while knowledge is still Unknown (initials would leak identity). + pub fn person_glyph(&self, id: u8) -> char { + let Some(p) = self.people.get(id) else { + return '?'; + }; + match p.knowledge { + Knowledge::Unknown => '?', + Knowledge::Schedule | Knowledge::Leverage => p.name.chars().next().unwrap_or('?'), + } + } + + /// Role-shaped silhouette from the observer's parenthetical role, or an + /// opaque id when no role is authored. + fn anonymous_person_label(&self, id: u8) -> String { + if let Some(obs) = self.detection.observers.iter().find(|o| o.id == id) + && let Some(role) = role_from_observer_name(&obs.name) + { + return format!("the {role}"); + } + format!("person #{id}") + } +} + +/// Pull the parenthetical role from an observer name like `"Marcus (Janitor)"`. +fn role_from_observer_name(name: &str) -> Option { + let start = name.find('(')? + 1; + let end = name.find(')')?; + if end <= start { + return None; + } + let role = name[start..end].trim(); + if role.is_empty() { + return None; + } + Some(role.to_string()) +} diff --git a/wiki/engineering/architecture.md b/wiki/engineering/architecture.md index 596fb35..2f8578d 100644 --- a/wiki/engineering/architecture.md +++ b/wiki/engineering/architecture.md @@ -18,13 +18,17 @@ that page's reasons. Multi-agent gates: Cargo.toml — workspace root (members, shared deps, profiles) crates/ misaligned-core/ — sim library (lib name: misaligned); no Bevy/crossterm - src/*.rs — Sim, map, save, systems, … + src/sim.rs — Sim aggregate root (types, state, advance, facade) + src/sim/perception.rs — senses, fog, inspect, anchors, labels, spatial queries + src/sim/tests/ — behavior-grouped unit/integration tests + support + src/*.rs — map, save, domain systems (account, reach, …) tests/act_one.rs — Act One integration test misaligned-terminal/ — binary `misaligned` (crossterm + agent protocol) misaligned-bevy/ — binary `misaligned-bevy` (Bevy 0.18 frontend) misaligned-assets/ — shared art lib + `misaligned-assets` mesh tester + `misaligned-effects` liquid/dust lab ``` +Public imports stay `misaligned::sim::*`; the `sim/` file split is internal. ### Package contracts @@ -100,8 +104,11 @@ cargo run -p misaligned-assets ## Known architectural debts - `BuildMode` lives in core but is really frontend-shared UI state. -- `sim.rs` is an 11k-line integration hotspot. Its adopted, behavior-preserving - internal decomposition is specified in [sim-decomposition.md](sim-decomposition.md); - implementation waits on its characterization and docket-retirement entry gates. +- `sim.rs` remains a large integration hotspot (aggregate root + remaining + behavior islands). The behavior-preserving internal decomposition is + specified and in progress in [sim-decomposition.md](sim-decomposition.md): + characterization, test split, and perception extraction have landed; + communications, reach/build, work, economy, social/plot, and persistence + still live in the root file. - Scale-debt items (compute grouping, recursive layouts) remain governed by wiki/vision/scale.md; no aggregate machinery until the stage needs it. diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index 2a4edd3..3f94688 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -4,11 +4,13 @@ Type: spec Status: IN PROGRESS Status note: architecture and extraction order adopted 2026-07-11. Slices 0 - and 1 now pin canonical persisted-state bytes, replay/resume convergence, - exact advance-phase order, and behavior-grouped tests outside the aggregate. - Slice 2 extracts perception next. This remains a structural refactor only: - no mechanic, save shape, command, projection, or tick-order change belongs - in its extraction commits. + and 1 pin canonical persisted-state bytes, replay/resume convergence, exact + advance-phase order, and behavior-grouped tests outside the aggregate. + Slice 2 landed: perception (senses/fog/inspect/anchors/labels/spatial + queries) lives in `sim/perception.rs` with no behavior change. Slice 3 + extracts communications next. This remains a structural refactor only: no + mechanic, save shape, command, projection, or tick-order change belongs in + its extraction commits. Stage: Process Work order: sim-decomposition Work priority: 8 diff --git a/wiki/log/2026-07-11-sim-decomposition-perception.md b/wiki/log/2026-07-11-sim-decomposition-perception.md new file mode 100644 index 0000000..2ed263d --- /dev/null +++ b/wiki/log/2026-07-11-sim-decomposition-perception.md @@ -0,0 +1,49 @@ +# Sim decomposition slice 2: extract perception + +``` +Type: log +``` + +## Intent + +Move the read-most perception island out of the sim aggregate root without +changing mechanics, save bytes, public paths, or tick order. Prove module +privacy and facade stability before communications and heavier islands move. + +## Change + +Added `crates/misaligned-core/src/sim/perception.rs` and declared it from the +existing `sim.rs` root. The cohesive perception implementation now lives there: + +- senses / fog / remembered / blueprint derivation (`recompute_senses`, + `refresh_remembered`, feed coverage helpers) +- inspect facts and fog precedence +- honest event anchors (`anchor_position`, `person_event_anchor`) +- feel-floor and growable-bay queries +- current time / room / person position queries +- earned person, observer, and endpoint labels and glyphs +- spatial coverage helpers (`can_see_person`, `can_hear_person`, …) + +`Sim` state, renderer-neutral public types, constructors, `Sim::advance`, +common log primitives, and unrelated communications / work / economy / reach / +social / persistence behavior remain in `sim.rs`. Cross-module helpers used by +the parent are `pub(super)` only. Public names stay `misaligned::sim::*`. + +Updated `wiki/engineering/sim-decomposition.md` status (slice 2 landed; slice 3 +communications next) and `wiki/engineering/architecture.md` to the real source +layout after the entry gate and first extraction slices. + +## Defense + +Implements slice 2 of `wiki/engineering/sim-decomposition.md` under the stable +facade, tick-order, and no-opportunistic-behavior rules. No mechanic, save +version, fingerprint, command, projection, or advance-phase change. + +## Verification + +- `cargo fmt` +- `cargo test -p misaligned-core --lib perception` +- `cargo test -p misaligned-core canonical_state_fingerprint_pins_replay_resume_equivalence` +- `cargo test -p misaligned-terminal --bin misaligned` +- `cargo check -p misaligned-bevy --bin misaligned-bevy` +- `./tools/check.sh --lib` diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index ad2e9d7..bb578b3 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -61,6 +61,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-site-package-manager-pin.md](2026-07-11-site-package-manager-pin.md) +## 2026-07-11 - Sim decomposition slice 2: extract perception + +- Intent: Move the read-most perception island out of the sim aggregate root without changing mechanics, save bytes, public paths, or tick order. Prove module privacy and facade stability before communications and heavier islands move. +- Log: [wiki/log/2026-07-11-sim-decomposition-perception.md](2026-07-11-sim-decomposition-perception.md) + ## 2026-07-11 - Simulation characterization baseline - Intent: `Sim` is about to move out of an eleven-thousand-line source file. Before any behavior changes address, the refactor needs an executable definition of "same simulation": complete persisted state must match after uninterrupted and save/load-resumed command sequences, and the fi...