diff --git a/src/reach.rs b/src/reach.rs index 3b03258a..e3a16959 100644 --- a/src/reach.rs +++ b/src/reach.rs @@ -106,20 +106,51 @@ impl Device { self.message_channels.contains(&channel) } - /// Insert every tile of this device's coverage disc into `out`. - /// Hearing uses this broad room-grade coverage; sight uses - /// [`Device::cover_sight_into`] so cameras do not see through walls. + /// 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) { - 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; + 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)); + } } - if dx * dx + dy * dy > self.radius * self.radius { + } + } + // 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; } - out.insert((tx, ty)); + frontier.push((nx, ny)); } } } @@ -580,6 +611,32 @@ mod tests { 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(); diff --git a/wiki/log/2026-07-08-hearing-room-grade.md b/wiki/log/2026-07-08-hearing-room-grade.md new file mode 100644 index 00000000..647012d8 --- /dev/null +++ b/wiki/log/2026-07-08-hearing-room-grade.md @@ -0,0 +1,17 @@ +# 2026-07-08 - Hearing coverage is room-grade, not a disc + +``` +Type: log +``` + +Cameron's screenshot: the Heard fog tint spilled outside the room into +void and through walls. Cause: `Device::cover_into` painted the raw +Euclidean radius disc, ignoring the map, while heard *events* were +room-grained via `feed_covering_room`. The 5a16ad8 occlusion fix only +covered the sight channel. + +Fix: `cover_into` now paints whole rooms the coverage disc reaches (the +exact room set the event path uses, so tint == event truth) plus a +wall-bounded flood for open space outside rooms. Regression test +`hearing_coverage_is_room_grade_never_raw_disc` asserts no heard tile +is a solid tile outside any room even at radius 100. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 94ea6eab..b0ee67f3 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,13 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-08 - Hearing fog is room-grade (no disc through walls) + +- Playtest screenshot finding: Heard tint spilled past walls. cover_into + now paints rooms the disc reaches + wall-bounded open space, matching + feed_covering_room's event truth; regression test added. cursor.md + hearing bullet updated. Log: wiki/log/2026-07-08-hearing-room-grade.md. + ## 2026-07-08 — Playtest sweep (docs only) - Intent: play the current build (agent mode, seed 7) as a naive then informed diff --git a/wiki/mechanics/cursor.md b/wiki/mechanics/cursor.md index 6e8898ff..a6a44f6d 100644 --- a/wiki/mechanics/cursor.md +++ b/wiki/mechanics/cursor.md @@ -64,7 +64,12 @@ The player's map presence is a **cursor** — attention, not an avatar. by physical line of sight. Full live detail inside; none outside. A ray may reveal the near face of an opaque tile (wall, closed door, sealed/roll door) but never tiles or people behind it. -- **Hearing** = the union of subscribed hearing-sensors' coverage. +- **Hearing** = the union of subscribed hearing-sensors' coverage, + **room-grade in space as well as in events** (fixed 2026-07-08: the + fog tint previously painted the raw radius disc through walls; it now + paints whole rooms the disc reaches — identical to the room set + `feed_covering_room` uses for events — plus wall-bounded open space, + so the tint and the event coverage never disagree). Yields **room-grade events**, not pictures: entry/exit, machinery state, conversations. A heard person surfaces per staged knowledge (social.md) — an unidentified presence until knowledge identifies