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.
19 kB · 565 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566//! Fluids phase: water/oil/blood downhill + pit fill-up + grate leaks//! (docs/03-systems-design.md §3.3 law 4;//! docs/04-software-architecture.md §4.2 phase 4, §4.10 hard rules).//! Freeze/melt + gas-ignition/powder are Phase 8 hooks (noted, not run).//!//! Rules, in priority order per fluid-bearing cell://! 1. Water + fire is owned by the fire phase (`fire.rs` "water douses"://! clears BURNING, sets WET, STEAM amt 6). This module never touches//! water+fire — it leaves water beside flame alone for phase 6.//! (Freeze/melt are Phase 8 hooks, noted only.)//! 2. Oil beside flame is only marked (cause-link; ignition belongs to the//! fire phase, i.e. `fire.rs` — this module never sets BURNING).//! 3. Downhill flow: -z first, one unit per call ("volumes move downhill").//! 4. Pit fill-up: equalize with the lowest same-kind horizontal neighbor//! ("fill pits upward").//! 5. Grate leaks: a passable below-cell (`occ == 0`, or GLASS which marks//! glass pipe/channel voxels) admits flow; GLASS passages emit a//! throttled "grates leak" link.//! 6. Blood stamps BLOOD scent on every cell it occupies (butchery trails).//!//! Determinism: iteration is x-fastest/z-slowest over the bounded region,//! ties break via the passed-in `Rng`, integer math only, no allocation in//! the scan. Budget: at most `max_updates` cell-updates per call; returns//! `(updated, spillover)` so the kernel can queue the remainder.//!//! Scale assumption (flag for parent/Phase 8): `fluid_amt` runs 0–8 units//! per cell (`MAX_FLUID_AMT`); `gas_amt` runs 0–16. Storage is u8 either//! way; only the working range is assumed here.
use crate::cause::{CauseChain, CauseLink};use crate::rng::Rng;use crate::voxel::{material, ChunkedGrid, Voxel};
/// Fluid kinds (`Voxel.fluid_kind`).pub mod fluid { /// No fluid. pub const NONE: u8 = 0; /// Water: swims, douses, conducts; douse owned by fire.rs. pub const WATER: u8 = 1; /// Oil: burns, slicks, floats; ignition deferred to fire.rs. pub const OIL: u8 = 2; /// Blood: trackable, weak acid per species (see `materials.rs`). pub const BLOOD: u8 = 3; /// Bile/sewage: disease, verdigris (see `materials.rs`). pub const BILE: u8 = 4; /// Aether-mist: wild magic (cookoff hook lives in fire.rs). pub const AETHER_MIST: u8 = 5;}
/// Gas kinds (`Voxel.gas_kind`).pub mod gas { /// No gas. pub const NONE: u8 = 0; /// Smoke (fire.rs spawn; rises — advection owned by gas workstream). pub const SMOKE: u8 = 1; /// Spores. pub const SPORES: u8 = 2; /// Chlorine (sinks; gas hazard ids live in `materials::corrodes_gas`). pub const CHLORINE: u8 = 3; /// Steam (written by fire.rs douse; rises — advection owned by gas workstream). pub const STEAM: u8 = 4; /// Mist. pub const MIST: u8 = 5;}
/// Voxel flag bits (shared contract).pub mod flag { /// Cell is on fire. pub const BURNING: u8 = 0x01; /// Cell is wet (damp, doused). pub const WET: u8 = 0x02; /// Cell is rusted. pub const RUSTED: u8 = 0x04; /// Lighting must be recomputed here. pub const LIGHT_DIRTY: u8 = 0x08; /// A secret here stands revealed. pub const SECRET_REVEALED: u8 = 0x10; /// Structural load must be rechecked here. pub const SUPPORT_DIRTY: u8 = 0x20;}
/// Scent kinds for `Voxel.scent = (kind << 5) | intensity`.pub mod scent_kind { /// No scent. pub const NONE: u8 = 0; /// Blood (butchery trails). pub const BLOOD: u8 = 1; /// Oil. pub const OIL: u8 = 2; /// Rot. pub const ROT: u8 = 3; /// Ozone. pub const OZONE: u8 = 4; /// Wet wool. pub const WOOL: u8 = 5; /// Smoke (masks other scents; shared contract names this SMOKE_MASK). pub const SMOKE_MASK: u8 = 6;}
/// Working-range fluid capacity per cell (storage is u8; see module docs).pub const MAX_FLUID_AMT: u8 = 8;/// Default per-call cell-update budget (docs/04 §4.2: 4k fluid updates/tick).pub const MAX_UPDATES_PER_CALL: usize = 4000;
/// Per-tick fluid configuration: just the update budget. The free/// [`step_fluids`] uses the default budget; construct this for custom caps.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct FluidTick { /// Maximum cell-updates before returning spillover. pub max_updates: usize,}
impl FluidTick { /// Custom budget. pub fn new(max_updates: usize) -> Self { Self { max_updates } }
/// Run the fluids phase over the bounded region `[region_min, region_max]` /// (inclusive). Returns `(updated, spillover)`. pub fn step( &self, grid: &mut ChunkedGrid, region_min: (i32, i32, i32), region_max: (i32, i32, i32), rng: &mut Rng, causes: &mut CauseChain, tick: u64, ) -> (usize, usize) { step_capped( grid, region_min, region_max, self.max_updates, rng, causes, tick, ) }}
impl Default for FluidTick { fn default() -> Self { Self { max_updates: MAX_UPDATES_PER_CALL, } }}
/// Run the fluids phase over the bounded region with the default 4000-cell/// budget. Returns `(updated, spillover)`; `spillover > 0` means the kernel/// should queue the region remainder for a later tick.pub fn step_fluids( grid: &mut ChunkedGrid, region_min: (i32, i32, i32), region_max: (i32, i32, i32), rng: &mut Rng, causes: &mut CauseChain, tick: u64,) -> (usize, usize) { FluidTick::default().step(grid, region_min, region_max, rng, causes, tick)}
const DIRS6: [(i32, i32, i32); 6] = [ (-1, 0, 0), (1, 0, 0), (0, -1, 0), (0, 1, 0), (0, 0, -1), (0, 0, 1),];
const DIRS4: [(i32, i32, i32); 4] = [(-1, 0, 0), (1, 0, 0), (0, -1, 0), (0, 1, 0)];
/// Per-call throttles so rare hooks (grate/oil links) fire once per call/// instead of once per cell.#[derive(Default)]struct LinkThrottle { oil_marked: bool, grate_marked: bool,}
fn scent_of(kind: u8, intensity: u8) -> u8 { (kind << 5) | (intensity & 0x1F)}
fn scent_kind_of(scent: u8) -> u8 { scent >> 5}
/// A fluid may enter open cells (`occ == 0`: air, grates, door gaps) and/// GLASS cells, which mark glass pipe/channel voxels ("flow through/// occ==0 / GLASS"). Anything else is solid to fluids.fn enterable(v: Voxel) -> bool { v.occ == 0 || v.material == material::GLASS}
fn at(grid: &ChunkedGrid, x: i32, y: i32, z: i32) -> Voxel { grid.get_world(x, y, z)}
fn burning_at(grid: &ChunkedGrid, x: i32, y: i32, z: i32) -> bool { at(grid, x, y, z).flags & flag::BURNING != 0}
fn near_burning(grid: &ChunkedGrid, x: i32, y: i32, z: i32, self_v: Voxel) -> bool { if self_v.flags & flag::BURNING != 0 { return true; } DIRS6 .iter() .any(|(dx, dy, dz)| burning_at(grid, x + dx, y + dy, z + dz))}
fn stamp_blood(v: &mut Voxel) -> bool { if scent_kind_of(v.scent) != scent_kind::BLOOD { v.scent = scent_of(scent_kind::BLOOD, v.fluid_amt.min(31)); // No SUPPORT_DIRTY: scent is not structure state; dirtying here // triggers spurious structure rechecks. true } else { false }}
fn step_capped( grid: &mut ChunkedGrid, region_min: (i32, i32, i32), region_max: (i32, i32, i32), budget: usize, rng: &mut Rng, causes: &mut CauseChain, tick: u64,) -> (usize, usize) { let x0 = region_min.0.min(region_max.0); let x1 = region_min.0.max(region_max.0); let y0 = region_min.1.min(region_max.1); let y1 = region_min.1.max(region_max.1); let z0 = region_min.2.min(region_max.2); let z1 = region_min.2.max(region_max.2); let dx = (x1 - x0 + 1) as usize; let dy = (y1 - y0 + 1) as usize; let dz = (z1 - z0 + 1) as usize; let volume = dx * dy * dz;
let mut marks = LinkThrottle::default(); let mut updated: usize = 0; let mut stop_at = volume; 'scan: for iz in 0..dz { for iy in 0..dy { for ix in 0..dx { let idx = (iz * dy + iy) * dx + ix; let x = x0 + ix as i32; let y = y0 + iy as i32; let z = z0 + iz as i32; if process_cell(grid, (x, y, z), rng, causes, tick, &mut marks) { updated += 1; if updated >= budget { stop_at = idx + 1; break 'scan; } } } } }
let mut spillover: usize = 0; if stop_at < volume { for idx in stop_at..volume { let ix = idx % dx; let iy = (idx / dx) % dy; let iz = idx / (dx * dy); let v = at(grid, x0 + ix as i32, y0 + iy as i32, z0 + iz as i32); if v.fluid_amt > 0 { spillover += 1; } } } (updated, spillover)}
/// Process one cell; returns true when it consumed one unit of budget.fn process_cell( grid: &mut ChunkedGrid, pos: (i32, i32, i32), rng: &mut Rng, causes: &mut CauseChain, tick: u64, marks: &mut LinkThrottle,) -> bool { let (x, y, z) = pos; let mut v = at(grid, x, y, z); if v.fluid_amt == 0 || v.fluid_kind == fluid::NONE { return false; } let kind = v.fluid_kind;
// 1. Oil beside flame: mark only; ignition belongs to the fire phase. if kind == fluid::OIL && near_burning(grid, x, y, z, v) && !marks.oil_marked { marks.oil_marked = true; causes.push(CauseLink::new( tick, "fire & heat", "Oil shivers beside flame; ignition belongs to the fire phase.", )); return true; }
// 2. Downhill flow (-z first), one unit per call. let below = at(grid, x, y, z - 1); if enterable(below) && (below.fluid_kind == fluid::NONE || (below.fluid_kind == kind && below.fluid_amt < v.fluid_amt)) && below.fluid_amt < MAX_FLUID_AMT { let mut dest = below; v.fluid_amt -= 1; if v.fluid_amt == 0 { v.fluid_kind = fluid::NONE; } v.flags |= flag::SUPPORT_DIRTY; dest.fluid_kind = kind; dest.fluid_amt = dest.fluid_amt.saturating_add(1).min(MAX_FLUID_AMT); dest.flags |= flag::SUPPORT_DIRTY; if kind == fluid::BLOOD { stamp_blood(&mut v); stamp_blood(&mut dest); } grid.set_world(x, y, z, v); grid.set_world(x, y, z - 1, dest); if dest.material == material::GLASS && !marks.grate_marked { marks.grate_marked = true; causes.push(CauseLink::new( tick, "grates leak", "Fluid finds a glass channel and leaks through — grates leak.", )); } return true; }
// 3. Pit fill-up: equalize toward the lowest same-kind horizontal // neighbor (empty passable cells read as amt 0 of any kind). let mut best: Option<((i32, i32, i32), u8)> = None; let mut ties: u64 = 0; for (dx, dy, dz) in DIRS4 { let p = (x + dx, y + dy, z + dz); let u = at(grid, p.0, p.1, p.2); if !enterable(u) || u.fluid_amt >= MAX_FLUID_AMT { continue; } if u.fluid_kind != fluid::NONE && u.fluid_kind != kind { continue; } match best { None => { best = Some((p, u.fluid_amt)); ties = 1; } Some((_, amt)) if u.fluid_amt < amt => { best = Some((p, u.fluid_amt)); ties = 1; } Some((_, amt)) if u.fluid_amt == amt => { ties += 1; if rng.below(ties) == 0 { best = Some((p, u.fluid_amt)); } } _ => {} } } if let Some(((bx, by, bz), bamt)) = best { if bamt < v.fluid_amt && v.fluid_amt - bamt >= 2 { let mut mv = (v.fluid_amt - bamt) / 2; mv = mv.min(MAX_FLUID_AMT - bamt); if mv > 0 { let mut dest = at(grid, bx, by, bz); v.fluid_amt -= mv; if v.fluid_amt == 0 { v.fluid_kind = fluid::NONE; } v.flags |= flag::SUPPORT_DIRTY; dest.fluid_kind = kind; dest.fluid_amt = dest.fluid_amt.saturating_add(mv).min(MAX_FLUID_AMT); dest.flags |= flag::SUPPORT_DIRTY; if kind == fluid::BLOOD { stamp_blood(&mut v); stamp_blood(&mut dest); } grid.set_world(x, y, z, v); grid.set_world(bx, by, bz, dest); return true; } } }
// 4. Blood with nowhere to run still marks its cell (butchery trails). if kind == fluid::BLOOD && stamp_blood(&mut v) { grid.set_world(x, y, z, v); return true; } false}
#[cfg(test)]mod tests { use super::*; use crate::voxel::material;
fn open() -> Voxel { Voxel::default() }
fn solid(mat: u8) -> Voxel { Voxel { material: mat, occ: 1, ..Default::default() } }
fn fluid(mat: u8, kind: u8, amt: u8) -> Voxel { Voxel { material: mat, fluid_kind: kind, fluid_amt: amt, ..Default::default() } }
fn setup() -> (ChunkedGrid, Rng, CauseChain) { ( ChunkedGrid::new(), Rng::from_seed(0xF1_01D5), CauseChain::new(), ) }
#[test] fn downhill_flows_down() { let (mut grid, mut rng, mut causes) = setup(); grid.set_world(0, 0, 1, fluid(material::AIR, fluid::WATER, 4)); let (updated, spill) = step_fluids(&mut grid, (0, 0, 0), (0, 0, 1), &mut rng, &mut causes, 1); assert_eq!(spill, 0); assert_eq!(updated, 1); assert_eq!(grid.get_world(0, 0, 1).fluid_amt, 3); let below = grid.get_world(0, 0, 0); assert_eq!(below.fluid_kind, fluid::WATER); assert_eq!(below.fluid_amt, 1); }
#[test] fn pit_fill_equalizes() { let (mut grid, mut rng, mut causes) = setup(); // Stone floor below; stone walls around so equalization has one target. for (x, y, z) in [ (0, 0, -1), (1, 0, -1), (-1, 0, 0), (2, 0, 0), (0, 1, 0), (0, -1, 0), (1, 1, 0), (1, -1, 0), ] { grid.set_world(x, y, z, solid(material::STONE)); } grid.set_world(0, 0, 0, fluid(material::AIR, fluid::WATER, 6)); step_fluids(&mut grid, (0, 0, 0), (1, 0, 0), &mut rng, &mut causes, 2); assert_eq!(grid.get_world(0, 0, 0).fluid_amt, 3); let east = grid.get_world(1, 0, 0); assert_eq!(east.fluid_kind, fluid::WATER); assert_eq!(east.fluid_amt, 3); // Nothing leaked through the solid floor. assert_eq!(grid.get_world(0, 0, -1).fluid_amt, 0); }
#[test] fn glass_channel_leaks_and_links() { let (mut grid, mut rng, mut causes) = setup(); let channel = Voxel { material: material::GLASS, occ: 1, ..Default::default() }; grid.set_world(0, 0, 0, channel); grid.set_world(0, 0, 1, fluid(material::AIR, fluid::WATER, 3)); step_fluids(&mut grid, (0, 0, 0), (0, 0, 1), &mut rng, &mut causes, 3); let dest = grid.get_world(0, 0, 0); assert_eq!(dest.fluid_kind, fluid::WATER); assert_eq!(dest.fluid_amt, 1); assert!(causes.links().iter().any(|l| l.rule == "grates leak")); }
#[test] fn water_beside_flame_left_for_fire_phase() { // Water + fire is owned by fire.rs ("water douses"). The fluids // phase must not boil, move, or link it — phase 6 handles it. // Stone floor below blocks downhill flow so non-interference is // observable; stone walls block pit fill-up. let (mut grid, mut rng, mut causes) = setup(); for (x, y, z) in [ (0, 0, -1), (1, 0, -1), (-1, 0, 0), (2, 0, 0), (0, 1, 0), (0, -1, 0), ] { grid.set_world(x, y, z, solid(material::STONE)); } grid.set_world(0, 0, 0, fluid(material::AIR, fluid::WATER, 4)); let burner = Voxel { material: material::WOOD, occ: 1, flags: flag::BURNING, ..Default::default() }; grid.set_world(1, 0, 0, burner); step_fluids(&mut grid, (0, 0, 0), (1, 0, 0), &mut rng, &mut causes, 4); let v = grid.get_world(0, 0, 0); assert_eq!(v.fluid_kind, fluid::WATER); assert_eq!(v.fluid_amt, 4); assert_eq!(v.gas_kind, gas::NONE); assert!(v.flags & flag::WET == 0); assert!(causes.links().is_empty()); }
#[test] fn blood_sets_scent() { let (mut grid, mut rng, mut causes) = setup(); // Stone floor + walls isolate the scent-only arm (no downhill flow, // no pit fill-up), so the assert below tests the stamp itself. for (x, y, z) in [(0, 0, -1), (-1, 0, 0), (1, 0, 0), (0, 1, 0), (0, -1, 0)] { grid.set_world(x, y, z, solid(material::STONE)); } grid.set_world(0, 0, 0, fluid(material::AIR, fluid::BLOOD, 2)); step_fluids(&mut grid, (0, 0, 0), (0, 0, 0), &mut rng, &mut causes, 5); let v = grid.get_world(0, 0, 0); assert_eq!(scent_kind_of(v.scent), scent_kind::BLOOD); assert_eq!(open().scent, 0); assert!( v.flags & flag::SUPPORT_DIRTY == 0, "scent-only stamp must not dirty structure state" ); }
#[test] fn cap_yields_spillover() { let (mut grid, mut rng, mut causes) = setup(); for z in 0..8 { for y in 0..32 { for x in 0..32 { grid.set_world(x, y, z, fluid(material::AIR, fluid::WATER, 8)); } } } let (updated, spill) = step_fluids(&mut grid, (0, 0, 0), (31, 31, 7), &mut rng, &mut causes, 6); assert_eq!(updated, MAX_UPDATES_PER_CALL); assert!(spill > 0, "huge region must leave queued remainder"); }}