Files
music-video-gen/metabolism/test/world.test.js
T

108 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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('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');
});