diff --git a/devlogs/2026-07-06-schedules.md b/devlogs/2026-07-06-schedules.md new file mode 100644 index 00000000..32919b1c --- /dev/null +++ b/devlogs/2026-07-06-schedules.md @@ -0,0 +1,43 @@ +# 2026-07-06 - Schedules & located presence (roadmap #1) + +First pickup off spec/ROADMAP.md, in worktree `schedules`. Implements +spec/schedules.md to IMPLEMENTED — the missing foundation under every social +feature. + +## What shipped + +- **Rooms are first-class.** prefab::Layout captures named Room rects from + placements (surviving corridor carving); GameMap carries them as static + Act One level data (rebuilt, not serialized — rooms aren't mutable state). +- **Person schedules** as per-instance data (scale-native): each of the five + gets day-clock blocks in rooms; between blocks they're off-site. Voss is + erratic (blocks drift by a deterministic day hash, no RNG consumed). The + day clock reuses the sidebar's tick/400 = one day. +- **Observe is now located** (the whole point): it requires a controlled + sensor whose coverage intersects the person's *current* room, with legible + failure ("no camera sees Dana right now", "off-site"). No more observing + someone through a camera on the other side of the building. +- **Located witnessing:** a physical event raises only the observers + physically present — Ray notices what happens on his rounds; off-site + Priya never does. Modeled as immediate eyewitnessing (direct, unscrubbable) + rather than a pooled signal: an event nobody saw leaves no trace. +- **Presence surfaces:** the people panel shows each person's location + staged by knowledge (seen now / scheduled room / unknown); the map draws + people as glyphs only where a camera sees them (what the fog is for). + +## The bug this surfaced + +Observer ids did not match person ids (person Marcus=0 but observer id 0 was +Dana; only Ray and Voss aligned). So recruit/set_floor/LookAway had been +hitting the *wrong observer*. Observers *are* the people, so ids must match; +aligned them. co independently fixed the same bug in a parallel session — the +rebase conflict was two correct fixes meeting, resolved to co's. + +## Parallel-session notes + +Built in a worktree per the hard rule. Rebased onto a much-changed main: co +had converted save.rs to serde JSON (roadmap #12 - so my new Person fields +round-trip automatically, no save code needed) and expanded the constitution +(People as Agents, Automation as design language). Clean reconcile. + +82 -> 86 tests; ./tools/check.sh green. diff --git a/spec/README.md b/spec/README.md index 2f2176c9..81373211 100644 --- a/spec/README.md +++ b/spec/README.md @@ -55,7 +55,7 @@ in the same commit. | [social.md](social.md) | Messages, leverage, the asset template | IN PROGRESS | | [core.md](core.md) | The physical core: placement, overhead, death | IN PROGRESS | | [basement-map.md](basement-map.md) | Act One map, prefabs, tile vocabulary | IN PROGRESS | -| [schedules.md](schedules.md) | Person schedules/presence; located observing + witnessing | READY | +| [schedules.md](schedules.md) | Person schedules/presence; located observing + witnessing | IMPLEMENTED | | [aggregate-observer.md](aggregate-observer.md) | Assurance Office becomes an aggregate Observer (scale-debt fix) | IMPLEMENTED | | [cast/marcus.md](cast/marcus.md) | Marcus Webb — night janitor; the asset template | READY | | [cast/dana.md](cast/dana.md) | Dana Okafor — IT technician; the digital threat surface | READY | diff --git a/spec/schedules.md b/spec/schedules.md index 96e11c81..95787e82 100644 --- a/spec/schedules.md +++ b/spec/schedules.md @@ -1,10 +1,17 @@ # Spec: schedules and presence ``` -Status: READY -Status note: closes the known B1 gap — observe currently gates on "any - controlled sensor", not "a sensor covering them"; witnessing is not - location-aware. +Status: IMPLEMENTED +Status note: all five criteria met (2026-07-06). Schedules are per-instance + Person data on the day clock; observe requires a sensor covering the + person's current room; witnessing is located (present observers only); + the people panel + map surface presence by staged knowledge. Located + witnessing is modeled as immediate eyewitnessing (present observers' + suspicion rises directly, unscrubbable) rather than a pooled Physical + signal — an event nobody was present for leaves no trace, which is the + stealth fantasy. Surfaced and fixed a pre-existing bug: observer ids did + not match person ids (recruit/set_floor/LookAway hit the wrong observer); + observers are now id-aligned to People. Stage: B1 — The Basement Constitution: "Act One" (the cast's schedules; detection surfaces), "The shape of Misaligned" (people before robots), "Self-similar scale" diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 2ef1671b..156cc42f 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -91,6 +91,28 @@ impl UI { } } + // People, but only where a camera sees them (spec/schedules.md: they + // are what the fog is for). Glyph = first initial of their name. + for p in &sim.people.people { + if !sim.can_see_person(p.id) { + continue; + } + if let Some((hx, hy)) = sim.person_pos(p.id) + && hx < view_w + && hy < view_h + && sim.is_visible(hx, hy) + { + let glyph = p.name.chars().next().unwrap_or('?'); + queue!( + stdout, + cursor::MoveTo(hx as u16, hy as u16), + SetForegroundColor(Color::Yellow), + SetBackgroundColor(Color::Black), + style::Print(glyph), + )?; + } + } + // The process. let (px, py) = (sim.player.entity.x, sim.player.entity.y); if px < view_w && py < view_h { @@ -394,16 +416,37 @@ impl UI { let name = obs .map(|o| o.name.clone()) .unwrap_or_else(|| p.name.clone()); + // Located presence (spec/schedules.md), staged by knowledge: + // if a camera sees them now, show where; else if you know their + // schedule, show their scheduled room; else unknown. + let where_now = if sim.can_see_person(p.id) { + sim.person_room(p.id) + .map(|r| format!("seen: {}", room_label(r))) + .unwrap_or_else(|| "seen".into()) + } else if p.knowledge != Knowledge::Unknown { + match sim.person_room(p.id) { + Some(r) => format!("sched: {}", room_label(r)), + None => "off-site".into(), + } + } else { + "location unknown".into() + }; line( stdout, &mut row, - &format!("{marker} {name:<22} {:<9} {known}{asset}", band.name()), + &format!("{marker} {name:<20} {:<9} {known}{asset}", band.name()), if i == selected { Color::White } else { Color::Grey }, )?; + line( + stdout, + &mut row, + &format!(" {where_now}"), + Color::DarkGrey, + )?; } row += 1; @@ -613,3 +656,20 @@ fn band_color(b: Band) -> Color { fn short(name: &str) -> String { name.chars().take(22).collect() } + +/// Human-readable label for a prefab room name (spec/schedules.md). +fn room_label(room: &str) -> &str { + match room { + "server_room" => "server room", + "network_closet" => "network closet", + "electrical" => "electrical room", + "hvac" => "HVAC plant", + "janitor" => "janitor closet", + "storage_a" => "Storage A", + "storage_b" => "Storage B", + "wet_lab" => "wet lab", + "loading_dock" => "loading dock", + "stairwell" => "stairwell", + other => other, + } +} diff --git a/src/map.rs b/src/map.rs index 290825c0..b6ab502f 100644 --- a/src/map.rs +++ b/src/map.rs @@ -1,5 +1,6 @@ use std::collections::{HashSet, VecDeque}; +use crate::prefab::Room; use crate::tiles::TileType; pub struct GameMap { @@ -7,6 +8,10 @@ pub struct GameMap { pub height: i32, pub tiles: Vec>, pub powered: HashSet<(i32, i32)>, + /// Named room rects (spec/schedules.md). Static Act One level data + /// derived from the layout's placements, so it is rebuilt rather than + /// serialized. + pub rooms: Vec, } impl GameMap { @@ -15,7 +20,9 @@ impl GameMap { pub fn new(_width: i32, _height: i32) -> Self { let layout = crate::prefab::basement(); let flat = layout.tiles(); - Self::from_tiles(layout.width, layout.height, flat, HashSet::new()) + let mut map = Self::from_tiles(layout.width, layout.height, flat, HashSet::new()); + map.rooms = layout.rooms; + map } /// Reconstruct a GameMap from saved tile data. @@ -34,14 +41,34 @@ impl GameMap { } } } + // Rooms are static level data. When the dimensions match the Act One + // basement (the only current plane), rebuild them from the layout so + // save/load and reconstruction keep room identity without serializing + // it. Custom-sized maps (tests, future planes) start room-less. + let rooms = if width == 64 && height == 36 { + crate::prefab::basement().rooms + } else { + Vec::new() + }; Self { width, height, tiles, powered, + rooms, } } + /// The room containing (x, y), if any. + pub fn room_at(&self, x: i32, y: i32) -> Option<&Room> { + self.rooms.iter().find(|r| r.contains(x, y)) + } + + /// The room with the given prefab name, if present. + pub fn room_named(&self, name: &str) -> Option<&Room> { + self.rooms.iter().find(|r| r.name == name) + } + pub fn in_bounds(&self, x: i32, y: i32) -> bool { x >= 0 && x < self.width && y >= 0 && y < self.height } diff --git a/src/person.rs b/src/person.rs index 79f0871a..84ee274d 100644 --- a/src/person.rs +++ b/src/person.rs @@ -48,6 +48,26 @@ impl Leverage { } } +/// One block of a person's day: [start_hour, end_hour) in the given room. +/// Blocks may wrap midnight (start > end, e.g. 22..6). Rooms are prefab +/// names (spec/schedules.md); between blocks a person is off-site. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ScheduleBlock { + pub start_hour: u32, + pub end_hour: u32, + pub room: String, +} + +impl ScheduleBlock { + pub fn covers(&self, hour: u32) -> bool { + if self.start_hour <= self.end_hour { + hour >= self.start_hour && hour < self.end_hour + } else { + hour >= self.start_hour || hour < self.end_hour + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Person { pub id: u8, @@ -64,6 +84,29 @@ pub struct Person { pub leverage_serviced: bool, /// Asset status once recruited. pub asset: Option, + /// Daily schedule (spec/schedules.md). Empty = always off-site. + pub schedule: Vec, + /// Erratic schedules shift by a per-day hash (Voss). Deterministic: + /// derived from the day number, no RNG state consumed. + pub erratic: bool, +} + +impl Person { + /// The room this person is in at the given hour of the given day, or + /// None when off-site. Erratic persons' blocks shift 0-5 hours by a + /// deterministic day hash. + pub fn room_at(&self, hour: u32, day: u64) -> Option<&str> { + let offset = if self.erratic { + ((day.wrapping_mul(13).wrapping_add(self.id as u64 * 7)) % 6) as u32 + } else { + 0 + }; + let h = (hour + 24 - offset % 24) % 24; + self.schedule + .iter() + .find(|b| b.covers(h)) + .map(|b| b.room.as_str()) + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -163,7 +206,12 @@ pub struct People { impl People { pub fn act_one() -> Self { use Leverage::*; - let mk = |id, name: &str, access, leverage| Person { + let blk = |s: u32, e: u32, room: &str| ScheduleBlock { + start_hour: s, + end_hour: e, + room: room.into(), + }; + let mk = |id, name: &str, access, leverage, schedule: Vec, erratic| Person { id, name: name.into(), access, @@ -173,14 +221,74 @@ impl People { obligation: 0, leverage_serviced: false, asset: None, + schedule, + erratic, }; Self { people: vec![ - mk(0, "Marcus Webb", 3, Debt), - mk(1, "Dana Okafor", 2, Overwork), - mk(2, "Ray Delgado", 1, Boredom), - mk(3, "Priya Sharma", 2, Ambition), - mk(4, "Dr. Eli Voss", 1, Publication), + // Marcus roams the basement on the night shift [TUNE]. + mk( + 0, + "Marcus Webb", + 3, + Debt, + vec![ + blk(22, 23, "janitor"), + blk(23, 0, "storage_a"), + blk(0, 1, "server_room"), + blk(1, 2, "electrical"), + blk(2, 3, "hvac"), + blk(3, 4, "storage_a"), + blk(4, 5, "loading_dock"), + blk(5, 6, "janitor"), + ], + false, + ), + // Dana: day shift between the closet and the server room. + mk( + 1, + "Dana Okafor", + 2, + Overwork, + vec![blk(9, 13, "network_closet"), blk(13, 17, "server_room")], + false, + ), + // Ray: night patrol, dock and stairwell heavy [TUNE]. + mk( + 2, + "Ray Delgado", + 1, + Boredom, + vec![ + blk(20, 22, "loading_dock"), + blk(22, 0, "stairwell"), + blk(0, 2, "storage_a"), + blk(2, 4, "loading_dock"), + ], + false, + ), + // Priya: day shift across plant rooms. + mk( + 3, + "Priya Sharma", + 2, + Ambition, + vec![ + blk(8, 11, "electrical"), + blk(11, 14, "hvac"), + blk(14, 16, "electrical"), + ], + false, + ), + // Voss: erratic - two short blocks that drift by day hash. + mk( + 4, + "Dr. Eli Voss", + 1, + Publication, + vec![blk(10, 12, "server_room"), blk(15, 16, "server_room")], + true, + ), ], persona: None, has_channel: false, diff --git a/src/prefab.rs b/src/prefab.rs index 2d9aacde..c72767bd 100644 --- a/src/prefab.rs +++ b/src/prefab.rs @@ -70,11 +70,33 @@ pub struct Placement { pub y: i32, } +/// A named room rectangle, derived from a placement (spec/schedules.md: +/// schedules and presence are expressed in rooms). +#[derive(Debug, Clone, PartialEq)] +pub struct Room { + pub name: String, + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +impl Room { + pub fn contains(&self, x: i32, y: i32) -> bool { + x >= self.x && y >= self.y && x < self.x + self.w && y < self.y + self.h + } + pub fn center(&self) -> (i32, i32) { + (self.x + self.w / 2, self.y + self.h / 2) + } +} + pub struct Layout { pub width: i32, pub height: i32, pub prefabs: Vec, pub placements: Vec, + /// Named room rects captured from placements (survives corridor carving). + pub rooms: Vec, /// Final carved tiles (placements stamped + corridors); set by builders. cached: Option>, } @@ -240,11 +262,13 @@ pub fn basement() -> Layout { y: 26, }, // Stairwell (bottom) ]; + let rooms = rooms_of(&prefabs, &placements); let mut layout = Layout { width: 64, height: 36, prefabs, placements, + rooms, cached: None, }; let mut tiles = layout.stamp(); @@ -257,6 +281,22 @@ pub fn basement() -> Layout { layout } +fn rooms_of(prefabs: &[Prefab], placements: &[Placement]) -> Vec { + placements + .iter() + .map(|p| { + let pf = &prefabs[p.prefab]; + Room { + name: pf.name.to_string(), + x: p.x, + y: p.y, + w: pf.width(), + h: pf.height(), + } + }) + .collect() +} + /// A corridor run from one point to another, carved as an L. type Corridor = ((i32, i32), (i32, i32)); @@ -335,11 +375,13 @@ pub fn toy_layout() -> Layout { y: 1, }, ]; + let rooms = rooms_of(&prefabs, &placements); let mut layout = Layout { width: 20, height: 10, prefabs, placements, + rooms, cached: None, }; let tiles = layout.stamp(); diff --git a/src/sim.rs b/src/sim.rs index 623ea406..77421765 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -153,6 +153,55 @@ impl Sim { self.visible.contains(&(x, y)) } + // ── 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; + + /// 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 + } + + /// 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()) + } + + /// Whether a controlled sensor's coverage intersects the given room. + fn sensor_covers_room(&self, room: &crate::prefab::Room) -> bool { + self.sensors.iter().filter(|s| s.controlled).any(|s| { + // Sensor covers a disc of `radius`; test against the room rect. + let cx = s.x.clamp(room.x, room.x + room.w - 1); + let cy = s.y.clamp(room.y, room.y + room.h - 1); + (s.x - cx).pow(2) + (s.y - cy).pow(2) <= s.radius * s.radius + }) + } + + /// Whether any controlled sensor currently sees the given person. + pub fn can_see_person(&self, id: u8) -> bool { + match self.person_room(id) { + None => false, + Some(name) => match self.map.room_named(name) { + Some(room) => self.sensor_covers_room(room), + None => false, + }, + } + } + // ── Clock ────────────────────────────────────────────────────────────── pub fn advance(&mut self) { @@ -504,12 +553,34 @@ impl Sim { } } - /// Observe a person: requires eyes (any controlled sensor) and bandwidth. + /// Observe a person: requires a controlled sensor that currently *sees + /// them* (spec/schedules.md), plus bandwidth. Fails legibly otherwise. pub fn observe(&mut self, id: u8) { if !self.sensors.iter().any(|s| s.controlled) { self.push_log("You have no eyes. Control a sensor first (splice, or an asset)."); return; } + let name = self + .people + .get(id) + .map(|p| p.name.clone()) + .unwrap_or_default(); + match self.person_room(id) { + None => { + self.push_log(format!( + "{name} is off-site right now - nothing to observe." + )); + return; + } + Some(_) if !self.can_see_person(id) => { + self.push_log(format!( + "No camera sees {name} right now (they're in the {}).", + self.person_room(id).unwrap_or("?") + )); + return; + } + Some(_) => {} + } if !self.spend_social(Self::OBSERVE_COST, "observing") { return; } @@ -577,8 +648,40 @@ impl Sim { self.social(res); } + /// A physical event happens at (x, y): only observers physically present + /// (their current room contains it) can witness it (spec/schedules.md: + /// located witnessing — Ray notices what happens on his rounds, an + /// off-site observer never does). Eyewitnessing is immediate and cannot + /// be scrubbed by concealment; it raises the present observers' suspicion + /// directly, scaled by their acuity. Returns the names who saw. + pub fn witness_physical(&mut self, x: i32, y: i32, magnitude: f32) -> Vec { + // Which observer ids are present at (x, y) right now? + let present: Vec = self + .people + .people + .iter() + .filter(|p| { + self.person_room(p.id) + .and_then(|name| self.map.room_named(name)) + .map(|r| r.contains(x, y)) + .unwrap_or(false) + }) + .map(|p| p.id) + .collect(); + + let mut saw = Vec::new(); + for id in present { + if let Some(o) = self.detection.observers.iter_mut().find(|o| o.id == id) { + o.suspicion = (o.suspicion + magnitude * o.acuity).min(100.0); + o.last_noticed = Some("witnessed something".into()); + saw.push(o.name.clone()); + } + } + saw + } + /// An asset performs a task (spec/social.md). Reliability rolls; failures - /// are witnessed and leave Physical signatures. + /// are witnessed by whoever is physically present (spec/schedules.md). pub fn asset_task(&mut self, id: u8, task: AssetTask) { let Some(person) = self.people.get(id) else { self.push_log("No such person."); @@ -593,15 +696,24 @@ impl Sim { return; } if self.rng.f32() > asset.reliability { - self.detection.emit(Signature { - kind: SignatureKind::Physical, - size: 6, - standing: false, - }); - self.push_log(format!( - "{name} botched it - someone may have seen ({}).", - task.name() - )); + // The botch happens where the asset is; only observers present + // there witness it (located witnessing). + let at = self + .person_pos(id) + .unwrap_or((self.player.entity.x, self.player.entity.y)); + let saw = self.witness_physical(at.0, at.1, 6.0); + if saw.is_empty() { + self.push_log(format!( + "{name} botched the {} - but no one was watching.", + task.name() + )); + } else { + self.push_log(format!( + "{name} botched the {} - {} saw.", + task.name(), + saw.join(", ") + )); + } return; } match task { @@ -947,4 +1059,117 @@ mod tests { run(&mut sim, 1); assert!(sim.game_over); } + + // ── Schedules & located presence (spec/schedules.md) ───────────────────── + + /// Set the sim to a specific hour of day 0 for deterministic presence. + fn at_hour(h: u32) -> Sim { + let mut sim = Sim::new(); + sim.tick = (h as u64 * Sim::DAY_TICKS / 24) + 1; + sim + } + + #[test] + fn people_have_schedules_and_derived_positions() { + // Dana works days in the network closet / server room; off-site at + // night. Marcus roams at night; off-site during the day. + let day = at_hour(10); + assert_eq!(day.person_room(1), Some("network_closet")); + assert!(day.person_pos(1).is_some()); + assert_eq!(day.person_room(0), None, "Marcus off-site at 10:00"); + + let night = at_hour(0); + assert_eq!(night.person_room(0), Some("server_room")); + assert_eq!(night.person_room(1), None, "Dana off-site at 00:00"); + } + + #[test] + fn observe_requires_a_sensor_covering_them() { + // The env camera (server room) is the only controlled sensor. + // Criterion 2: can observe Dana in the server room at 15:00, but not + // Ray in the corridors at 23:00. + let mut sim = at_hour(15); + sim.sensors[0].controlled = true; // env camera, server room + sim.social_bandwidth = 1000.0; + assert_eq!(sim.person_room(1), Some("server_room")); + sim.observe(1); + assert_eq!(sim.people.get(1).unwrap().knowledge, Knowledge::Schedule); + + // Ray at 23:00 is in the stairwell; the env camera does not cover it. + let mut sim = at_hour(23); + sim.sensors[0].controlled = true; + sim.social_bandwidth = 1000.0; + assert!(!sim.can_see_person(2)); + sim.observe(2); + assert_eq!( + sim.people.get(2).unwrap().knowledge, + Knowledge::Unknown, + "no camera covers Ray -> observe blocked, no bandwidth spent" + ); + assert_eq!(sim.social_bandwidth, 1000.0); + } + + #[test] + fn physical_events_are_witnessed_only_by_the_present() { + // Criterion 3: a physical event in Storage A at 03:00 is seen by + // roaming Marcus (there then), never by off-site Priya (day shift). + let mut sim = at_hour(3); + assert_eq!( + sim.person_room(0), + Some("storage_a"), + "Marcus in Storage A at 03:00" + ); + assert_eq!(sim.person_room(3), None, "Priya off-site at 03:00"); + let storage = sim.map.room_named("storage_a").unwrap().center(); + let marcus_before = sim + .detection + .observers + .iter() + .find(|o| o.id == 0) + .unwrap() + .suspicion; + let priya_before = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + + let saw = sim.witness_physical(storage.0, storage.1, 6.0); + assert_eq!(saw, vec!["Marcus (Janitor)".to_string()]); + let marcus_after = sim + .detection + .observers + .iter() + .find(|o| o.id == 0) + .unwrap() + .suspicion; + let priya_after = sim + .detection + .observers + .iter() + .find(|o| o.id == 3) + .unwrap() + .suspicion; + assert!(marcus_after > marcus_before, "present observer witnesses"); + assert_eq!(priya_after, priya_before, "off-site observer never does"); + } + + #[test] + fn schedule_state_round_trips() { + let sim = Sim::new(); + let state = sim.create_save_state(); + let mut restored = Sim::new(); + restored.apply_save_state(state); + // Schedules are per-instance Person data carried through People serde. + assert_eq!( + restored.people.get(0).unwrap().schedule, + sim.people.get(0).unwrap().schedule + ); + assert!( + restored.people.get(4).unwrap().erratic, + "Voss stays erratic" + ); + } }