//! 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"); } }