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.
15 kB · 374 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375//! Scent & tracking volumes (docs/03-systems-design.md §3.3 law 10://! "Blood, oil, rot, ozone, wet wool persist as scent volumes; dogs, rats, and//! worse follow them. Wash in water; smoke masks."; docs/04 §4.2 phase 5//! `gas & scent`, §4.10.5 first-time law lines).//!//! Scent rides on the voxel `scent` byte: `(kind << 5) | intensity`, kinds//! `NONE=0 BLOOD=1 OIL=2 ROT=3 OZONE=4 WOOL=5 SMOKE_MASK=6` (shared contract).//! This module owns emission, slow decay, a stub +x wind drift, washing, smoke//! masking, and the AI trail query. Full draft advection (pressure-node winds,//! doors slamming, flour-revealed secret vents) is parent/air-side: the parent//! feeds the pressure draft field into these volumes, and this stub documents//! the handoff (see [`step_scent`]).
use crate::voxel::{material, ChunkedGrid, CHUNK};
/// Scent kind ids (shared contract; mirrored here, never cross-imported).pub mod kind { pub const NONE: u8 = 0; pub const BLOOD: u8 = 1; pub const OIL: u8 = 2; pub const ROT: u8 = 3; pub const OZONE: u8 = 4; pub const WOOL: u8 = 5; pub const SMOKE_MASK: u8 = 6;}
/// Gas protocol mirror: smoke masks scent (shared contract §"Voxel": SMOKE = 1).pub const GAS_SMOKE: u8 = 1;
/// Maximum scent intensity (5 low bits).pub const INTENSITY_MAX: u8 = 31;
/// Scent decays by 1 every 600 ticks (docs/04 §4.6 autosave cadence: one notch/// per save window — smells outlast a delve leg, not a lineage).pub const DECAY_EVERY_TICKS: u64 = 600;
/// Stub wind: +x drift of 1 cell every 10 ticks (1 tick = 100ms, so a gentle/// walking-pace draft). Full pressure-draft advection is parent/air-side.pub const ADVECT_EVERY_TICKS: u64 = 10;
/// Cell-update budget per call; excess work defers to a later tick.pub const MAX_UPDATES: usize = 4000;
/// Max span per axis scanned in one call — the perf bound. Larger regions are/// clamped, never rejected (deterministic truncation, documented). Mirrors/// `structure.rs` `MAX_REGION_SPAN` + `bounded_span` slice-local.pub const MAX_REGION_SPAN: i32 = 64;
/// Clamp one axis of the caller's region to the perf bound, tolerating/// swapped min/max (mirrors `structure.rs` `bounded_span`).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)))}
/// Chunk coordinate containing voxel coordinate `c` (mirrors `air.rs`/// `chunk_of` so drift targets resolve to chunk coords for the load check).fn chunk_of(c: i32) -> i32 { c.div_euclid(CHUNK as i32)}
/// Split a scent byte into its kind (upper 3 bits).pub fn scent_kind(scent: u8) -> u8 { scent >> 5}
/// Split a scent byte into its intensity (lower 5 bits, 0–31).pub fn scent_intensity(scent: u8) -> u8 { scent & INTENSITY_MAX}
/// Pack kind + intensity into a scent byte. Inputs are masked to their fields.pub fn make_scent(kind: u8, intensity: u8) -> u8 { ((kind & 0x07) << 5) | (intensity & INTENSITY_MAX)}
/// Lay scent at one cell. `kind == NONE`, reserved kinds above `SMOKE_MASK`,/// or zero intensity all clear the cell (no signal without strength).pub fn emit_scent(grid: &mut ChunkedGrid, x: i32, y: i32, z: i32, kind: u8, intensity: u8) { let mut v = grid.get_world(x, y, z); if kind == kind::NONE || kind > kind::SMOKE_MASK || intensity == 0 { v.scent = 0; } else { v.scent = make_scent(kind, intensity.min(INTENSITY_MAX)); } grid.set_world(x, y, z, v);}
/// Advance scent one tick over the inclusive region box. Returns/// `(cells_changed, spillover)`; `spillover` is true when [`MAX_UPDATES`]/// truncated decay or drift so the parent re-runs next tick (same shape as/// [`crate::signal::step_signal`] so the parent `Spill` queue covers both/// uniformly).////// Two sub-steps, each on its own cadence so the common tick is a cheap scan:/// - decay: every [`DECAY_EVERY_TICKS`], intensity − 1 (kind-only residue with/// zero intensity is cleared — a smell with no strength is no smell);/// - drift (stub): every [`ADVECT_EVERY_TICKS`], each scented cell copies its/// scent one cell +x when the target is weaker. Collect-then-apply, so no/// cascade within one step and order is deterministic.////// Region spans are clamped to [`MAX_REGION_SPAN`] per axis (deterministic/// truncation: cells past the bound defer to a later call with a shifted/// region — never silently dropped).////// Drift targets in unloaded chunks are skipped (never allocate a 1.7 MB/// chunk just to park a smell in the void; cf `air.rs` advect guard), as are/// targets inside solid rock — drift only enters cells with `occ == 0` or/// [`material::GLASS`] (mirrors light/fluids enterability).////// HANDOFF: real advection rides the pressure draft field (docs/04 §4.3 "Rooms/// breathe", docs/03 §3.3.3): doors, grates, shafts and secret vents steer/// smoke/scent/gas parent-side via the air module. That layer reads these/// volumes with [`strongest_trail`] and writes with [`emit_scent`]; this stub/// only guarantees still-air behavior (decay + faint +x draft) stays sane.pub fn step_scent( grid: &mut ChunkedGrid, region_min: (i32, i32, i32), region_max: (i32, i32, i32), tick: u64,) -> (usize, bool) { // NOTE: `% N == 0` instead of `u64::is_multiple_of` — the latter needs // Rust 1.87+ and our MSRV floor is 1.75 (workspace rust-version; state.rs // documents the same convention). let decay = tick % DECAY_EVERY_TICKS == 0; let advect = tick % ADVECT_EVERY_TICKS == 0; if !decay && !advect { return (0, false); } // Clamp each axis to the perf bound (tolerates swapped min/max). 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 modified = 0usize; let mut spillover = false; let mut drift: Vec<((i32, i32, i32), u8)> = Vec::new(); for x in x0..=x1 { for y in y0..=y1 { for z in z0..=z1 { let mut v = grid.get_world(x, y, z); if v.scent == 0 { continue; } if decay { let intensity = scent_intensity(v.scent); if intensity <= 1 { v.scent = 0; } else { v.scent = make_scent(scent_kind(v.scent), intensity - 1); } grid.set_world(x, y, z, v); modified += 1; if modified >= MAX_UPDATES { // Budget spent: drift defers to the next eligible tick. return (modified, true); } } if advect && v.scent != 0 { drift.push(((x, y, z), v.scent)); } } } } if advect { for ((x, y, z), scent) in drift { if modified >= MAX_UPDATES { spillover = true; break; } let tx = x + 1; if tx > x1 { continue; } // Never materialise an unloaded chunk for drift (cf air.rs:139). if !grid.is_loaded(chunk_of(tx), chunk_of(y), chunk_of(z)) { continue; } let mut target = grid.get_world(tx, y, z); // Drift cannot enter solid rock: only empty or glass cells. if target.occ != 0 && target.material != material::GLASS { continue; } if scent_intensity(target.scent) < scent_intensity(scent) { target.scent = scent; grid.set_world(tx, y, z, target); modified += 1; } } } (modified, spillover)}
/// Rinse one cell: water clears scent (docs/03 §3.3.10 "Wash in water").////// This is the rinsing action itself, so it clears unconditionally; the parent/// only calls it for cells with WATER present (fluid protocol), which is where/// the fiction lives.pub fn wash(grid: &mut ChunkedGrid, x: i32, y: i32, z: i32) { let mut v = grid.get_world(x, y, z); v.scent = 0; grid.set_world(x, y, z, v);}
/// Smoke-mask one cell: live SMOKE gas overwrites the scent kind with/// `SMOKE_MASK`, preserving strength (floored at 1 so the mask is observable)./// Without smoke present the cell is left untouched.pub fn mask_with_smoke(grid: &mut ChunkedGrid, x: i32, y: i32, z: i32) { let mut v = grid.get_world(x, y, z); if v.gas_kind == GAS_SMOKE && v.gas_amt > 0 { let intensity = scent_intensity(v.scent).max(1); v.scent = make_scent(kind::SMOKE_MASK, intensity); grid.set_world(x, y, z, v); }}
/// AI following query (dogs, rats, and worse): the strongest scent byte in the/// 3×3×3 neighbourhood including the cell itself, or 0 when nothing carries./// Ties resolve to the first cell in fixed scan order — deterministic./// Returns the full scent byte (kind + intensity) so followers know *what*/// they trail, not just how hot it is.pub fn strongest_trail(grid: &ChunkedGrid, x: i32, y: i32, z: i32) -> u8 { let mut best = 0u8; let mut best_intensity = 0u8; for dx in -1..=1 { for dy in -1..=1 { for dz in -1..=1 { let scent = grid.get_world(x + dx, y + dy, z + dz).scent; let intensity = scent_intensity(scent); if intensity > best_intensity { best_intensity = intensity; best = scent; } } } } best}
#[cfg(test)]mod tests { use super::*;
const REGION: ((i32, i32, i32), (i32, i32, i32)) = ((-4, -4, -4), (4, 4, 4));
#[test] fn emit_sets_kind_and_intensity() { let mut grid = ChunkedGrid::new(); emit_scent(&mut grid, 3, 0, 0, kind::BLOOD, 20); let scent = grid.get_world(3, 0, 0).scent; assert_eq!(scent_kind(scent), kind::BLOOD); assert_eq!(scent_intensity(scent), 20); }
#[test] fn decay_ticks_down() { let mut grid = ChunkedGrid::new(); emit_scent(&mut grid, 0, 0, 0, kind::WOOL, 4); let (touched, spill) = step_scent(&mut grid, REGION.0, REGION.1, DECAY_EVERY_TICKS); assert!(touched >= 1); assert!(!spill); assert_eq!(scent_intensity(grid.get_world(0, 0, 0).scent), 3); // Off-cadence ticks leave scent alone. let (idle, idle_spill) = step_scent(&mut grid, REGION.0, REGION.1, DECAY_EVERY_TICKS + 1); assert_eq!(idle, 0); assert!(!idle_spill); assert_eq!(scent_intensity(grid.get_world(0, 0, 0).scent), 3); }
#[test] fn wash_clears() { let mut grid = ChunkedGrid::new(); emit_scent(&mut grid, 1, 1, 1, kind::OIL, 12); wash(&mut grid, 1, 1, 1); assert_eq!(grid.get_world(1, 1, 1).scent, 0); }
#[test] fn smoke_masks() { let mut grid = ChunkedGrid::new(); emit_scent(&mut grid, 0, 0, 0, kind::BLOOD, 10); let mut v = grid.get_world(0, 0, 0); v.gas_kind = GAS_SMOKE; v.gas_amt = 6; grid.set_world(0, 0, 0, v); mask_with_smoke(&mut grid, 0, 0, 0); let scent = grid.get_world(0, 0, 0).scent; assert_eq!(scent_kind(scent), kind::SMOKE_MASK); assert_eq!(scent_intensity(scent), 10); }
#[test] fn trail_readable() { let mut grid = ChunkedGrid::new(); assert_eq!(strongest_trail(&grid, 0, 0, 0), 0); emit_scent(&mut grid, 1, 0, 0, kind::BLOOD, 9); emit_scent(&mut grid, 0, 1, 0, kind::OIL, 4); assert_eq!(strongest_trail(&grid, 0, 0, 0), make_scent(kind::BLOOD, 9)); }
#[test] fn huge_region_clamps_within_budget() { let mut grid = ChunkedGrid::new(); // Scent at the region's low corner: clamping keeps `lo` and truncates // `hi`, so this cell is always scanned. emit_scent(&mut grid, -500, -500, -500, kind::BLOOD, 9); // A region far beyond MAX_REGION_SPAN must terminate inside the // budget instead of scanning millions of cells. let (touched, spill) = step_scent( &mut grid, (-500, -500, -500), (500, 500, 500), DECAY_EVERY_TICKS, ); assert!(touched <= MAX_UPDATES); assert!(!spill); assert!(touched >= 1); assert_eq!(scent_intensity(grid.get_world(-500, -500, -500).scent), 8); }
#[test] fn drift_into_unloaded_chunk_allocates_nothing() { let mut grid = ChunkedGrid::new(); // Scent at the +x edge of loaded chunk 0; the drift target (48,0,0) // sits in unloaded chunk 1. The region spans the boundary so the // drift guard (not the region edge) is what skips the write. emit_scent(&mut grid, 47, 0, 0, kind::BLOOD, 9); let before = grid.loaded_count(); assert!(before > 0); let (_, spill) = step_scent(&mut grid, (47, 0, 0), (48, 0, 0), ADVECT_EVERY_TICKS); assert!(!spill); assert_eq!(grid.loaded_count(), before); // Source keeps its scent; nothing leaked into the void. assert_eq!(scent_intensity(grid.get_world(47, 0, 0).scent), 9); }
#[test] fn drift_skips_solid_rock() { use crate::voxel::{material, Voxel}; let mut grid = ChunkedGrid::new(); emit_scent(&mut grid, 0, 0, 0, kind::BLOOD, 9); // Solid rock target: occupied stone blocks the drift. let rock = Voxel { material: material::STONE, occ: 1, ..Voxel::default() }; grid.set_world(1, 0, 0, rock); let before = grid.loaded_count(); // Tick 10: advect-only (10 % 600 != 0, 10 % 10 == 0). let (_, spill) = step_scent(&mut grid, (-4, -4, -4), (4, 4, 4), ADVECT_EVERY_TICKS); assert!(!spill); assert_eq!(grid.loaded_count(), before); assert_eq!(scent_intensity(grid.get_world(1, 0, 0).scent), 0); assert_eq!(scent_intensity(grid.get_world(0, 0, 0).scent), 9); }
#[test] fn over_budget_region_reports_spillover() { let mut grid = ChunkedGrid::new(); // 65 × 9 × 9 = 5265 scented cells > MAX_UPDATES = 4000: the decay // pass must truncate and report spillover. (Tick 600 runs decay + // drift, but decay hits the budget first.) for x in 0..=64 { for y in 0..=8 { for z in 0..=8 { emit_scent(&mut grid, x, y, z, kind::OIL, 20); } } } let (touched, spill) = step_scent(&mut grid, (0, 0, 0), (64, 8, 8), DECAY_EVERY_TICKS); assert_eq!(touched, MAX_UPDATES); assert!(spill); }}