Two root causes fixed end to end:
1. Exactness race: phaseFields' velocity pass wrote its own coarse blocks
and then read halo blocks no neighbour had committed yet — pool runs
diverged at random frames (2/4 runs). The velocity pass now runs as a
block-owner section at the END of phaseStrains; phaseFields only reads
committed velocities. 8/8 pool runs bit-exact through f1239; tests 9/9
(including 1600-frame x 2-worker runs across the section boundary).
2. Browser boot stall: beginPhase's hot spin (Atomics.wait throws on a
window) froze pending web-worker script loads — Chromium routes worker
startup through the busy main thread, so the show stalled 30s and
threw 'region pool stalled'. The web shell now acks ('pool') after
makeRegion (mail[63] is a real boot counter), the world runs cold until
every worker acks (step/stepTo skip frames; post-warm catch-up is
unbounded), and loadSongData awaits player.warm() before the show.
Node pools are untouched (native threads answer barriers directly).
Also: Player.build/rewind dispose the old pool (no more leaked idle
worlds); region-core.js is the shared worker body; region-worker.web.js
the browser shell (vite bundles it via the literal new Worker(new URL)
spelling — dist emits a self-contained worker asset instead of a broken
base64 data URL).
Circuit breaker in the demo: >=2 consecutive sub-25fps windows trip;
>=30 frames behind the audio clock for >=750ms trips (dormant while
stepTo catches up unconditionally — the fps path is live). 12/12 mirror
scenarios pass.
Bench: node serial 74.2ms/f, 2w 38.9 (1.91x), 4w 24.6 (3.02x). Browser:
steady 30fps including a 64s soak to 20k organisms at 640x360.
138 lines
6.7 KiB
JavaScript
138 lines
6.7 KiB
JavaScript
// The world must be deterministic, bounded, and fundamentally unstable under
|
||
// seed change — while two DIFFERENT songs must grow clearly different worlds.
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { resolve, dirname } from 'node:path';
|
||
import { analyse } from '../src/audio/analyse.js';
|
||
import { decodeWav, mono } from '../src/audio/wav.js';
|
||
import { buildSong } from '../src/song/song.js';
|
||
import { World } from '../src/sim/world.js';
|
||
import { Run } from '../src/film.js';
|
||
import { fingerprint, divergence } from '../src/metrics.js';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
||
|
||
function load(name) {
|
||
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
||
return buildSong(analyse(mono(wav.channels), wav.sampleRate), 12345);
|
||
}
|
||
|
||
/** Run `seconds` of the world for song `name` with a seed. Returns world. */
|
||
function grow(name, seed, seconds = 20) {
|
||
const song = load(name);
|
||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0 });
|
||
for (let f = 0; f < Math.floor(seconds * 30); f++) {
|
||
run.world.step();
|
||
run.camera.update(f);
|
||
}
|
||
return { world: run.world, song };
|
||
}
|
||
|
||
test('same seed reproduces identical history', () => {
|
||
const { world: a } = grow('drone', 12345);
|
||
const { world: b } = grow('drone', 12345);
|
||
assert.deepEqual(Array.from(a.history.alive), Array.from(b.history.alive));
|
||
assert.deepEqual(Array.from(a.history.movement), Array.from(b.history.movement));
|
||
assert.deepEqual(Array.from(a.trail[0]), Array.from(b.trail[0]));
|
||
assert.equal(a.frame, b.frame);
|
||
});
|
||
|
||
test('different seeds give different ecologies', () => {
|
||
const { world: a } = grow('drone', 12345);
|
||
const { world: b } = grow('drone', 99999);
|
||
const moved = a.history.movement.some((v, i) => Math.abs(v - b.history.movement[i]) > 1e-3);
|
||
assert.ok(moved, 'ecologies should diverge under a different seed');
|
||
});
|
||
|
||
test('population stays within bounds and alive', () => {
|
||
const { world: w } = grow('runner', 12345, 30);
|
||
for (const v of w.history.alive) {
|
||
assert.ok(v <= w.cap + 40, `population exceeded cap: ${v}`);
|
||
}
|
||
assert.ok(w.history.alive.some((v) => v > 0), 'the dish should stay inhabited');
|
||
});
|
||
|
||
test('field and trail never go negative', () => {
|
||
const { world: w } = grow('drone', 7, 25);
|
||
for (const v of w.resource) assert.ok(v >= -1e-9, `negative resource ${v}`);
|
||
for (let c = 0; c < 3; c++) {
|
||
for (const v of w.trail[c]) assert.ok(v >= -1e-9, `negative trail ${v}`);
|
||
}
|
||
});
|
||
|
||
test('trail mass reflects a busy vs sparse world', () => {
|
||
const sparse = grow('drone', 12345, 25);
|
||
const dense = grow('runner', 12345, 25);
|
||
assert.ok(dense.world.count > sparse.world.count * 1.5);
|
||
});
|
||
|
||
test('three songs grow fingerprints further apart than seeds of one song', () => {
|
||
const a = fingerprint(grow('drone', 12345).world);
|
||
const b = fingerprint(grow('runner', 12345).world);
|
||
const c = fingerprint(grow('dusk', 12345).world);
|
||
const cross = Math.max(divergence(a, b), divergence(a, c), divergence(b, c));
|
||
const a2 = fingerprint(grow('drone', 99).world);
|
||
const within = divergence(a, a2);
|
||
assert.ok(cross > within * 2, `cross ${cross} !> 2×within ${within}`);
|
||
});
|
||
|
||
test('genesis pre-simulates generations so frame 0 is already an ecosystem', () => {
|
||
const song = load('drone');
|
||
const withGenesis = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
|
||
const without = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
|
||
|
||
assert.ok(withGenesis.world.genesisFrames > 0, 'song should demand a genesis epoch');
|
||
// The world at frame 0 is more evolved (generations already lived)…
|
||
assert.ok(withGenesis.world.count > without.world.count * 1.1,
|
||
`genesis world should be more populated (${withGenesis.world.count} vs ${without.world.count})`);
|
||
// …and the ground already carries history, where a fresh plate has none.
|
||
assert.ok(withGenesis.world.sampleTrailMass() > 0.00003,
|
||
`genesis world should already hold ink (${withGenesis.world.sampleTrailMass()})`);
|
||
assert.equal(withGenesis.world.history.alive[0], 0, 'genesis must not record into the film');
|
||
assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');
|
||
});
|
||
|
||
test('the region pool is exact: parallel == serial, bit for bit', () => {
|
||
const grow = (workers, { frames = 150, seed = 12345 } = {}) => {
|
||
const song = load('drone');
|
||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0, workers });
|
||
for (let f = 0; f < frames; f++) { run.world.step(); run.camera.update(f); }
|
||
const history = Array.from(run.world.history.alive.slice(0, frames));
|
||
const trail = Array.from(run.world.trail[0].slice(0, 4000));
|
||
run.world.dispose?.();
|
||
return { history, trail };
|
||
};
|
||
const serial = grow(0);
|
||
const two = grow(2);
|
||
const four = grow(4);
|
||
const eq = (a, b) => a.history.every((v, i) => v === b.history[i]) &&
|
||
a.trail.every((v, i) => v === b.trail[i]);
|
||
assert.ok(eq(serial, two), '2 regions must match the serial world exactly');
|
||
assert.ok(eq(serial, four), '4 regions must match the serial world exactly');
|
||
|
||
// LONG: across a section boundary (drone's drop lands at frame 1545) and
|
||
// with a seed distinct from song.seed (the workers must be told the same
|
||
// seed — they seed their own jitter-noise fields). A 150-frame run inside
|
||
// one section with the default seed passes even with both bugs present.
|
||
const longSerial = grow(0, { frames: 1600, seed: 999 });
|
||
const longTwo = grow(2, { frames: 1600, seed: 999 });
|
||
const longEq = (a, b) => a.history.every((v, i) => v === b.history[i]) &&
|
||
a.trail.every((v, i) => v === b.trail[i]);
|
||
assert.ok(longEq(longSerial, longTwo),
|
||
'2 regions must match the serial world exactly across the section boundary');
|
||
});
|
||
|
||
test('genesis is deterministic and song-scaled', () => {
|
||
const a = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
|
||
const b = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
|
||
assert.equal(a.world.genesisFrames, b.world.genesisFrames);
|
||
assert.deepEqual(Array.from(a.world.pop[0]), Array.from(b.world.pop[0]));
|
||
|
||
const runner = new Run(load('runner'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
|
||
assert.notEqual(a.world.genesisFrames, runner.world.genesisFrames,
|
||
'the genesis amount should depend on the song');
|
||
}); |