import { test } from 'node:test'; import assert from 'node:assert/strict'; import { venues } from '../src/venues/registry.js'; import { generateVenue } from '../src/venues/VenueGenerator.js'; import { generateActorSet } from '../src/actors/ActorGenerator.js'; import { generatePersonality } from '../src/look/Personality.js'; import { generateIdentity } from '../src/look/Identity.js'; import { Rng } from '../src/engine/rng.js'; import { Compositor } from '../src/engine/Compositor.js'; const SUMMARY = { meanCentroid: 0.5, meanFlatness: 0.15, bpm: 120, dynamicRange: 0.6, meanLoudness: 0.4, }; function personalityAt(seed) { const rng = new Rng(seed); return generatePersonality(SUMMARY, rng.fork('personality'), () => 10, 4); } test('every venue declares a reach rating in (0, 2]', () => { assert.ok(venues.length > 0, 'no venues registered'); for (const v of venues) { assert.equal(typeof v.reach, 'number', `${v.name}: missing reach rating`); assert.ok(v.reach > 0 && v.reach <= 2, `${v.name}: reach ${v.reach} outside (0, 2]`); } }); test('venue spec carries a stable seed and per-section evolution', () => { const rng = new Rng(1234); const story = { sections: [ { act: 'setup' }, { act: 'development' }, { act: 'climax' }, { act: 'resolution' }, ], }; const venue = generateVenue({ summary: SUMMARY, rng: rng.fork('venue'), personality: personalityAt(99), director: null, story, sections: [{}, {}, {}, {}], }); assert.ok(venue.seed !== undefined && Number.isInteger(venue.seed)); assert.equal(venue.perSection.length, 4); // Climax is the brightest, most populated point of the arc. assert.ok(venue.perSection[2].light >= venue.perSection[0].light, 'climax should be at least as bright as setup'); assert.ok(venue.perSection[2].popMul >= venue.perSection[3].popMul, 'climax should be at least as populated as resolution'); // Deterministic: same seed, same spec. const again = generateVenue({ summary: SUMMARY, rng: new Rng(1234).fork('venue'), personality: personalityAt(99), director: null, story, sections: [{}, {}, {}, {}], }); assert.equal(again.seed, venue.seed); assert.deepEqual(again.perSection, venue.perSection); assert.deepEqual(again.rearrangeAt, venue.rearrangeAt); }); test('rearrangeAt only jumps on act changes, and the recap returns home', () => { const story = { recap: true, sections: [ { act: 'setup' }, { act: 'development' }, { act: 'development' }, { act: 'climax' }, { act: 'resolution' }, ], }; const mk = (seed) => generateVenue({ summary: SUMMARY, rng: new Rng(seed).fork('venue'), personality: personalityAt(99), director: null, story, sections: [{ kind: 'intro' }, { kind: 'build' }, { kind: 'drop' }, { kind: 'drop' }, { kind: 'outro' }], }); const venue = mk(777); assert.equal(venue.rearrangeAt.length, 5); // Same-act section never rearranges. assert.equal(venue.rearrangeAt[2], venue.rearrangeAt[1], 'same-act section must keep its arrangement'); // Recap outro returns to the intro's room. assert.equal(venue.rearrangeAt[4], venue.rearrangeAt[0], 'recap outro must return to arrangement 0'); // Arrangement ids never decrease except at the recap. for (let i = 1; i < 4; i++) { assert.ok(venue.rearrangeAt[i] >= venue.rearrangeAt[i - 1], `arrangement went backwards at section ${i}`); } // Stable across regenerations. assert.deepEqual(mk(777).rearrangeAt, venue.rearrangeAt); }); test('venue room camera walks on a bounded, deterministic path', () => { // No WebGL needed: drive the camera method on a bare object. const comp = Object.create(Compositor.prototype); comp.sharedCamera = { position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } }, rotation: {}, lookAt() {}, updateMatrixWorld() {}, }; const personality = personalityAt(7); const venue = { archetype: 'chamber', seed: 4242, perSection: [{ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 }], }; const at = (time, journey) => { comp._updateVenueCamera(comp.sharedCamera, { framing: { scale: 1, shift: [0, 0] }, personality, time, venue, journey, // Production always passes progress (Show.js); the journey-only // fallback exists for hand-built paths. Measure the real path. progress: time / 400, }); return { ...comp.sharedCamera.position }; }; const a = at(10, 0.2), b = at(40, 0.6), c = at(10, 0.2); // Deterministic in (time, journey): same inputs, same position. assert.deepEqual(a, c); // It moves over 30 s — this is the device under test. const travelled = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); assert.ok(travelled > 0.15, `camera barely moves: ${travelled.toFixed(3)} units in 30 s`); // Bounded: a walk that leaves the room fails the device. for (const t of [0, 30, 90, 200, 400]) { const p = at(t, 0.5); assert.ok(Math.abs(p.x) < 2.6, `x=${p.x.toFixed(2)} outside the room at t=${t}`); assert.ok(p.z > 0.4 && p.z < 8, `z=${p.z.toFixed(2)} outside the room at t=${t}`); } // Stage walks further than chamber (bigger room). The traverse is // journey-driven, so both probes move in journey as well as time. const stageAt = (time, journey) => { comp._updateVenueCamera(comp.sharedCamera, { framing: { scale: 1, shift: [0, 0] }, personality, time, venue: { ...venue, archetype: 'stage' }, journey, progress: time / 400, }); return { ...comp.sharedCamera.position }; }; const s0 = stageAt(0, 0.2), s1 = stageAt(37.5, 0.6); const chamberSpan = Math.hypot(b.x - a.x, b.y - a.y); const stageSpan = Math.hypot(s1.x - s0.x, s1.y - s0.y); assert.ok(stageSpan >= chamberSpan * 0.9, `stage walk (${stageSpan.toFixed(2)}) should reach at least as far as chamber (${chamberSpan.toFixed(2)})`); }); test('each venue module builds and updates headless with a furnished actor', () => { // THREE is ESM-only but importable from node; geometry build paths need no GL. const THREE = awaitImportThree(); return THREE.then((T) => { const personality = personalityAt(21); const identity = generateIdentity(SUMMARY, new Rng(21).fork('identity'), 4); personality.identity = identity; const actors = generateActorSet(SUMMARY, new Rng(21).fork('actors'), personality, identity); for (const v of venues) { const scene = new T.Scene(); const instance = v.build({ scene, seed: 99, params: {}, actorSpec: actors.monolith, venueSpec: { seed: 5, population: { count: 4, spread: 0.7, jitter: 0.25 }, paletteMap: [0, 1, 2, 3], perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }], }, THREE: T, }); assert.ok(instance, `${v.name}: build returned nothing`); v.update({ instance, scene, camera: new T.PerspectiveCamera(), timeline: { time: 12.5, frame: 750, progress: 0.1 }, features: {}, params: { light: 0.7, columns: 7, spread: 1.1 }, palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.4, 0.3]], personality, framing: { scale: 1, shift: [0, 0] }, opacity: 1, actorSpec: actors.monolith, venueSpec: instance.venueSpec, venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }, THREE: T, }); const furnished = countMeshes(scene); assert.ok(furnished >= 2, `${v.name}: only ${furnished} mesh(es) after update with an actor — furniture did not build`); } }); }); function countMeshes(root) { let n = 0; root.traverse((o) => { if (o.isMesh) n++; }); return n; } // NOTE: ArcDriver persistence/interpolation/jump logic is exercised in the // browser (checks + filmstrip), not here: ArcDriver's import chain reads // scenes/metadata.json, which Node 20 cannot import without an attribute. // See tools/venue-check.js --filmstrip for the carryover measurement. // Dynamic import indirection so a missing/broken three surfaces as a test // failure with a name on it rather than a top-level load error for the file. function awaitImportThree() { return import('three'); }