Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
13 kB · 354 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355//! 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); }}