//! Fire & heat phase: ignite, spread, smoke spawn, melt/dry/cookoff hooks, //! douse-to-steam (docs/03-systems-design.md §3.3 law 5; //! docs/04-software-architecture.md §4.2 phase 6, §4.10 hard rules). //! //! Rules per burning cell, in order: //! 1. Water (in-cell or 6-adjacent) extinguishes: clears BURNING, sets WET, //! spawns STEAM gas amt 6 (cause-link "water douses"). //! 2. Burn tick: spawn/raise SMOKE gas (smoke chokes — the canonical mimic //! kill, doc 01), stamp smoke scent (masks other scents), tick down the //! fuel counter (`Voxel.pad`, initialized from material on first sight //! of flame); exhausted fuel burns to AIR/ash (cause-link). `pad` is //! used because the 16-bit auxiliary voxel field is owned by other //! workstreams (air vent flags, rot perishable bit, structure crack //! flags) — a burning cell must not false-trigger any of them. //! 3. Hooks (link-only, throttled to one per call): GLASS-adjacent heat //! ("heat melts" — observation only, no mutation at slice fidelity); //! AETHER_MIST present ("wild surge" — the full miscast table is Phase 8). //! 4. Spread: each 6-neighbor rolls `rng.below(100) < rating` where rating //! mirrors `materials::flammable` (oil-wet counts 90); WET neighbors dry //! instead of catching ("heat dries"). //! //! Slice-local copies: the flammability table and flag/gas constants are //! duplicated here (not imported from `materials.rs`/`fluids.rs`) so each //! workstream-B file stands alone until the parent wires `lib.rs`; keep the //! ratings in sync with `materials::flammable` until Phase 8 merges them. //! //! Budget: at most 4000 cell-updates per call; returns `(updated, //! spillover)` like fluids. Gas scale assumption (flag for parent/Phase 8): //! smoke accumulates to a working cap of 16 (`gas_amt` storage is u8). use crate::cause::{CauseChain, CauseLink}; use crate::rng::Rng; use crate::voxel::{material, ChunkedGrid, Voxel}; /// Gas kinds written by this module (`Voxel.gas_kind`). pub mod gas { /// No gas. pub const NONE: u8 = 0; /// Smoke from burning fuel. pub const SMOKE: u8 = 1; /// Steam from doused water ("water douses", amt 6). pub const STEAM: u8 = 4; } /// 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; } /// Oil fluid id (an oil-wet cell ignites at rating 90). pub const OIL: u8 = 2; /// Water fluid id (douse checks; mirrors the shared fluid protocol). pub const WATER: u8 = 1; /// Aether-mist fluid id (wild-surge cookoff hook). pub const AETHER_MIST: u8 = 5; /// Smoke working cap for `gas_amt` (storage is u8; see module docs). pub const SMOKE_CAP: u8 = 16; /// Steam puff written on extinguish. pub const DOUSE_STEAM_AMT: u8 = 6; /// Default per-call cell-update budget (docs/04 §4.2: 4k updates/tick). pub const MAX_UPDATES_PER_CALL: usize = 4000; /// Run the fire phase over the bounded region `[region_min, region_max]` /// (inclusive). Returns `(updated, spillover)`; `spillover > 0` means the /// kernel should queue the region remainder for a later tick. pub fn step_fire( grid: &mut ChunkedGrid, region_min: (i32, i32, i32), region_max: (i32, i32, i32), 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 budget = Budget { used: 0, max: MAX_UPDATES_PER_CALL, }; 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 pos = (x0 + ix as i32, y0 + iy as i32, z0 + iz as i32); if !burn_cell(grid, pos, rng, causes, tick, &mut marks, &mut 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 = grid.get_world(x0 + ix as i32, y0 + iy as i32, z0 + iz as i32); if v.flags & flag::BURNING != 0 { spillover += 1; } } } (budget.used, spillover) } const DIRS6: [(i32, i32, i32); 6] = [ (-1, 0, 0), (1, 0, 0), (0, -1, 0), (0, 1, 0), (0, 0, -1), (0, 0, 1), ]; /// Per-call budget ledger. struct Budget { used: usize, max: usize, } impl Budget { /// Charge one cell-update; false means the budget is exhausted. fn spend(&mut self) -> bool { if self.used >= self.max { false } else { self.used += 1; true } } } /// Per-call throttles so observation hooks link once per call. #[derive(Default)] struct LinkThrottle { melt_linked: bool, surge_linked: bool, } /// Slice-local mirror of `materials::flammable` (see module docs). fn rating(mat: u8) -> u8 { match mat { material::PAPER => 100, material::ROPE => 70, material::WOOD => 60, material::FLESH => 15, _ => 0, } } /// Burn duration in ticks once lit (stored in `Voxel.pad`, u8; fuel 0–8 fits). /// The 16-bit auxiliary voxel field is NOT used: it carries air-vent flags, /// the rot perishable bit, and structure crack flags, all of which a nonzero /// fuel counter would false-trigger. fn fuel_ticks(mat: u8, oiled: bool) -> u8 { if oiled { return 6; } match mat { material::PAPER => 3, material::ROPE => 5, material::WOOD => 8, material::FLESH => 4, _ => 4, } } fn material_name(mat: u8) -> &'static str { match mat { material::WOOD => "timber", material::PAPER => "paper", material::ROPE => "rope", material::FLESH => "flesh", _ => "fuel", } } fn at(grid: &ChunkedGrid, x: i32, y: i32, z: i32) -> Voxel { grid.get_world(x, y, z) } /// Process one cell; false means the budget ran out (stop the scan). fn burn_cell( grid: &mut ChunkedGrid, pos: (i32, i32, i32), rng: &mut Rng, causes: &mut CauseChain, tick: u64, marks: &mut LinkThrottle, budget: &mut Budget, ) -> bool { let (x, y, z) = pos; let mut v = at(grid, x, y, z); if v.flags & flag::BURNING == 0 { return true; } // 1. Water douses: in-cell water or 6-adjacent water. let self_water = v.fluid_kind == WATER; let adjacent_water = DIRS6.iter().any(|(dx, dy, dz)| { let u = at(grid, x + dx, y + dy, z + dz); u.fluid_kind == WATER && u.fluid_amt > 0 }); if (self_water && v.fluid_amt > 0) || adjacent_water { if !budget.spend() { return false; } v.flags &= !flag::BURNING; v.flags |= flag::WET | flag::LIGHT_DIRTY | flag::SUPPORT_DIRTY; if self_water { v.fluid_kind = 0; v.fluid_amt = 0; } v.gas_kind = gas::STEAM; v.gas_amt = DOUSE_STEAM_AMT; grid.set_world(x, y, z, v); causes.push(CauseLink::new( tick, "water douses", "Water hisses the flame out; steam veils the way.", )); return true; } // 2a. Smoke spawn + smoke-scent mask (kind 6). if v.gas_kind == gas::NONE || v.gas_kind == gas::SMOKE { if !budget.spend() { return false; } v.gas_kind = gas::SMOKE; v.gas_amt = v.gas_amt.saturating_add(2).min(SMOKE_CAP); v.scent = (6 << 5) | (v.gas_amt.min(31) & 0x1F); v.flags |= flag::LIGHT_DIRTY; grid.set_world(x, y, z, v); } // Snapshot neighbors once (deterministic order, no allocation). let mut neighbors = [(0i32, 0i32, 0i32, Voxel::default()); 6]; for (i, (dx, dy, dz)) in DIRS6.iter().enumerate() { neighbors[i] = (x + dx, y + dy, z + dz, at(grid, x + dx, y + dy, z + dz)); } // 2b. Observation hooks (link-only, throttled, no budget). if !marks.melt_linked && neighbors .iter() .any(|(_, _, _, u)| u.material == material::GLASS) { marks.melt_linked = true; causes.push(CauseLink::new( tick, "heat melts", "The heat warps nearby glass — hotter or longer would melt it.", )); } if !marks.surge_linked && (v.fluid_kind == AETHER_MIST || neighbors .iter() .any(|(_, _, _, u)| u.fluid_kind == AETHER_MIST)) { marks.surge_linked = true; causes.push(CauseLink::new( tick, "wild surge", "Aether-mist boils in the heat — a wild surge scrambles the air (full miscast table lands in Phase 8).", )); } // 2c. Fuel tick; first sight of flame initializes the counter in `pad`. let oiled_self = v.fluid_kind == OIL && v.fluid_amt > 0; if v.pad == 0 { if !budget.spend() { return false; } v.pad = fuel_ticks(v.material, oiled_self); v.flags |= flag::SUPPORT_DIRTY; grid.set_world(x, y, z, v); } else { v.pad -= 1; if v.pad == 0 { if !budget.spend() { return false; } v.flags &= !flag::BURNING; v.flags |= flag::LIGHT_DIRTY | flag::SUPPORT_DIRTY; v.material = material::AIR; v.occ = 0; v.fluid_kind = 0; v.fluid_amt = 0; grid.set_world(x, y, z, v); causes.push(CauseLink::new( tick, "fire & heat", "The fuel burns to ash — fire climbs, ash falls.", )); return true; } if !budget.spend() { return false; } grid.set_world(x, y, z, v); } // 3. Spread to 6-neighbors by flammability roll; wet dries instead. for (nx, ny, nz, u) in neighbors { if u.flags & flag::BURNING != 0 { continue; } if u.flags & flag::WET != 0 { if !budget.spend() { return false; } let mut dried = u; dried.flags &= !flag::WET; dried.flags |= flag::LIGHT_DIRTY; grid.set_world(nx, ny, nz, dried); causes.push(CauseLink::new( tick, "heat dries", "Heat dries the damp out of it before it can catch.", )); continue; } let oiled = u.fluid_kind == OIL && u.fluid_amt > 0; let r = rating(u.material).max(if oiled { 90 } else { 0 }); if r == 0 { continue; } if rng.below(100) < u64::from(r) { if !budget.spend() { return false; } let mut lit = u; lit.flags |= flag::BURNING | flag::LIGHT_DIRTY | flag::SUPPORT_DIRTY; lit.pad = fuel_ticks(lit.material, oiled); grid.set_world(nx, ny, nz, lit); causes.push(CauseLink::new( tick, "fire & heat", if oiled { "Oil catches with a soft whumpf — fire climbs.".to_string() } else { format!("{} catches — fire climbs.", material_name(u.material)) }, )); } } true } #[cfg(test)] mod tests { use super::*; fn burning(mat: u8, fuel: u8) -> Voxel { Voxel { material: mat, occ: 1, flags: flag::BURNING, pad: fuel, ..Default::default() } } fn setup() -> (ChunkedGrid, Rng, CauseChain) { (ChunkedGrid::new(), Rng::from_seed(1), CauseChain::new()) } #[test] fn oil_ignites_beside_flame() { let (mut grid, mut rng, mut causes) = setup(); grid.set_world(0, 0, 0, burning(material::WOOD, 0)); let oil = Voxel { material: material::STONE, occ: 1, fluid_kind: OIL, fluid_amt: 4, ..Default::default() }; grid.set_world(1, 0, 0, oil); let mut lit = false; for t in 0..6 { step_fire(&mut grid, (0, 0, 0), (1, 0, 0), &mut rng, &mut causes, t); if grid.get_world(1, 0, 0).flags & flag::BURNING != 0 { lit = true; break; } } assert!(lit, "oil at rating 90 must catch within 6 ticks"); assert!(causes.links().iter().any(|l| l.rule == "fire & heat")); } #[test] fn water_extinguishes_and_steams() { let (mut grid, mut rng, mut causes) = setup(); let water = Voxel { fluid_kind: 1, fluid_amt: 4, ..Default::default() }; grid.set_world(0, 0, 0, water); grid.set_world(1, 0, 0, burning(material::WOOD, 5)); step_fire(&mut grid, (0, 0, 0), (1, 0, 0), &mut rng, &mut causes, 7); let v = grid.get_world(1, 0, 0); assert!(v.flags & flag::BURNING == 0); assert!(v.flags & flag::WET != 0); assert_eq!(v.gas_kind, gas::STEAM); assert_eq!(v.gas_amt, DOUSE_STEAM_AMT); assert!(causes.links().iter().any(|l| l.rule == "water douses")); } #[test] fn fuel_counter_lives_in_pad() { // First sight of flame initializes the fuel counter in `pad`. let (mut grid, mut rng, mut causes) = setup(); grid.set_world(0, 0, 0, burning(material::WOOD, 0)); step_fire(&mut grid, (0, 0, 0), (0, 0, 0), &mut rng, &mut causes, 11); let v = grid.get_world(0, 0, 0); assert!(v.pad > 0, "fuel counter lives in pad"); } #[test] fn smoke_spawns_on_burn() { let (mut grid, mut rng, mut causes) = setup(); grid.set_world(0, 0, 0, burning(material::WOOD, 0)); step_fire(&mut grid, (0, 0, 0), (0, 0, 0), &mut rng, &mut causes, 8); let v = grid.get_world(0, 0, 0); assert_eq!(v.gas_kind, gas::SMOKE); assert!(v.gas_amt > 0); } #[test] fn fuel_burns_to_ash() { let (mut grid, mut rng, mut causes) = setup(); grid.set_world(0, 0, 0, burning(material::WOOD, 1)); step_fire(&mut grid, (0, 0, 0), (0, 0, 0), &mut rng, &mut causes, 9); let v = grid.get_world(0, 0, 0); assert!(v.flags & flag::BURNING == 0); assert_eq!(v.material, material::AIR); } #[test] fn spread_capped() { let (mut grid, mut rng, mut causes) = setup(); for z in 0..8 { for y in 0..40 { for x in 0..40 { grid.set_world(x, y, z, burning(material::WOOD, 5)); } } } let (updated, spill) = step_fire(&mut grid, (0, 0, 0), (39, 39, 7), &mut rng, &mut causes, 10); assert_eq!(updated, MAX_UPDATES_PER_CALL); assert!(spill > 0, "huge burn must leave queued remainder"); } }