//! Structural collapse — law 11 (docs/03-systems-design.md §3.3.11). //! //! Design trace: //! - "Timber props, rusted bolts, cracked lintels. Noise + weight + rot //! collapse things. Sledgehammers open shortcuts and tombs (and alarms)." //! - Tick phase: Structure, phase 10 (docs/04 §4.2) — load checks run on //! marked-dirty supports only, never full-grid scans. The parent calls //! [`mark_dirty`] from weight/noise/rot events (Move, Sound, Rot phases) //! and [`step_structure`] with a bounded region each tick. //! - Hard rules (docs/04 §4.10): deterministic (no maps, no RNG, fixed //! x→y→z visit order); every collapse pushes a `CauseLink` with rule tag //! `"structure"` in NetHack voice; survivors stay silent (a hold is not a //! hazard — rule 5 binds hazards, not holds). //! //! Simplified deterministic rule: a solid [`SUPPORT_DIRTY`] voxel collapses //! when it has no solid neighbor below or diagonally within //! [`SUPPORT_RADIUS`], or — disturbed while weakened — when it is a //! [`RUSTED`] iron bolt or a cracked (`extra & extra::CRACKED`) stone lintel //! even with support. Timber props hold while supported; remove one and the cascade //! marks its neighbors for next tick. Collapse turns the cell to AIR (falling //! rubble spawns are the parent's hook — count + link carry everything the //! spawner needs); sledge shock likewise marks the 3×3×3 neighborhood dirty //! so damage propagates one ring per tick: dramatic, fair, and bounded. //! //! What the parent must pass for noise/weight inputs: call [`mark_dirty`] on //! the support cells under/around heavy loads, sledge strikes, explosions, //! rot-stage changes, and prop removals. This module never scans for those //! itself — unmarked cells are untouched by construction. use crate::cause::{CauseChain, CauseLink}; use crate::voxel::{extra, material, ChunkedGrid, Voxel, CHUNK}; /// Voxel flag bit: support state needs rechecking this tick (shared contract). pub const SUPPORT_DIRTY: u8 = 0x20; /// Voxel flag bit: iron has rusted (shared contract). pub const RUSTED: u8 = 0x04; /// Max span per axis scanned in one call — the perf bound. Larger regions are /// clamped, never rejected (deterministic truncation, documented). pub const MAX_REGION_SPAN: i32 = 64; /// Support footprint: solid voxels within ±2 horizontally and 1–2 below. pub const SUPPORT_RADIUS: i32 = 2; /// Clamp one axis of the caller's region to the perf bound, tolerating /// swapped min/max. fn bounded_span(a: i32, b: i32) -> (i32, i32) { let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; (lo, hi.min(lo.saturating_add(MAX_REGION_SPAN))) } /// True when the chunk containing this cell is loaded. Disturbance marks and /// cascade writes skip unloaded cells — never allocate a 1.7 MB chunk just to /// record that the void is unsupported. fn cell_loaded(grid: &ChunkedGrid, x: i32, y: i32, z: i32) -> bool { grid.is_loaded( x.div_euclid(CHUNK as i32), y.div_euclid(CHUNK as i32), z.div_euclid(CHUNK as i32), ) } /// Mark one cell for recheck on the next [`step_structure`]. Noise, weight, /// and rot events call this; unloaded cells are ignored (see [`cell_loaded`]). pub fn mark_dirty(grid: &mut ChunkedGrid, x: i32, y: i32, z: i32) { if !cell_loaded(grid, x, y, z) { return; } let mut v = grid.get_world(x, y, z); v.flags |= SUPPORT_DIRTY; grid.set_world(x, y, z, v); } /// Weakened while disturbed: rusted iron bolts shear, cracked stone lintels /// give way, even with support underneath. fn is_weakened(v: &Voxel) -> bool { (v.material == material::IRON && v.flags & RUSTED != 0) || (v.material == material::STONE && (v.extra & extra::CRACKED) != 0) } /// NetHack-voice noun for the collapse link. fn describe(v: &Voxel) -> &'static str { match v.material { material::WOOD => "timber prop", material::IRON => { if v.flags & RUSTED != 0 { "rusted iron bolt" } else { "iron fitting" } } material::STONE => { if (v.extra & extra::CRACKED) != 0 { "cracked stone lintel" } else { "stone block" } } _ => "masonry", } } /// True when any solid voxel sits below or diagonally within the footprint. /// Direct-below is checked first (common case = one read). fn has_support(grid: &ChunkedGrid, x: i32, y: i32, z: i32) -> bool { if grid.get_world(x, y, z - 1).material != material::AIR { return true; } for dz in 1..=SUPPORT_RADIUS { for dy in -SUPPORT_RADIUS..=SUPPORT_RADIUS { for dx in -SUPPORT_RADIUS..=SUPPORT_RADIUS { if dx == 0 && dy == 0 && dz == 1 { continue; } if grid.get_world(x + dx, y + dy, z - dz).material != material::AIR { return true; } } } } false } /// Recheck every [`SUPPORT_DIRTY`] cell in the clamped region; returns the /// collapse count. Unsupported solids — and disturbed weakened bolts/lintels /// — become AIR (falling rubble; the parent spawns it from count + links) and /// push a `"structure"` link each. Survivors and dirty AIR cells just get /// their mark cleared. Cascade marks land after the sweep, so each collapse /// propagates exactly one ring per tick. Cost is O(region dirty cells × /// footprint reads); clean cells cost one read and are never written. pub fn step_structure( grid: &mut ChunkedGrid, region_min: (i32, i32, i32), region_max: (i32, i32, i32), causes: &mut CauseChain, tick: u64, ) -> usize { let (x0, x1) = bounded_span(region_min.0, region_max.0); let (y0, y1) = bounded_span(region_min.1, region_max.1); let (z0, z1) = bounded_span(region_min.2, region_max.2); let mut collapsed = 0usize; let mut cascade: Vec<(i32, i32, i32)> = Vec::new(); for z in z0..=z1 { for y in y0..=y1 { for x in x0..=x1 { let v = grid.get_world(x, y, z); if v.flags & SUPPORT_DIRTY == 0 { continue; } if v.material == material::AIR { let mut clear = v; clear.flags &= !SUPPORT_DIRTY; grid.set_world(x, y, z, clear); continue; } if has_support(grid, x, y, z) && !is_weakened(&v) { let mut clear = v; clear.flags &= !SUPPORT_DIRTY; grid.set_world(x, y, z, clear); continue; } let kind = describe(&v); let mut rubble = v; rubble.material = material::AIR; rubble.occ = 0; rubble.flags &= !SUPPORT_DIRTY; grid.set_world(x, y, z, rubble); causes.push(CauseLink::new( tick, "structure", format!("the {kind} gives way at ({x}, {y}, {z})"), )); collapsed += 1; for dz in -1..=1 { for dy in -1..=1 { for dx in -1..=1 { if dx == 0 && dy == 0 && dz == 0 { continue; } cascade.push((x + dx, y + dy, z + dz)); } } } } } } for (x, y, z) in cascade { mark_dirty(grid, x, y, z); } collapsed } /// Sledgehammer shortcut (law 11: shortcuts *and* alarms). Smashes any solid /// voxel to AIR, marks the shocked 3×3×3 neighborhood dirty (possible /// cascade next tick), and pushes a `"structure"` link. Returns false — with /// no link — for AIR and unloaded cells. Loud-noise emission at the strike is /// the parent Sound phase's hook: everything heard that. pub fn sledge( grid: &mut ChunkedGrid, x: i32, y: i32, z: i32, causes: &mut CauseChain, tick: u64, ) -> bool { if !cell_loaded(grid, x, y, z) { return false; } let v = grid.get_world(x, y, z); if v.material == material::AIR { return false; } let kind = describe(&v); let mut open = v; open.material = material::AIR; open.occ = 0; open.flags &= !SUPPORT_DIRTY; grid.set_world(x, y, z, open); causes.push(CauseLink::new( tick, "structure", format!("the {kind} comes down with a crash that everything hears at ({x}, {y}, {z})"), )); for dz in -1..=1 { for dy in -1..=1 { for dx in -1..=1 { if dx == 0 && dy == 0 && dz == 0 { continue; } mark_dirty(grid, x + dx, y + dy, z + dz); } } } true } #[cfg(test)] #[allow(clippy::field_reassign_with_default)] mod tests { use super::*; fn put(grid: &mut ChunkedGrid, x: i32, y: i32, z: i32, mat: u8, flags: u8, extra: u16) { let v = Voxel { material: mat, flags, extra, ..Voxel::default() }; grid.set_world(x, y, z, v); } const REGION_MIN: (i32, i32, i32) = (-4, -4, -4); const REGION_MAX: (i32, i32, i32) = (8, 8, 8); #[test] fn unsupported_collapses() { let mut grid = ChunkedGrid::new(); put(&mut grid, 0, 0, 5, material::STONE, 0, 0); mark_dirty(&mut grid, 0, 0, 5); let mut causes = CauseChain::new(); let n = step_structure(&mut grid, REGION_MIN, REGION_MAX, &mut causes, 1); assert_eq!(n, 1); assert_eq!(grid.get_world(0, 0, 5).material, material::AIR); assert_eq!(causes.len(), 1); assert_eq!(causes.links()[0].rule, "structure"); assert!(causes.links()[0].detail.contains("gives way")); } #[test] fn supported_stands() { let mut grid = ChunkedGrid::new(); put(&mut grid, 0, 0, 0, material::STONE, 0, 0); put(&mut grid, 0, 0, 1, material::STONE, 0, 0); mark_dirty(&mut grid, 0, 0, 1); let mut causes = CauseChain::new(); let n = step_structure(&mut grid, REGION_MIN, REGION_MAX, &mut causes, 1); assert_eq!(n, 0); assert_eq!(grid.get_world(0, 0, 1).material, material::STONE); assert_eq!(grid.get_world(0, 0, 1).flags & SUPPORT_DIRTY, 0); assert!(causes.is_empty()); } #[test] fn rusted_bolt_shears_with_support() { let mut grid = ChunkedGrid::new(); put(&mut grid, 0, 0, 0, material::STONE, 0, 0); put(&mut grid, 0, 0, 1, material::IRON, RUSTED, 0); mark_dirty(&mut grid, 0, 0, 1); let mut causes = CauseChain::new(); let n = step_structure(&mut grid, REGION_MIN, REGION_MAX, &mut causes, 1); assert_eq!(n, 1); assert_eq!(grid.get_world(0, 0, 1).material, material::AIR); assert!(causes.links()[0].detail.contains("rusted iron bolt")); } #[test] fn sound_iron_stands() { let mut grid = ChunkedGrid::new(); put(&mut grid, 0, 0, 0, material::STONE, 0, 0); put(&mut grid, 0, 0, 1, material::IRON, 0, 0); mark_dirty(&mut grid, 0, 0, 1); let mut causes = CauseChain::new(); let n = step_structure(&mut grid, REGION_MIN, REGION_MAX, &mut causes, 1); assert_eq!(n, 0); assert_eq!(grid.get_world(0, 0, 1).material, material::IRON); } #[test] fn cracked_lintel_gives_way() { let mut grid = ChunkedGrid::new(); put(&mut grid, 0, 0, 0, material::STONE, 0, 0); put( &mut grid, 0, 0, 1, material::STONE, 0, crate::voxel::extra::CRACKED, ); mark_dirty(&mut grid, 0, 0, 1); let mut causes = CauseChain::new(); let n = step_structure(&mut grid, REGION_MIN, REGION_MAX, &mut causes, 1); assert_eq!(n, 1); assert!(causes.links()[0] .detail .contains("the cracked stone lintel gives way")); } #[test] fn sledge_opens() { let mut grid = ChunkedGrid::new(); put(&mut grid, 2, 0, 0, material::STONE, 0, 0); let mut causes = CauseChain::new(); assert!(sledge(&mut grid, 2, 0, 0, &mut causes, 1)); assert_eq!(grid.get_world(2, 0, 0).material, material::AIR); assert_eq!(causes.len(), 1); assert!(causes.links()[0].detail.contains("crash")); assert!(!sledge(&mut grid, 2, 0, 0, &mut causes, 2)); assert_eq!(causes.len(), 1); } #[test] fn dirty_only_clean_cells_untouched() { let mut grid = ChunkedGrid::new(); put(&mut grid, 0, 0, 5, material::STONE, 0, 0); put(&mut grid, 6, 6, 6, material::STONE, 0, 0); mark_dirty(&mut grid, 0, 0, 5); let mut causes = CauseChain::new(); let n = step_structure(&mut grid, REGION_MIN, REGION_MAX, &mut causes, 1); assert_eq!(n, 1); let survivor = grid.get_world(6, 6, 6); assert_eq!(survivor.material, material::STONE); assert_eq!(survivor.flags & SUPPORT_DIRTY, 0); } }