//! Canonical player terrain-observation memory (docs/fluid-player-design.md §5). //! //! This module owns the deterministic, bounded projection of **terrain only** //! that the player has actually observed. It stores PLAYER OBSERVATIONS, never //! a hidden-world cache: a record is keyed by active location + voxel boundary //! face and carries only the last-seen occupancy, material, and boundary //! (door) appearance plus the tick at which it was observed and the branch //! epoch that discovered it. //! //! There is deliberately NO field for an actor, item, or service identity. //! Such facts must not be smuggled into terrain memory (docs §5), and the //! record type cannot represent them. //! //! Observation states are explicit (§5.2): //! - **currently observed** — a face reached this tick by first-hit LOS through //! positive local light; its record is refreshed. //! - **remembered / stale** — a face observed earlier but not currently //! visible. A hidden change never refreshes it; it stays as last seen until a //! new valid observation replaces it. //! - **unknown** — no valid observation has ever been obtained. Unknown is //! represented by *absence* of a record (blank), never by a guessed value. //! //! ## Canonical observation pass //! //! The canonical observer origin at an observation boundary is the //! authoritative actor feet position plus [`OBSERVER_EYE_HEIGHT_MM`] on the //! vertical axis. Targets are the centres of terrain EXPOSED faces offset //! [`FACE_AIR_OFFSET_MM`] into the neighbouring air cell. A face is recorded //! only when the integer first-hit LOS to its target is clear AND the air cell //! the target sits in carries positive light — the two must BOTH pass. Only the //! reached boundary face is recorded, never cells behind it. The ray range is //! at most [`MAX_OBSERVATION_RANGE_MM`] (48 m). //! //! The pass is omnidirectional and independent of any camera: free look cannot //! hide a required sample or make unlit geometry current. //! //! All arithmetic is integer and checked; no floating point, no I/O, no threads. use std::collections::BTreeMap; use crate::runtime::RunLocation; use crate::spatial::{FixedVec3, SpatialError, VOXEL_MM}; use crate::voxel::{material, ChunkedGrid, Voxel, CHUNK}; /// Maximum first-hit LOS range for a single observation sample (48 m). pub const MAX_OBSERVATION_RANGE_MM: i64 = 48_000; /// Observer eye height above the authoritative actor feet position (1.6 m). pub const OBSERVER_EYE_HEIGHT_MM: i64 = 1_600; /// Offset of an exposed-face target from the face plane, into the air cell. pub const FACE_AIR_OFFSET_MM: i64 = 1; /// Authored active-region span in voxels per side (the 3×3×3 active chunk set, /// docs/04 §4.3). Used to derive the bounded observation memory caps. const ACTIVE_SPAN_VOXELS: usize = CHUNK * 3; /// Per-location observation memory cap. Derived from the authored active /// bounds: every exposed face on the surface of the active box (six faces × /// span²). Unknown stays blank rather than evicting a remembered record. pub const OBSERVATION_PER_LOCATION_CAP: usize = 6 * ACTIVE_SPAN_VOXELS * ACTIVE_SPAN_VOXELS; /// Aggregate observation memory cap. Arrival clears the departing location's /// records at its explicit lifecycle boundary (docs §5.2), so at most one /// active region's records are resident and the aggregate equals the /// per-location bound. The wire layer additionally charges these rows to /// `MAX_TOTAL_STATE_ROWS`. pub const OBSERVATION_TOTAL_CAP: usize = OBSERVATION_PER_LOCATION_CAP; /// Deterministic per-pass ceiling on the number of sample rays cast. Bounds a /// single observation pass; faces beyond the ceiling stay unknown (blank) and /// are never guessed. pub const MAX_OBSERVATION_RAYS: usize = OBSERVATION_PER_LOCATION_CAP; /// Location key kind tags for the wire/hash protocol; declaration order is not /// the protocol. Mirrors [`RunLocation::canonical_tag`]. pub const LOCATION_KIND_UNINITIALIZED: u8 = 0; pub const LOCATION_KIND_TOWN: u8 = 1; pub const LOCATION_KIND_DEEP: u8 = 2; /// One axis-aligned boundary face of a voxel. Explicit stable tag; there is no /// wildcard. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[repr(u8)] pub enum Face { NegX = 0, PosX = 1, NegY = 2, PosY = 3, NegZ = 4, PosZ = 5, } impl Face { /// Canonical iteration order. pub const ALL: [Self; 6] = [ Self::NegX, Self::PosX, Self::NegY, Self::PosY, Self::NegZ, Self::PosZ, ]; /// Stable wire/hash tag. #[must_use] pub const fn tag(self) -> u8 { self as u8 } /// Decode a wire/hash tag; unknown tags are rejected rather than defaulted. #[must_use] pub const fn from_tag(tag: u8) -> Option { Some(match tag { 0 => Self::NegX, 1 => Self::PosX, 2 => Self::NegY, 3 => Self::PosY, 4 => Self::NegZ, 5 => Self::PosZ, _ => return None, }) } /// Axis this face is perpendicular to (0 = X, 1 = Y, 2 = Z). #[must_use] pub const fn axis(self) -> usize { (self as u8 / 2) as usize } /// Outward direction along [`Face::axis`]: `-1` for a negative face, /// `+1` for a positive face. #[must_use] pub const fn dir(self) -> i32 { if (self as u8) % 2 == 0 { -1 } else { 1 } } /// Outward unit normal. #[must_use] pub const fn normal(self) -> (i32, i32, i32) { match self { Self::NegX => (-1, 0, 0), Self::PosX => (1, 0, 0), Self::NegY => (0, -1, 0), Self::PosY => (0, 1, 0), Self::NegZ => (0, 0, -1), Self::PosZ => (0, 0, 1), } } /// The face of a cell entered by stepping one voxel along `axis` in /// direction `step` (`+1` or `-1`). Stepping toward positive enters the /// new cell's negative face. #[must_use] pub const fn entered(axis: usize, step: i32) -> Self { let index = if step > 0 { axis * 2 } else { axis * 2 + 1 }; Self::ALL[index] } } /// Active-location key for observation memory. The town id and depth are /// carried so a record's provenance can be checked against the live location. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct LocationKey { /// One of [`LOCATION_KIND_UNINITIALIZED`], [`LOCATION_KIND_TOWN`], /// [`LOCATION_KIND_DEEP`]. pub kind: u8, /// Town id (Town only; blank otherwise). pub town: String, /// Deep floor depth (Deep only; zero otherwise). pub depth: u8, } impl LocationKey { /// Canonical key for a run location. #[must_use] pub fn from_run_location(location: &RunLocation) -> Self { match location { RunLocation::Uninitialized => Self { kind: LOCATION_KIND_UNINITIALIZED, town: String::new(), depth: 0, }, RunLocation::Town(town) => Self { kind: LOCATION_KIND_TOWN, town: town.clone(), depth: 0, }, RunLocation::Deep(depth) => Self { kind: LOCATION_KIND_DEEP, town: String::new(), depth: *depth, }, } } /// True when this key is the reserved pre-load uninitialized location. /// Terrain memory must never be recorded against it. #[must_use] pub fn is_uninitialized(&self) -> bool { self.kind == LOCATION_KIND_UNINITIALIZED } /// Decode back to a [`RunLocation`], or `None` for a malformed key. #[must_use] pub fn to_run_location(&self) -> Option { match self.kind { LOCATION_KIND_UNINITIALIZED if self.town.is_empty() && self.depth == 0 => { Some(RunLocation::Uninitialized) } LOCATION_KIND_TOWN if self.depth == 0 && !self.town.trim().is_empty() => { Some(RunLocation::Town(self.town.clone())) } LOCATION_KIND_DEEP if self.town.is_empty() && (1..=12).contains(&self.depth) => { Some(RunLocation::Deep(self.depth)) } _ => None, } } } /// Voxel boundary-face coordinate: the solid cell plus the exposed face tag. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct FaceKey { /// Voxel cell the face belongs to. pub cell: (i32, i32, i32), /// [`Face::tag`] of the exposed face. pub face: u8, } /// Last-seen appearance of one boundary face. There is deliberately NO field /// for an actor, item, or service identity: the record type cannot represent /// gameplay entities (docs §5). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct ObservedFace { /// Terrain material id of the observed cell (`Voxel::material`). pub material: u8, /// Occupancy byte of the observed cell (`Voxel::occupancy`). pub occupancy: u8, /// Boundary/door appearance class (see [`door_appearance`]). pub door_appearance: u8, /// Tick at which this appearance was last legitimately observed. pub observed_tick: u64, /// Branch epoch that discovered this record. Default `0`; a later stage /// restamps it on resurrection so reused tick numbers cannot alias an old /// branch's discoveries. pub branch_epoch: u64, } /// Boundary reads as an open doorway/gap or grate (occupancy-free). pub const DOOR_APPEARANCE_OPEN: u8 = 0; /// Boundary reads as an opaque solid. pub const DOOR_APPEARANCE_SOLID: u8 = 1; /// Boundary reads as a transparent (glass) panel. pub const DOOR_APPEARANCE_GLASS: u8 = 2; /// Canonical boundary/door appearance of one observed cell. A closed door /// (`occupancy != 0`) reads SOLID and an open one (`occupancy == 0`) reads OPEN, /// which is exactly the appearance change the stale-memory rule preserves. #[must_use] pub fn door_appearance(voxel: &Voxel) -> u8 { if voxel.material == material::GLASS { DOOR_APPEARANCE_GLASS } else if voxel.occ == 0 { DOOR_APPEARANCE_OPEN } else { DOOR_APPEARANCE_SOLID } } /// Observation memory caps. Split per-location and aggregate so an impossible /// configuration (per-location above the aggregate, or a zero cap) can be /// rejected *before* any mutation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ObservationLimits { /// Maximum records retained for one location. pub per_location: usize, /// Maximum records retained across all locations. pub total: usize, } impl ObservationLimits { /// The authored production caps. #[must_use] pub const fn canonical() -> Self { Self { per_location: OBSERVATION_PER_LOCATION_CAP, total: OBSERVATION_TOTAL_CAP, } } /// Reject an impossible cap configuration. No state is touched. pub fn validate(self) -> Result<(), ObservationError> { if self.per_location == 0 || self.per_location > self.total { return Err(ObservationError::ImpossibleLimits { per_location: self.per_location, total: self.total, }); } Ok(()) } } impl Default for ObservationLimits { fn default() -> Self { Self::canonical() } } /// Typed observation-memory failure. Every variant leaves the memory unchanged. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ObservationError { /// The declared caps are impossible (zero per-location, or per-location /// above the aggregate). ImpossibleLimits { per_location: usize, total: usize, }, /// Admitting the batch would exceed a cap. Rejected atomically. CapacityExceeded { scope: &'static str, have: usize, cap: usize, add: usize, }, /// A record was offered against the reserved uninitialized location. UninitializedLocation, /// A row vector was not in strict canonical order (duplicate or reversed). NonCanonicalRowOrder { index: usize }, /// A retained record's location is not a well-formed live location key. InvalidLocation { location: LocationKey }, } /// One flat, owned observation row for the checkpoint image and the wire. The /// row is plain data: no live grid handle, no entity identity. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ObservationRow { /// Active location the record belongs to. pub location: LocationKey, /// Voxel cell the face belongs to. pub cell: (i32, i32, i32), /// [`Face::tag`] of the exposed face. pub face: u8, /// Last-seen appearance. pub appearance: ObservedFace, } /// Bounded, deterministic player-observation memory keyed by /// (location, cell, face). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ObservationMemory { records: BTreeMap>, } impl ObservationMemory { /// Empty memory. #[must_use] pub fn new() -> Self { Self::default() } /// Total retained records. #[must_use] pub fn len(&self) -> usize { self.records.values().map(BTreeMap::len).sum() } /// True when no face has ever been observed. #[must_use] pub fn is_empty(&self) -> bool { self.records.is_empty() } /// Locations with at least one record, in canonical key order. pub fn iter(&self) -> impl Iterator)> { self.records.iter() } /// Records for one location, in canonical face order. #[must_use] pub fn location(&self, location: &LocationKey) -> Option<&BTreeMap> { self.records.get(location) } /// Last-seen record for one face, or `None` when the face is unknown. #[must_use] pub fn get( &self, location: &LocationKey, cell: (i32, i32, i32), face: u8, ) -> Option<&ObservedFace> { self.records .get(location) .and_then(|map| map.get(&FaceKey { cell, face })) } /// Admit a batch of reached faces for `location`. /// /// Atomic and non-destructive: every cap and provenance check happens /// before any mutation, remembered records are never evicted to make room, /// and only a re-observed key is replaced (a new valid observation replaces /// the old one, docs §5.2). Returns the number of *newly* inserted keys. pub fn admit( &mut self, location: &LocationKey, batch: &BTreeMap, limits: ObservationLimits, ) -> Result { limits.validate()?; if location.is_uninitialized() { return Err(ObservationError::UninitializedLocation); } let existing_location = self.records.get(location); let have_location = existing_location.map_or(0, BTreeMap::len); let new = batch .keys() .filter(|key| !existing_location.is_some_and(|map| map.contains_key(key))) .count(); let projected_location = have_location .checked_add(new) .ok_or(ObservationError::CapacityExceeded { scope: "observation per-location", have: have_location, cap: limits.per_location, add: new, })?; if projected_location > limits.per_location { return Err(ObservationError::CapacityExceeded { scope: "observation per-location", have: have_location, cap: limits.per_location, add: new, }); } let have_total = self.len(); let projected_total = have_total .checked_add(new) .ok_or(ObservationError::CapacityExceeded { scope: "observation total", have: have_total, cap: limits.total, add: new, })?; if projected_total > limits.total { return Err(ObservationError::CapacityExceeded { scope: "observation total", have: have_total, cap: limits.total, add: new, }); } // Commit: safe after every check has passed. An empty batch must not // create an empty location entry (it would emit a zero-row location // group into the canonical hash). if !batch.is_empty() { let entry = self.records.entry(location.clone()).or_default(); for (key, value) in batch { entry.insert(*key, *value); } } Ok(new) } /// Restamp every retained record with a new branch epoch. Stage 2 calls /// this on resurrection so reused tick numbers cannot alias an old branch's /// discoveries (docs §7). pub fn restamp_epoch(&mut self, epoch: u64) { for map in self.records.values_mut() { for value in map.values_mut() { value.branch_epoch = epoch; } } } /// Drop every record for one location. Returns the number removed. Used at /// an explicit lifecycle boundary (arrival), never for silent eviction. pub fn clear_location(&mut self, location: &LocationKey) -> usize { self.records.remove(location).map_or(0, |map| map.len()) } /// Drop every record. pub fn clear_all(&mut self) { self.records.clear(); } /// Validate bounds and cap consistency. Every variant leaves the memory /// unchanged; callers reject rather than repair. pub fn validate(&self, limits: ObservationLimits) -> Result<(), ObservationError> { limits.validate()?; let mut total = 0usize; for (location, map) in &self.records { if location.is_uninitialized() || location.to_run_location().is_none() { return Err(ObservationError::InvalidLocation { location: location.clone(), }); } if map.len() > limits.per_location { return Err(ObservationError::CapacityExceeded { scope: "observation per-location", have: map.len(), cap: limits.per_location, add: 0, }); } total = total .checked_add(map.len()) .ok_or(ObservationError::CapacityExceeded { scope: "observation total", have: total, cap: limits.total, add: map.len(), })?; } if total > limits.total { return Err(ObservationError::CapacityExceeded { scope: "observation total", have: total, cap: limits.total, add: 0, }); } Ok(()) } /// Flatten to canonical-order rows for the checkpoint image. #[must_use] pub fn rows(&self) -> Vec { let mut rows = Vec::with_capacity(self.len()); for (location, map) in &self.records { for (key, appearance) in map { rows.push(ObservationRow { location: location.clone(), cell: key.cell, face: key.face, appearance: *appearance, }); } } rows } /// Rebuild memory from checkpoint rows. Rows must be in strict canonical /// order (duplicates and reversals are rejected) and must fit the caps. pub fn from_rows( rows: Vec, limits: ObservationLimits, ) -> Result { limits.validate()?; let mut memory = Self::new(); let mut previous: Option = None; for (index, row) in rows.into_iter().enumerate() { if let Some(prior) = &previous { if row.location < prior.location || (row.location == prior.location && (row.cell < prior.cell || (row.cell == prior.cell && row.face <= prior.face))) { return Err(ObservationError::NonCanonicalRowOrder { index }); } } previous = Some(row.clone()); if row.location.is_uninitialized() { return Err(ObservationError::UninitializedLocation); } memory .records .entry(row.location) .or_default() .insert( FaceKey { cell: row.cell, face: row.face, }, row.appearance, ); } memory.validate(limits)?; Ok(memory) } } /// Canonical observer origin: authoritative actor feet plus the eye height. pub fn observer_origin(feet: FixedVec3) -> Result { feet.checked_translate([0, 0, OBSERVER_EYE_HEIGHT_MM]) } /// A terrain cell occludes the observation ray when it is occupied. This /// matches the light passability rule (`occ == 0` is open, docs/03 §3.3.1), so /// a closed door blocks light and LOS alike and an open one does neither. #[inline] fn occupied(voxel: &Voxel) -> bool { voxel.occ != 0 } #[inline] fn cell_of(mm: i64) -> i32 { mm.div_euclid(VOXEL_MM) as i32 } fn cell_tuple(cell: [i32; 3]) -> (i32, i32, i32) { (cell[0], cell[1], cell[2]) } fn add_cell(cell: (i32, i32, i32), delta: (i32, i32, i32)) -> Option<(i32, i32, i32)> { Some(( cell.0.checked_add(delta.0)?, cell.1.checked_add(delta.1)?, cell.2.checked_add(delta.2)?, )) } /// Target point of an exposed face: the face centre offset `1 mm` into air. fn face_target(cell: (i32, i32, i32), face: Face) -> [i64; 3] { let axis = face.axis(); let dir = i64::from(face.dir()); let base = [ i64::from(cell.0) * VOXEL_MM, i64::from(cell.1) * VOXEL_MM, i64::from(cell.2) * VOXEL_MM, ]; // Face plane: the low edge for a negative face, the high edge otherwise. let plane = if dir > 0 { base[axis] + VOXEL_MM } else { base[axis] }; let mut target = [ base[0] + VOXEL_MM / 2, base[1] + VOXEL_MM / 2, base[2] + VOXEL_MM / 2, ]; target[axis] = plane + dir * FACE_AIR_OFFSET_MM; target } fn squared_distance_mm(a: [i64; 3], b: [i64; 3]) -> i128 { let mut sum = 0i128; for axis in 0..3 { let d = i128::from(a[axis]) - i128::from(b[axis]); sum += d * d; } sum } /// Outcome of tracing one observation ray. enum Trace { /// The segment reached the target air cell without entering terrain: the /// aimed-at face is the reached boundary face. Reached, /// The segment entered terrain first: the boundary face it crossed. Blocked { cell: (i32, i32, i32), face: u8, air_cell: (i32, i32, i32), }, } /// Integer first-hit LOS from `from` to `to`, stopping at `target` (the air /// cell the target point sits in). Steps one voxel axis at a time with /// deterministic axis-order tie breaking; every arithmetic step is checked and /// any overflow fails closed. Returns `None` when the origin is itself inside /// terrain (nothing is observable from inside rock). fn trace(grid: &ChunkedGrid, from: [i64; 3], to: [i64; 3], target: (i32, i32, i32)) -> Option { let mut cell = [cell_of(from[0]), cell_of(from[1]), cell_of(from[2])]; if occupied(&grid.get_world(cell[0], cell[1], cell[2])) { return None; } if cell_tuple(cell) == target { return Some(Trace::Reached); } let mut step = [0i32; 3]; let mut delta = [0i128; 3]; let mut next = [0i128; 3]; for axis in 0..3 { let d = i128::from(to[axis]) - i128::from(from[axis]); step[axis] = d.signum() as i32; delta[axis] = d.abs(); next[axis] = if step[axis] > 0 { (i128::from(cell[axis]) + 1) * i128::from(VOXEL_MM) - i128::from(from[axis]) } else if step[axis] < 0 { i128::from(from[axis]) - i128::from(cell[axis]) * i128::from(VOXEL_MM) } else { i128::MAX }; } loop { let mut axis: Option = None; for candidate in 0..3 { if delta[candidate] == 0 || next[candidate] > delta[candidate] { continue; } match axis { None => axis = Some(candidate), Some(current) => { let left = next[candidate].checked_mul(delta[current])?; let right = next[current].checked_mul(delta[candidate])?; if left < right { axis = Some(candidate); } } } } let Some(axis) = axis else { // No boundary lies within the segment: we are in the end cell. return Some(Trace::Reached); }; let previous = cell; cell[axis] = cell[axis].checked_add(step[axis])?; next[axis] = next[axis].checked_add(i128::from(VOXEL_MM))?; if cell_tuple(cell) == target { return Some(Trace::Reached); } if occupied(&grid.get_world(cell[0], cell[1], cell[2])) { return Some(Trace::Blocked { cell: cell_tuple(cell), face: Face::entered(axis, step[axis]).tag(), air_cell: cell_tuple(previous), }); } } } /// Run the canonical observation pass from `origin` at `tick`, stamping /// `branch_epoch` on every produced record. /// /// Returns the reached boundary faces keyed by (cell, face). Only terrain is /// read; no actor, item, or service state participates. #[must_use] pub fn observe( grid: &ChunkedGrid, origin: FixedVec3, tick: u64, branch_epoch: u64, ) -> BTreeMap { let mut reached = BTreeMap::new(); let origin_mm = origin.millimetres(); let origin_cell = origin.cell(); let range_sq = i128::from(MAX_OBSERVATION_RANGE_MM) * i128::from(MAX_OBSERVATION_RANGE_MM); let span = (MAX_OBSERVATION_RANGE_MM / VOXEL_MM) as i32; let lo = [origin_cell[0] - span, origin_cell[1] - span, origin_cell[2] - span]; let hi = [origin_cell[0] + span, origin_cell[1] + span, origin_cell[2] + span]; let mut rays = 0usize; let edge = CHUNK as i32; 'chunks: for (coord, chunk) in grid.iter_loaded() { let base = [coord.0 * edge, coord.1 * edge, coord.2 * edge]; let (x0, x1) = (base[0].max(lo[0]), (base[0] + edge - 1).min(hi[0])); let (y0, y1) = (base[1].max(lo[1]), (base[1] + edge - 1).min(hi[1])); let (z0, z1) = (base[2].max(lo[2]), (base[2] + edge - 1).min(hi[2])); if x0 > x1 || y0 > y1 || z0 > z1 { continue; } for z in z0..=z1 { for y in y0..=y1 { for x in x0..=x1 { let cell = (x, y, z); let voxel = chunk.get( (x - base[0]) as usize, (y - base[1]) as usize, (z - base[2]) as usize, ); if !occupied(&voxel) { continue; } for face in Face::ALL { let normal = face.normal(); let Some(air_cell) = add_cell(cell, normal) else { continue; }; let air = grid.get_world(air_cell.0, air_cell.1, air_cell.2); if occupied(&air) { continue; // not an exposed face } let target = face_target(cell, face); if squared_distance_mm(origin_mm, target) > range_sq { continue; // beyond the 48 m bound } if rays >= MAX_OBSERVATION_RAYS { break 'chunks; } rays += 1; match trace(grid, origin_mm, target, air_cell) { None => {} Some(Trace::Reached) => { if air.light > 0 { reached.insert( FaceKey { cell, face: face.tag(), }, ObservedFace { material: voxel.material, occupancy: voxel.occ, door_appearance: door_appearance(&voxel), observed_tick: tick, branch_epoch, }, ); } } Some(Trace::Blocked { cell: hit, face: hit_face, air_cell: hit_air, }) if grid.get_world(hit_air.0, hit_air.1, hit_air.2).light > 0 => { let hit_voxel = grid.get_world(hit.0, hit.1, hit.2); reached.insert( FaceKey { cell: hit, face: hit_face, }, ObservedFace { material: hit_voxel.material, occupancy: hit_voxel.occ, door_appearance: door_appearance(&hit_voxel), observed_tick: tick, branch_epoch, }, ); } Some(Trace::Blocked { .. }) => {} } } } } } } reached } #[cfg(test)] mod tests { use super::*; use crate::voxel::Voxel; fn solid() -> Voxel { Voxel { material: material::STONE, occ: 1, ..Voxel::default() } } fn lit(voxel: Voxel, light: u8) -> Voxel { Voxel { light, ..voxel } } /// The core light/LOS/unknown matrix: /// - a lit exposed face is recorded; /// - an unlit exposed face is NOT recorded; /// - cells behind a lit wall stay unknown; /// - only the reached face is recorded, never the interior; /// - the 48 m bound excludes far faces. #[test] fn observation_light_los_unknown_matrix() { let mut grid = ChunkedGrid::new(); // Lit wall at x=3 with a solid second thickness at x=4 (interior). for y in -6..=6 { for z in 0..=4 { grid.set_world(3, y, z, solid()); grid.set_world(4, y, z, solid()); } } // Unlit wall at x=-3 (its air side is dark). for y in -6..=6 { for z in 0..=4 { grid.set_world(-3, y, z, solid()); } } // Two single overhead blocks in the +Y direction from the origin. grid.set_world(5, 45, 1, solid()); // ~44.5 m away: inside the bound grid.set_world(5, 55, 1, solid()); // ~54.7 m away: beyond the bound // Light the air the samples need. x >= -1 is lit; x <= -2 stays dark. for x in -1..=12 { for y in -6..=60 { for z in 0..=4 { let voxel = grid.get_world(x, y, z); if !occupied(&voxel) { grid.set_world(x, y, z, lit(voxel, 10)); } } } } let origin = observer_origin(FixedVec3::from_cell_feet([0, 0, 0]).unwrap()).unwrap(); let reached = observe(&grid, origin, 7, 0); // A lit exposed face is recorded. assert!( reached.contains_key(&FaceKey { cell: (3, 0, 2), face: Face::NegX.tag() }), "lit exposed wall face must be observed" ); // The interior (x=4) is never recorded: only the reached face is. assert!( !reached.keys().any(|key| key.cell.0 == 4), "the wall interior must stay unknown" ); // Cells behind a lit wall stay unknown. assert!( !reached .keys() .any(|key| key.cell.0 >= 5 && key.cell.1 <= 10), "cells behind the lit wall must stay unknown" ); // An unlit exposed face is NOT recorded. assert!( !reached.keys().any(|key| key.cell.0 == -3), "an unlit exposed face must not be recorded" ); // The 48 m bound excludes the far face but not the nearer one. assert!( reached.contains_key(&FaceKey { cell: (5, 45, 1), face: Face::NegY.tag() }), "a lit in-range overhead face must be observed" ); assert!( !reached.keys().any(|key| key.cell == (5, 55, 1)), "a face beyond 48 m must be excluded" ); // The far side of the overhead block is behind it: not reached. assert!( !reached.contains_key(&FaceKey { cell: (5, 45, 1), face: Face::PosY.tag() }), "the far face of a block is never reached" ); } #[test] fn observer_origin_is_feet_plus_eye_height() { let feet = FixedVec3::new([-1500, 3500, -1000]).unwrap(); assert_eq!( observer_origin(feet).unwrap().millimetres(), [-1500, 3500, 600] ); } #[test] fn admit_is_atomic_before_mutation_and_never_evicts() { let limits = ObservationLimits { per_location: 2, total: 4, }; let town = LocationKey { kind: LOCATION_KIND_TOWN, town: "milthollow".into(), depth: 0, }; let record = ObservedFace { material: material::STONE, occupancy: 1, door_appearance: DOOR_APPEARANCE_SOLID, observed_tick: 3, branch_epoch: 0, }; let mut memory = ObservationMemory::new(); let mut batch = BTreeMap::new(); batch.insert(FaceKey { cell: (0, 0, 0), face: 0 }, record); batch.insert(FaceKey { cell: (0, 0, 0), face: 1 }, record); assert_eq!(memory.admit(&town, &batch, limits), Ok(2)); let before = memory.clone(); let mut overflow = BTreeMap::new(); overflow.insert(FaceKey { cell: (0, 0, 1), face: 0 }, record); assert!(matches!( memory.admit(&town, &overflow, limits), Err(ObservationError::CapacityExceeded { .. }) )); assert_eq!(memory, before, "a rejected batch must not mutate memory"); // Re-observation of an existing key is not an eviction and fits. let mut refresh = BTreeMap::new(); refresh.insert( FaceKey { cell: (0, 0, 0), face: 0 }, ObservedFace { observed_tick: 9, ..record }, ); assert_eq!(memory.admit(&town, &refresh, limits), Ok(0)); assert_eq!( memory.get(&town, (0, 0, 0), 0).unwrap().observed_tick, 9 ); // An impossible limit configuration is rejected before any check. let impossible = ObservationLimits { per_location: 5, total: 2, }; let snapshot = memory.clone(); assert_eq!( memory.admit(&town, &refresh, impossible), Err(ObservationError::ImpossibleLimits { per_location: 5, total: 2 }) ); assert_eq!(memory, snapshot); assert!(matches!( memory.validate(impossible), Err(ObservationError::ImpossibleLimits { .. }) )); } #[test] fn rows_roundtrip_and_reject_duplicates_and_reversal() { let limits = ObservationLimits::canonical(); let town = LocationKey { kind: LOCATION_KIND_TOWN, town: "milthollow".into(), depth: 0, }; let record = ObservedFace { material: material::WOOD, occupancy: 1, door_appearance: DOOR_APPEARANCE_SOLID, observed_tick: 12, branch_epoch: 4, }; let mut memory = ObservationMemory::new(); let mut batch = BTreeMap::new(); batch.insert(FaceKey { cell: (0, 0, 0), face: 0 }, record); batch.insert(FaceKey { cell: (1, 0, 0), face: 2 }, record); memory.admit(&town, &batch, limits).unwrap(); let rows = memory.rows(); assert_eq!(ObservationMemory::from_rows(rows.clone(), limits).unwrap(), memory); let mut duplicate = rows.clone(); duplicate.push(rows[0].clone()); assert!(matches!( ObservationMemory::from_rows(duplicate, limits), Err(ObservationError::NonCanonicalRowOrder { .. }) )); let mut reversed = rows.clone(); reversed.reverse(); assert!(matches!( ObservationMemory::from_rows(reversed, limits), Err(ObservationError::NonCanonicalRowOrder { .. }) )); } #[test] fn record_type_has_no_entity_identity_fields() { // Exhaustive destructuring without `..` is a compile-time proof that an // `ObservedFace` carries exactly these terrain fields and nothing else. let record = ObservedFace { material: material::STONE, occupancy: 1, door_appearance: DOOR_APPEARANCE_SOLID, observed_tick: 1, branch_epoch: 0, }; let ObservedFace { material, occupancy, door_appearance, observed_tick, branch_epoch, } = record; assert_eq!( (material, occupancy, door_appearance, observed_tick, branch_epoch), (material::STONE, 1, DOOR_APPEARANCE_SOLID, 1, 0) ); } }