Back to Powder Tool
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool

Release
242076e6da24…
Author-recorded commit
c4489f440f27…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-48e516e34e5

Archive hash verified: f2fd072590aac7a5…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/simulation.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
const source = await readFile(new URL('../src/simulation.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
const module = { exports: {} }; runInNewContext(compiled, { module, exports: module.exports });
const { Simulation, makeScene, M, vertices } = module.exports;
const run = (sim, ticks) => { for (let i = 0; i < ticks; i++) sim.step(); };
const count = (sim, m) => sim.cells.filter(value => value === m).length;

test('sand falls, accumulates, and is conserved in a closed world', () => {
  const sim = new Simulation(80, 80); sim.paint(40, 10, 6, M.sand); const n = count(sim, M.sand); run(sim, 100);
  assert.equal(count(sim, M.sand), n); assert.equal(sim.get(40, 10), M.empty); assert.equal(sim.get(40, 79), M.sand);
});
test('water spreads across the floor without creating or losing particles', () => {
  const sim = new Simulation(80, 80); sim.paint(40, 25, 10, M.water); const n = count(sim, M.water); run(sim, 150);
  assert.equal(count(sim, M.water), n); assert.equal(sim.get(5, 79), M.water); assert.equal(sim.get(75, 79), M.water);
});
test('sand sinks through water and oil rises above it', () => {
  const sim = new Simulation(80, 80);
  for (let y = 50; y < 80; y++) for (let x = 0; x < 80; x++) sim.set(x, y, M.water);
  sim.set(40, 49, M.sand); sim.set(30, 78, M.oil); run(sim, 160);
  assert.equal(sim.get(40, 79), M.sand);
  const oil = sim.cells.findIndex(value => value === M.oil); assert.ok(Math.floor(oil / 80) <= 51);
});
test('water extinguishes fire and cools lava into wall plus steam', () => {
  for (const hot of [M.fire, M.lava]) {
    const sim = new Simulation(80, 80); sim.set(40, 40, hot); sim.set(41, 40, M.water);
    // Block the liquid so it stays beside the heat source for the reaction.
    for (let x = 39; x <= 42; x++) sim.set(x, 41, M.wall);
    sim.set(42, 40, M.wall); sim.step();
    assert.equal(count(sim, hot), 0); assert.ok(count(sim, M.steam) >= 1);
    if (hot === M.lava) assert.equal(sim.get(40, 40), M.wall);
  }
});
test('wood and oil ignite and fire expires', () => {
  const sim = new Simulation(80, 80);
  for (let y = 30; y <= 40; y++) for (let x = 30; x <= 40; x++) sim.set(x, y, M.wood);
  for (let x = 29; x <= 41; x++) sim.set(x, 29, M.fire, 180);
  run(sim, 70); assert.ok(count(sim, M.wood) < 121);
  run(sim, 600); assert.equal(count(sim, M.fire), 0);
});
test('rigid bodies settle on the floor without tunneling', () => {
  const sim = new Simulation(100, 100); sim.addBody('box', 30, 25, 8); sim.addBody('stone', 65, 20, 9); run(sim, 480);
  for (const b of sim.bodies) {
    const lowest = Math.max(...vertices(b).map(v => v.y)); assert.ok(lowest <= 100.3 && lowest > 98, `bottom ${lowest}`); assert.ok(Math.abs(b.vy) < 2, `velocity ${b.vy}`);
  }
});
test('a box lands on a drawn platform; two boxes remain stacked', () => {
  const sim = new Simulation(100, 100);
  for (let x = 5; x < 95; x++) for (let y = 85; y < 90; y++) sim.set(x, y, M.wall);
  sim.addBody('box', 50, 70, 8).angle = 0; sim.addBody('box', 50, 40, 8).angle = 0; run(sim, 480);
  const ys = sim.bodies.map(b => b.y).sort((a,b) => a-b);
  assert.ok(ys[1] < 78, JSON.stringify(ys)); assert.ok(ys[1] - ys[0] > 14, JSON.stringify(ys));
});
test('crates float while stones sink in a deep pool, conserving water', () => {
  const sim = new Simulation(140, 120);
  for (let y = 65; y < 120; y++) for (let x = 0; x < 140; x++) sim.set(x, y, M.water);
  sim.addBody('box', 35, 30, 8); sim.addBody('stone', 105, 30, 8);
  const n = count(sim, M.water); run(sim, 600);
  assert.equal(count(sim, M.water), n);
  assert.ok(sim.bodies[0].y < 80, `crate depth ${sim.bodies[0].y}`); assert.ok(sim.bodies[1].y > 105, `stone depth ${sim.bodies[1].y}`);
});
test('grab spring moves a body, and release preserves throwing velocity', () => {
  const sim = new Simulation(120, 100); const b = sim.addBody('box', 30, 30, 7);
  sim.grab = { id: b.id, x: 80, y: 30, localX: 4, localY: 0 }; run(sim, 20);
  assert.ok(b.x > 40); assert.ok(Math.abs(b.omega) > .01); sim.grab = null; const x = b.x; sim.step(); assert.ok(b.x > x);
});
test('zero gravity suspends sand and bodies', () => {
  const sim = new Simulation(80, 80); sim.gravity = 0; sim.set(20, 20, M.sand); const b = sim.addBody('box', 50, 30, 6); run(sim, 80);
  assert.equal(sim.get(20, 20), M.sand); assert.equal(b.y, 30);
});
test('save round-trip preserves particles, motion and settings, and rejects corrupt data', () => {
  const sim = makeScene('playground', 120, 100); run(sim, 20); sim.gravity = .5;
  const snapshot = JSON.parse(JSON.stringify(sim.snapshot())); const restored = Simulation.restore(snapshot);
  assert.deepEqual(JSON.parse(JSON.stringify(restored.snapshot())), snapshot);
  assert.throws(() => Simulation.restore({ ...snapshot, width: 50000 }));
  assert.throws(() => Simulation.restore({ ...snapshot, cells: [8, 999999999] }));
  assert.throws(() => Simulation.restore({ ...snapshot, bodies: [{ ...snapshot.bodies[0], vx: Infinity }] }));
});
test('every preset evolves without NaN, escaped bodies, or exceeding storage quota', () => {
  for (const name of ['playground', 'waterworks', 'furnace']) {
    const sim = makeScene(name); run(sim, 120);
    assert.ok(JSON.stringify(sim.snapshot()).length < 480000);
    for (const b of sim.bodies) assert.ok(Number.isFinite(b.x) && Number.isFinite(b.angle) && b.x >= 0 && b.x <= sim.width && b.y <= sim.height, name);
  }
});

test('light gases rise at different rates, heavy CO2 sinks, and smoke expires', () => {
  const positions = {};
  for (const material of [M.hydrogen, M.helium, M.smoke, M.co2]) {
    const sim = new Simulation(100, 120); sim.set(50, 60, material); run(sim, 30);
    const i = sim.cells.findIndex(value => value === material); assert.ok(i >= 0);
    positions[material] = Math.floor(i / 100);
  }
  assert.ok(positions[M.hydrogen] < positions[M.smoke]); assert.ok(positions[M.helium] < positions[M.smoke]);
  assert.ok(positions[M.smoke] < 60); assert.ok(positions[M.co2] > 60);
  const sim = new Simulation(80, 80); sim.gravity = 0; sim.set(40, 40, M.smoke, 3); run(sim, 4); assert.equal(count(sim, M.smoke), 0);
});
test('hydrogen burns, helium stays inert, and CO2 smothers nearby fire', () => {
  for (const material of [M.hydrogen, M.helium, M.co2]) {
    const sim = new Simulation(80, 80); sim.gravity = 0; sim.set(40, 40, M.fire, 100); sim.set(41, 40, material); sim.step();
    if (material === M.hydrogen) { assert.equal(count(sim, M.hydrogen), 0); assert.equal(count(sim, M.fire), 2); }
    if (material === M.helium) assert.equal(count(sim, M.helium), 1);
    if (material === M.co2) { assert.equal(count(sim, M.fire), 0); assert.equal(count(sim, M.co2), 1); }
  }
});
test('a ceiling traps inert gas and the closed floor retains heavy gas', () => {
  for (const material of [M.helium, M.co2]) {
    const sim = new Simulation(80, 80);
    for (let x = 0; x < 80; x++) sim.set(x, 10, M.wall);
    sim.paint(40, 40, 8, material); const n = count(sim, material); run(sim, 130); assert.equal(count(sim, material), n);
    const rows = [...sim.cells.entries()].filter(([,v])=>v===material).map(([i])=>Math.floor(i/80));
    assert.ok(material===M.helium ? Math.max(...rows)<20 : Math.min(...rows)>70);
  }
});
const settings = (extra={}) => ({material:M.sand,rate:60,radius:6,direction:'down',enabled:true,...extra});
test('emitters produce their configured rate, direction and material; disable stops flow', () => {
  const sim = new Simulation(120, 120); const e = sim.addEmitter(60, 20, settings()); run(sim, 60); assert.equal(count(sim,M.sand),60);
  assert.ok([...sim.cells.entries()].filter(([,v])=>v===M.sand).every(([i])=>Math.floor(i/120)>20));
  sim.updateEmitter(e.id,settings({enabled:false}));run(sim,60);assert.equal(count(sim,M.sand),60);
  sim.updateEmitter(e.id,settings({material:M.helium,direction:'up',enabled:true}));sim.step();assert.equal(count(sim,M.helium),1);
  assert.ok(Math.floor(sim.cells.findIndex(v=>v===M.helium)/120)<20);
});
test('blocked emitters never replace walls or accumulate delayed bursts', () => {
  const sim = new Simulation(80,80); const e = sim.addEmitter(40,40,settings({rate:600}));
  for(let y=41;y<60;y++)for(let x=20;x<60;x++)sim.set(x,y,M.wall);
  const n=count(sim,M.wall);run(sim,60);assert.equal(count(sim,M.wall),n);assert.equal(count(sim,M.sand),0);
  for(let y=41;y<60;y++)for(let x=20;x<60;x++)sim.set(x,y,M.empty);
  sim.step();assert.ok(count(sim,M.sand)<=10);assert.ok(e.carry<1);
});
test('emitter configuration and fractional rate survive save/load exactly', () => {
  const sim = new Simulation(100,100);sim.addEmitter(50,20,settings({rate:31,radius:8,direction:'right'}));run(sim,7);
  const data=sim.snapshot(); const restored=Simulation.restore(JSON.parse(JSON.stringify(data)));
  assert.deepEqual(JSON.parse(JSON.stringify(restored.snapshot())),JSON.parse(JSON.stringify(data)));
  assert.throws(()=>Simulation.restore({...data,emitters:[{...data.emitters[0],rate:1e9}]}));
  assert.throws(()=>Simulation.restore({...data,emitters:[{...data.emitters[0],direction:'diagonal'}]}));
  assert.throws(()=>Simulation.restore({...data,emitters:[{...data.emitters[0],x:-1}]}));
  assert.throws(()=>Simulation.restore({...data,emitters:[data.emitters[0],data.emitters[0]]}));
  assert.equal(sim.addEmitter(1,1,settings({rate:NaN})),null);
});
test('higher resolution preserves stage geometry, bodies and emitters', () => {
  const sim = new Simulation(300,200);sim.set(50,60,M.wood);sim.set(90,60,M.helium);sim.addBody('box',120,80,10);sim.addEmitter(200,40,settings());
  const next=sim.resized(600,400);
  assert.equal(next.get(100,120),M.wood);assert.equal(next.get(101,121),M.wood);assert.equal(count(next,M.helium),4);
  assert.equal(next.bodies[0].x,240);assert.equal(next.bodies[0].size,20);assert.equal(next.emitters[0].x,400);
  assert.equal(next.cells.length,sim.cells.length*4);
  const back=next.resized(300,200);assert.deepEqual([...back.cells],[...sim.cells]);
});
test('legacy version 1 scenes load and compact high-resolution saves stay within the host quota', () => {
  const old={version:1,width:80,height:80,gravity:1,seed:123,cells:[0,6400],life:[0,6400],bodies:[]};
  const sim=Simulation.restore(old);assert.equal(sim.width,80);assert.equal(sim.emitters.length,0);assert.equal(sim.snapshot().version,2);
  const scene=makeScene('gaslab',600,400);run(scene,30);const json=JSON.stringify(scene.snapshot());
  assert.ok(json.length<480000,`save ${json.length} bytes`);
  assert.deepEqual(JSON.parse(JSON.stringify(Simulation.restore(JSON.parse(json)).snapshot())),JSON.parse(json));
});
test('erasing deletes a source and a snapshot restores it; resolution keeps its configured rate', () => {
  const sim=new Simulation(300,200);sim.addEmitter(150,70,settings({rate:90}));const saved=sim.snapshot();
  sim.paint(150,70,2,M.empty);assert.equal(sim.emitters.length,0);
  const restored=Simulation.restore(saved);assert.equal(restored.emitters.length,1);assert.equal(restored.resized(600,400).emitters[0].rate,90);
});

test('legacy wide scenes retain the exact geometry of their smallest bodies', () => {
  const old={version:1,width:400,height:200,gravity:1,seed:123,cells:[0,65535,0,14465],life:[0,65535,0,14465],bodies:[{kind:'box',x:150,y:40,vx:0,vy:0,angle:0,omega:0,size:4,heat:0}]};
  const sim=Simulation.restore(old);assert.equal(sim.bodies[0].size,4);assert.equal(Math.max(...vertices(sim.bodies[0]).map(p=>p.x))-Math.min(...vertices(sim.bodies[0]).map(p=>p.x)),8);
});