← Natura·Docs/Modules/Source

fire-test.js

106 lines · in natura-src-1.0.0.zip as natura/fire-test.js · a test

// fire-test.js -- assertions for the Fire system (ignition, spread, coupling to vegetation).
const WG = require('./worldgen.js');
const N = require('./natura.js');
const { makeWeather } = require('./weather.js');
const { makeVegetation } = require('./vegetation.js');
const { makeFire } = require('./fire.js');
const { World, UNITS } = N;
const GRID = 48, HOUR = UNITS.HOUR, DAY = UNITS.DAY;

let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };

function build(seed, fireCfg) {
  const cont = WG.makeContinent(seed, { period: 6, shore: 0.5 });
  const el = (x, y) => cont.heightAt(x, y);
  return World({ seed, systems: [
    makeWeather({ N: GRID, seaLevel: 0, elevation: el, prevailU: GRID * 0.05, prevailV: GRID * 0.012, systems: 5 }),
    makeVegetation({ N: GRID, weather: 'weather', waterK: 13, drainDays: 40, rate: 0.75, respire: 0.06 }),
    makeFire(Object.assign({ N: GRID, vegetation: 'vegetation', weather: 'weather', elevation: el, igniteP: 0 }, fireCfg))
  ] });
}
function spin(w, days, dt) { const n = Math.round(days * 86400 / dt); for (let i = 0; i < n; i++) w.step(dt); }
const dryOf = (sw) => 0.3 / (sw + 0.3);

// grow the fuel, then set a hand-lit fire at the most flammable cell (biomass x dryness); run it
function lightAndRun(seed, days, fireCfg) {
  const w = build(seed, fireCfg);
  spin(w, 90, HOUR * 3);                                // grow the vegetation (the fuel)
  const veg = w.get('vegetation'), fire = w.get('fire');
  const bio = veg.bio, sw = veg.sw, land = veg.land;
  let best = -1, bestF = 0;
  for (let k = 0; k < GRID * GRID; k++) if (land[k]) { const f = bio[k] * dryOf(sw[k]); if (f > bestF) { bestF = f; best = k; } }
  const bioBefore = Float32Array.from(bio);
  fire.burning[best] = 1;                               // the strike
  let totalBurned = 0;
  for (let i = 0; i < Math.round(days * 86400 / (HOUR * 3)); i++) { w.step(HOUR * 3); const bd = fire.burned; for (let k = 0; k < GRID * GRID; k++) totalBurned += bd[k]; }
  return { w, veg, fire, best, bioBefore, totalBurned };
}

console.log('fire assertions:');

// 1. SPREADS: a single strike grows into a burn covering many cells (not just the one lit)
{
  const { fire } = lightAndRun(7, 20, { spreadK: 8 });
  let scarred = 0; for (let k = 0; k < GRID * GRID; k++) if (fire.scar[k] > 0.2) scarred++;
  ok(scarred > 4, 'a single strike spreads into a multi-cell burn (' + scarred + ' scarred cells)');
}

// 2. DETERMINISM: same seed + same strike -> identical scar
{
  const a = lightAndRun(7, 20, { spreadK: 8 }).fire, b = lightAndRun(7, 20, { spreadK: 8 }).fire;
  let same = a.best === b.best; for (let k = 0; k < GRID * GRID && same; k++) if (Math.abs(a.scar[k] - b.scar[k]) > 1e-6) same = false;
  ok(same, 'same seed + strike -> identical burn (reproducible)');
}

// 3. FIRE CONSUMES VEGETATION: the burn eats real biomass (the coupling into vegetation). Regrowth is
//    fast, so the clearing is transient -- the honest measure is the total biomass CONSUMED over the burn.
{
  const r = lightAndRun(7, 20, { spreadK: 8 });
  ok(r.totalBurned > 0.8, 'fire consumes real vegetation as it burns (total biomass burned ' + r.totalBurned.toFixed(2) + ')');
}

// 4. WET GROUND is a FIREBREAK: cells that were sodden do not carry the fire
{
  const { veg, fire } = lightAndRun(7, 20, { spreadK: 8 });
  let wetBurned = 0, wetTot = 0;
  for (let k = 0; k < GRID * GRID; k++) if (veg.land[k] && veg.sw[k] > 1.0) { wetTot++; if (fire.scar[k] > 0.2) wetBurned++; }
  ok(wetTot === 0 || wetBurned / wetTot < 0.15, 'wet ground resists burning (' + wetBurned + '/' + wetTot + ' sodden cells burned)');
}

// 5. SCARS HEAL: a burn scar fades over time as the plants return (the fade mechanism, in isolation)
{
  const w = build(7, { scarFade: 0.03 }); spin(w, 40, HOUR * 3);
  const fire = w.get('fire'), land = w.get('vegetation').land; fire.burning.fill(0);
  const marks = []; for (let k = 0; k < GRID * GRID && marks.length < 8; k += 53) if (land[k]) { fire.scar[k] = 0.8; marks.push(k); }
  spin(w, 90, HOUR * 6);                                             // no ignition (igniteP 0) -> scars just fade
  let mx = 0; for (const k of marks) mx = Math.max(mx, fire.scar[k]);
  ok(mx < 0.7, 'burn scars heal over time (painted 0.80 -> max ' + mx.toFixed(2) + ')');
}

// 6. NATURAL IGNITION: with lightning on, a long dry run starts at least one fire on its own
{
  const w = build(3, { igniteP: 0.5, spreadK: 8 });
  let everBurned = false;
  for (let i = 0; i < Math.round(300 * 86400 / (HOUR * 3)); i++) { w.step(HOUR * 3); if (w.get('fire').active > 0) { everBurned = true; break; } }
  ok(everBurned, 'lightning ignites a fire on its own over a long dry spell');
}

// 7. EMBER SPOTTING: strong wind carries fire ACROSS a no-fuel firebreak that adjacent spread cannot cross
{
  const G = 40;
  const mkVeg = (gap) => { const bio = new Float32Array(G * G).fill(1), sw = new Float32Array(G * G).fill(0.15), land = new Uint8Array(G * G).fill(1);
    for (let y = 0; y < G; y++) for (let x = gap; x < gap + 4; x++) bio[y * G + x] = 0;   // a 4-cell no-fuel firebreak strip
    return { name: 'vegetation', domain: 'life', scale: 'ecological', params: {}, init() { return { bio, sw, land }; }, step(s) { return s; }, fields(s) { return { N: G, biomass: s.bio, soilWater: s.sw, land: s.land }; } }; };
  const wind = { name: 'weather', domain: 'physical', scale: 'weather', params: {}, init() { return {}; }, step(s) { return s; },
    fields() { return { N: G, windU: new Float32Array(G * G).fill(3.0), windV: new Float32Array(G * G).fill(0) }; } };
  const beyond = (fire) => { let n = 0; for (let y = 0; y < G; y++) for (let x = 16; x < G; x++) if (fire.scar[y * G + x] > 0.2 || fire.burning[y * G + x] > 0) n++; return n; };  // gap at x=12..15
  const runEmber = (emberK) => { const w = World({ seed: 5, systems: [mkVeg(12), wind, makeFire({ N: G, vegetation: 'vegetation', weather: 'weather', elevation: () => 0.2, igniteP: 0, spreadK: 8, emberK })] });
    const fire = w.get('fire'); for (let y = 18; y < 22; y++) fire.burning[y * G + 6] = 1;   // light a fire WEST of the gap
    for (let i = 0; i < 160; i++) w.step(6 * HOUR); return beyond(fire); };
  ok(runEmber(0) === 0, 'without embers, a no-fuel firebreak STOPS the fire (nothing catches beyond it)');
  ok(runEmber(0.8) > 0, 'with embers, strong wind JUMPS the firebreak (' + runEmber(0.8) + ' cells caught beyond it)');
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);