New VenueGenerator 'dungeon' archetype. The floor plan is generated first on a 44x44 grid (rejection-sampled rooms, Kruskal MST + loop edges, L-shaped corridors, BFS-verified connectivity); walls are merged runs built exactly where open meets solid, so the shell has no holes, and the walk route is a closed travelling tour through every room. The camera either walks first-person (hero hidden to avoid clipping) or plants on a per-room watch cell with line-of-sight to the route and tracks the actor. Enemies (walker/structure/swarm ActorSpecs from the track's own summary) lurk, stand sentry, dart and drift; loot caches pause the analytic odometer, which can hold or advance but never retreat. Includes dungeon-debug.html (2D map + timeline stills), 14 map tests plus facade camera tests, and the portcullis-holds-for-the-whole-party regression test.
388 lines
19 KiB
JavaScript
388 lines
19 KiB
JavaScript
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.
|
|
|
|
test('ride declares directCamera and drives its own rig deterministically', () => {
|
|
const THREE = awaitImportThree();
|
|
return THREE.then((T) => {
|
|
const rideMod = venues.find((v) => v.name === 'Ride');
|
|
assert.ok(rideMod, 'Ride venue not registered');
|
|
assert.equal(rideMod.directCamera, true, 'Ride must own its camera rig');
|
|
const personality = personalityAt(11);
|
|
const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4);
|
|
personality.identity = identity;
|
|
const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity);
|
|
const scene = new T.Scene();
|
|
const instance = rideMod.build({
|
|
scene, seed: 4242, params: {}, actorSpec: actors.monolith,
|
|
venueSpec: {
|
|
seed: 5, population: { count: 3, spread: 0.6, jitter: 0.2 },
|
|
paletteMap: [0, 1, 2, 3],
|
|
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
|
|
},
|
|
THREE: T,
|
|
});
|
|
const stubCam = () => ({
|
|
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
|
|
rotation: {},
|
|
look: null,
|
|
lookAt(x, y, z) { this.look = [x, y, z]; },
|
|
updateMatrixWorld() {},
|
|
});
|
|
const drive = (time, framing) => {
|
|
const cam = stubCam();
|
|
rideMod.update({
|
|
instance, scene, camera: cam,
|
|
timeline: { time, frame: Math.round(time * 60), progress: time / 200 },
|
|
features: {}, params: { light: 0.85, speed: 0.7 },
|
|
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
|
|
personality, framing, opacity: 1,
|
|
actorSpec: actors.monolith,
|
|
venueSpec: instance.venueSpec,
|
|
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
|
|
THREE: T,
|
|
});
|
|
const { x, y, z } = cam.position;
|
|
return { x, y, z, look: cam.look };
|
|
};
|
|
const framing = { scale: 1, shift: [0, 0] };
|
|
const a = drive(10, framing), b = drive(10, framing);
|
|
assert.deepEqual(a, b, 'same inputs must place the camera identically');
|
|
const c = drive(40, framing);
|
|
const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z);
|
|
assert.ok(travelled > 0.15, `ride camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
|
|
// Stays inside its own box (wider + behind the car, unlike rooms —
|
|
// must match the clamp in driveRideCamera).
|
|
for (const t of [0, 30, 90, 200, 400]) {
|
|
const p = drive(t, framing);
|
|
assert.ok(Math.abs(p.x) <= 6.0, `x=${p.x.toFixed(2)} outside the ride box at t=${t}`);
|
|
assert.ok(p.z >= -6.0 && p.z <= 6.0, `z=${p.z.toFixed(2)} outside the ride box at t=${t}`);
|
|
assert.ok(p.look && p.look.length === 3, 'camera must look somewhere');
|
|
}
|
|
// A planned mark steers the head: screen vs room framings look apart.
|
|
const shot = { pos: [1, 0.5, 4], look: [0, 0.8, -0.8], size: 'close', target: 'screen', dolly: 0.72 };
|
|
const roomShot = { pos: [-1, 0.5, 5], look: [0, 0.3, -6], size: 'wide', target: 'room', dolly: 1.3 };
|
|
const s1 = drive(60, { scale: 1, shift: [0, 0], venueShot: shot });
|
|
const s2 = drive(60, { scale: 1, shift: [0, 0], venueShot: roomShot });
|
|
assert.ok(Math.hypot(s1.look[0] - s2.look[0], s1.look[1] - s2.look[1]) > 0.2,
|
|
'screen vs room marks must aim the camera apart');
|
|
});
|
|
});
|
|
|
|
// 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');
|
|
}
|
|
|
|
test('dungeon declares directCamera and plants its cameras on open cells', () => {
|
|
const THREE = awaitImportThree();
|
|
return THREE.then((T) => {
|
|
const dungeonMod = venues.find((v) => v.name === 'Dungeon');
|
|
assert.ok(dungeonMod, 'Dungeon venue not registered');
|
|
assert.equal(dungeonMod.directCamera, true, 'Dungeon must own its camera rig');
|
|
const personality = personalityAt(11);
|
|
const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4);
|
|
personality.identity = identity;
|
|
const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity);
|
|
const scene = new T.Scene();
|
|
const dungeonSpec = {
|
|
seed: 5, population: { count: 3, spread: 0.5, jitter: 0.1 },
|
|
paletteMap: [0, 1, 2, 3],
|
|
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
|
|
};
|
|
const instance = dungeonMod.build({
|
|
scene, seed: 4242, params: {}, actorSpec: actors.monolith,
|
|
venueSpec: dungeonSpec, THREE: T,
|
|
});
|
|
const stubCam = () => ({
|
|
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
|
|
rotation: {},
|
|
look: null,
|
|
lookAt(x, y, z) { this.look = [x, y, z]; },
|
|
updateMatrixWorld() {},
|
|
});
|
|
const drive = (time, framing) => {
|
|
const cam = stubCam();
|
|
dungeonMod.update({
|
|
instance, scene, camera: cam,
|
|
timeline: { time, frame: Math.round(time * 60), progress: time / 200 },
|
|
features: {}, params: { light: 0.85, speed: 0.7 },
|
|
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
|
|
personality, framing, opacity: 1,
|
|
actorSpec: actors.monolith,
|
|
venueSpec: instance.venueSpec,
|
|
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
|
|
THREE: T,
|
|
});
|
|
const { x, y, z } = cam.position;
|
|
return { x, y, z, look: cam.look };
|
|
};
|
|
const framing = { scale: 1, shift: [0, 0] };
|
|
const a = drive(10, framing), b = drive(10, framing);
|
|
assert.deepEqual(a, b, 'same inputs must place the camera identically');
|
|
const c = drive(40, framing);
|
|
const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z);
|
|
assert.ok(travelled > 0.15, `dungeon camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
|
|
// Every camera — walk or room plant — stands on an open cell at eye
|
|
// height, and always looks somewhere. The map is 88 units wide, so
|
|
// positions roam; openness is the invariant, not a small box.
|
|
const cellOpen = (x, z) => {
|
|
const map = instance.map;
|
|
const cx = Math.floor(x / 2 + map.w / 2);
|
|
const cy = Math.floor(z / 2 + map.h / 2);
|
|
return cx >= 0 && cy >= 0 && cx < map.w && cy < map.h && map.grid[cy * map.w + cx] === 1;
|
|
};
|
|
const watches = [
|
|
framing,
|
|
{ scale: 1, shift: [0, 0], venueShot: { pos: [0, 1, 2], look: [0, 0.2, -5], size: 'normal', target: 'walk', dolly: 1.0 } },
|
|
{ scale: 1, shift: [0, 0], venueShot: { pos: [0, 1.5, 3], look: [0, 0.9, -3], size: 'normal', target: 'room', dolly: 1.0 } },
|
|
{ scale: 1, shift: [0, 0], venueShot: { pos: [2.6, 1.5, 1.0], look: [-0.5, 1.3, -8], size: 'wide', target: 'portal', dolly: 1.3, drive: 0.5 } },
|
|
];
|
|
for (const t of [0, 30, 90, 200, 400]) {
|
|
for (const f of watches) {
|
|
const p = drive(t, f);
|
|
assert.ok(cellOpen(p.x, p.z), `camera inside a wall at t=${t} (${p.x.toFixed(2)},${p.z.toFixed(2)})`);
|
|
assert.ok(p.y >= 0.2 && p.y <= 3.4, `y=${p.y.toFixed(2)} off eye height at t=${t}`);
|
|
assert.ok(p.look && p.look.length === 3, 'camera must look somewhere');
|
|
// The bearer hides in exactly the frames the camera stands
|
|
// where he is: first-person never renders him, plants do.
|
|
assert.equal(instance.hero.visible, instance.watchRoom < 0 ? false : true,
|
|
`hero visibility disagrees with watchRoom at t=${t}`);
|
|
}
|
|
}
|
|
// Walk vs room aim apart: the modes are the coverage.
|
|
const m1 = drive(60, watches[1]);
|
|
const m2 = drive(60, watches[2]);
|
|
const m3 = drive(60, watches[3]);
|
|
assert.ok(Math.hypot(m1.x - m2.x, m1.z - m2.z) > 0.2,
|
|
'walk vs room must place the camera apart');
|
|
assert.ok(Math.hypot(m1.look[0] - m3.look[0], m1.look[2] - m3.look[2]) > 0.2,
|
|
'walk vs portal must aim apart');
|
|
// The portal ring sits on an open cell and the tableau camera aims
|
|
// at the live actor, not at a conventional mark.
|
|
const ring = instance.portal.group.position;
|
|
assert.ok(cellOpen(ring.x, ring.z), 'portal ring inside a wall');
|
|
const hero = instance.hero.position;
|
|
assert.deepEqual([m3.look[0], m3.look[2]], [hero.x, hero.z],
|
|
'tableau camera must track the actor');
|
|
// Enemies wear the song's actor pool: grown bodies from this track's
|
|
// walker/structure/swarm ActorSpecs, darkened hostile with ember
|
|
// eyes set into the face the geometry actually has.
|
|
const grown = (instance.cast || []).filter((en) => en.grown);
|
|
assert.ok(grown.length > 0, 'cast never grew its song bodies');
|
|
// Grown from the song's actor pool, not the venue monolith: the
|
|
// cached specs carry walker/structure/swarm archetypes.
|
|
const specs = instance.enemySpecs || {};
|
|
for (const [kind, arch] of [['lurker', 'walker'], ['sentry', 'structure'], ['critter', 'swarm']]) {
|
|
if (!specs[kind]) continue;
|
|
assert.equal(specs[kind].archetype, arch,
|
|
`${kind} enemy grown from ${specs[kind].archetype}, not ${arch}`);
|
|
assert.notEqual(specs[kind].seed, actors.monolith.seed,
|
|
`${kind} enemy reuses the monolith seed`);
|
|
}
|
|
for (const en of grown) {
|
|
if (en.spec.kind === 'wisp') continue;
|
|
assert.ok(en.bodyMesh && en.bodyMesh.isMesh, `${en.spec.kind} has no body`);
|
|
assert.ok(en.eyes.length >= 2, `${en.spec.kind} has no eyes`);
|
|
// The body is this song's cast, not a stock primitive: its part
|
|
// is tagged with the kind it plays.
|
|
assert.equal(en.bodyMesh.userData.kind, en.spec.kind,
|
|
`${en.spec.kind} body is not tagged from the actor pool`);
|
|
const bb = en.bodyMesh.geometry.boundingBox;
|
|
assert.ok(bb, `${en.spec.kind} body has no measured box`);
|
|
const h = (bb.max.y - bb.min.y) * en.bodyMesh.scale.x;
|
|
assert.ok(Math.abs(h - en.height) < 0.05,
|
|
`${en.spec.kind} stands ${h.toFixed(2)} tall, not ${en.height}`);
|
|
}
|
|
});
|
|
});
|