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.
16 kB · 482 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483//! 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"); }}