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);