Dungeon venue: grid-first traversable dungeon with cast, loot and encounters
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.
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { Rng } from '../src/engine/rng.js';
|
||||
import { genDungeon, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from '../src/venues/dungeon/map.js';
|
||||
import { PROP_BUILDERS } from '../src/venues/dungeon/props.js';
|
||||
import { HAZARD_BUILDERS, avoidanceOffset } from '../src/venues/dungeon/hazards.js';
|
||||
import { generateVenue } from '../src/venues/VenueGenerator.js';
|
||||
import { generatePersonality } from '../src/look/Personality.js';
|
||||
|
||||
test('dungeon map is deterministic with rooms, zones and a closed route', () => {
|
||||
const a = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const b = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.deepEqual(a, b, 'same seed must generate the same dungeon');
|
||||
assert.ok(a.rooms.length >= 3, `only ${a.rooms.length} rooms`);
|
||||
const zones = new Set(a.rooms.map((r) => r.zone));
|
||||
assert.ok(zones.size >= 3, `zone coverage thin: ${[...zones].join(',')}`);
|
||||
assert.ok(a.route.totalLen > 80, `route too short: ${a.route.totalLen}`);
|
||||
// pathAt wraps and is pure.
|
||||
const s1 = pathAt(a, 3.7), s2 = pathAt(a, 3.7 + a.route.totalLen);
|
||||
assert.deepEqual(s1, s2, 'pathAt must wrap seamlessly');
|
||||
assert.ok(Number.isFinite(s1.yaw), 'heading must be finite');
|
||||
});
|
||||
|
||||
test('every room is reachable: BFS over open cells hits all rooms', () => {
|
||||
for (const seed of [5, 7, 4242]) {
|
||||
const m = genDungeon({ seed, rng: new Rng(seed) });
|
||||
const seen = new Set([0]);
|
||||
const r0 = m.rooms[0];
|
||||
const qx = [r0.x + Math.floor(r0.w / 2)], qy = [r0.y + Math.floor(r0.h / 2)];
|
||||
const seenCells = new Set([qy[0] * m.w + qx[0]]);
|
||||
const roomOf = new Int16Array(m.w * m.h).fill(-1);
|
||||
for (const rm of m.rooms) {
|
||||
for (let y = rm.y; y < rm.y + rm.h; y++) {
|
||||
for (let x = rm.x; x < rm.x + rm.w; x++) roomOf[y * m.w + x] = rm.id;
|
||||
}
|
||||
}
|
||||
let head = 0;
|
||||
while (head < qx.length) {
|
||||
const cx = qx[head], cy = qy[head];
|
||||
head++;
|
||||
if (roomOf[cy * m.w + cx] >= 0) seen.add(roomOf[cy * m.w + cx]);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = cx + dx, ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue;
|
||||
if (!m.grid[ny * m.w + nx] || seenCells.has(ny * m.w + nx)) continue;
|
||||
seenCells.add(ny * m.w + nx);
|
||||
qx.push(nx); qy.push(ny);
|
||||
}
|
||||
}
|
||||
assert.equal(seen.size, m.rooms.length,
|
||||
`seed ${seed}: reachable ${seen.size}/${m.rooms.length} rooms`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the route only walks open cells and visits every room', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
// Route samples sit on open cells (checked at cell granularity).
|
||||
for (let s = 0; s < m.route.totalLen; s += 2) {
|
||||
const p = pathAt(m, s);
|
||||
const cx = Math.floor(p.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(p.z / CELL + m.h / 2);
|
||||
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `route off the grid at s=${s}`);
|
||||
assert.ok(m.grid[cy * m.w + cx], `route crosses a solid cell at s=${s.toFixed(1)}`);
|
||||
}
|
||||
const visited = new Set(m.route.roomIds.filter((id) => id >= 0));
|
||||
assert.equal(visited.size, m.rooms.length, 'the tour must enter every room');
|
||||
});
|
||||
|
||||
test('no holes indoors, green edges outdoors: every open edge is walled or planted', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const hRuns = m.wallRuns.filter((r) => r.z0 === r.z1);
|
||||
const vRuns = m.wallRuns.filter((r) => r.x0 === r.x1);
|
||||
const coveredH = (x, z) => hRuns.some((r) =>
|
||||
Math.abs(r.z0 - z) < 1e-6 && x + 1e-6 >= r.x0 && x - 1e-6 <= r.x1);
|
||||
const coveredV = (x, z) => vRuns.some((r) =>
|
||||
Math.abs(r.x0 - x) < 1e-6 && z + 1e-6 >= r.z0 && z - 1e-6 <= r.z1);
|
||||
const plantedNear = (x, z) => m.items.vegetation.some((v) =>
|
||||
Math.hypot(v.x - x, v.z - z) < 1.6);
|
||||
const w0 = (cx) => (cx - m.w / 2) * CELL; // west edge of cell cx
|
||||
const z0 = (cy) => (cy - m.h / 2) * CELL;
|
||||
// Indoor-ness is the map's own flag (rooms by rect, corridors by the
|
||||
// rooms they join) — the test reads it, never re-derives it, so a
|
||||
// corridor the tour never walks still judges correctly.
|
||||
const cellIndoor = (x, y) => m.indoor[y * m.w + x] === 1;
|
||||
let walled = 0, planted = 0;
|
||||
for (let y = 0; y < m.h; y++) {
|
||||
for (let x = 0; x < m.w; x++) {
|
||||
if (!m.grid[y * m.w + x]) continue;
|
||||
const openN = (nx, ny) => nx >= 0 && ny >= 0 && nx < m.w && ny < m.h && m.grid[ny * m.w + nx];
|
||||
const edges = [];
|
||||
if (!openN(x - 1, y)) edges.push(['v', w0(x), z0(y) + CELL / 2]);
|
||||
if (!openN(x + 1, y)) edges.push(['v', w0(x) + CELL, z0(y) + CELL / 2]);
|
||||
if (!openN(x, y - 1)) edges.push(['h', w0(x) + CELL / 2, z0(y)]);
|
||||
if (!openN(x, y + 1)) edges.push(['h', w0(x) + CELL / 2, z0(y) + CELL]);
|
||||
for (const [kind, ex, ez] of edges) {
|
||||
const covered = kind === 'h' ? coveredH(ex, ez) : coveredV(ex, ez);
|
||||
if (cellIndoor(x, y)) {
|
||||
assert.ok(covered, `hole on ${kind === 'h' ? 'horizontal' : 'vertical'} edge of (${x},${y})`);
|
||||
walled++;
|
||||
} else {
|
||||
assert.ok(plantedNear(ex, ez) || covered,
|
||||
`outdoor edge of (${x},${y}) neither walled nor planted`);
|
||||
planted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(walled > 100, `only ${walled} wall edges checked — map too open?`);
|
||||
assert.ok(planted > 20, `only ${planted} planted edges — outdoor bare?`);
|
||||
for (const v of m.items.vegetation) {
|
||||
assert.ok(['tree', 'bush', 'boulder'].includes(v.kind), `unknown planting ${v.kind}`);
|
||||
const cx = Math.floor(v.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(v.z / CELL + m.h / 2);
|
||||
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, 'planting off the grid');
|
||||
assert.ok(!m.grid[cy * m.w + cx], 'planting on an open cell — it would block the walk');
|
||||
}
|
||||
});
|
||||
|
||||
test('watch cells stand on open ground with line-of-sight to the route', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.ok(Object.keys(m.watch).length >= m.rooms.length - 1,
|
||||
'nearly every room needs a camera chair');
|
||||
for (const [id, w] of Object.entries(m.watch)) {
|
||||
const cx = Math.floor(w.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(w.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx], `watch cell of room ${id} is solid`);
|
||||
const rm = m.rooms.find((r) => r.id === Number(id));
|
||||
assert.ok(cx >= rm.x && cx < rm.x + rm.w && cy >= rm.y && cy < rm.y + rm.h,
|
||||
`watch cell of room ${id} is outside its room`);
|
||||
}
|
||||
});
|
||||
|
||||
test('portal sits off-route in a room, facing the walk', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.ok(m.portal, 'no portal planned');
|
||||
const cx = Math.floor(m.portal.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(m.portal.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx], 'portal cell is solid');
|
||||
const rm = m.rooms.find((r) => r.id === m.portal.roomId);
|
||||
assert.ok(rm && rm.indoor, 'portal belongs in an indoor room');
|
||||
assert.ok(Number.isFinite(m.portal.yaw), 'portal yaw must be finite');
|
||||
});
|
||||
|
||||
test('items sit on open cells, off the walked line', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const onOpen = (x, z, what) => {
|
||||
const cx = Math.floor(x / CELL + m.w / 2);
|
||||
const cy = Math.floor(z / CELL + m.h / 2);
|
||||
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `${what} off the grid`);
|
||||
assert.ok(m.grid[cy * m.w + cx], `${what} on a solid cell`);
|
||||
};
|
||||
for (const p of m.items.props) {
|
||||
assert.ok(PROP_BUILDERS[p.type], `unknown prop ${p.type}`);
|
||||
onOpen(p.x, p.z, `prop ${p.type}`);
|
||||
}
|
||||
assert.ok(m.items.torches.length >= 2, `only ${m.items.torches.length} torches`);
|
||||
assert.ok(m.items.torches.length <= 18, 'torch cap blown — clutter');
|
||||
for (const tc of m.items.torches) {
|
||||
// Sconces hang on indoor stone: an indoor open cell within a step,
|
||||
// with a solid neighbour in reach of the bracket — never floating,
|
||||
// never buried deep.
|
||||
const cx = Math.floor(tc.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(tc.z / CELL + m.h / 2);
|
||||
let indoorNear = false, solidNear = false, openNear = false;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = cx + dx, ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue;
|
||||
if (m.grid[ny * m.w + nx]) {
|
||||
openNear = true;
|
||||
if (m.indoor[ny * m.w + nx]) indoorNear = true;
|
||||
} else solidNear = true;
|
||||
}
|
||||
}
|
||||
assert.ok(openNear, 'torch buried in stone');
|
||||
assert.ok(indoorNear, 'torch burning outdoors');
|
||||
assert.ok(solidNear, 'torch with no wall in reach — floating');
|
||||
}
|
||||
assert.ok(m.items.patrols.length >= 1, 'no patrol lanterns for the open ground');
|
||||
assert.ok(m.items.windows.length >= 4, `only ${m.items.windows.length} windows`);
|
||||
assert.ok(m.items.puddles.length === 4, 'want exactly 4 puddles');
|
||||
for (const g of [...m.items.gates, ...m.items.gaps, ...m.items.patrols]) {
|
||||
onOpen(g.x, g.z, 'hazard');
|
||||
}
|
||||
for (const g of m.items.gates) {
|
||||
assert.ok(HAZARD_BUILDERS.gate, 'gate builder missing');
|
||||
assert.ok(typeof g.s === 'number', 'gate needs route arclength for the lift');
|
||||
}
|
||||
// Avoidance stays bounded on the real gate list.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const off = avoidanceOffset(i * 1.7, m.items.gates, i * 0.63);
|
||||
assert.ok(Number.isFinite(off) && Math.abs(off) <= 1.0, `avoidance out of bounds: ${off}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('portcullis stays up for the whole procession, not just the hero', async () => {
|
||||
const T = await import('three');
|
||||
const { updateHazards, HAZARD_BUILDERS } = await import('../src/venues/dungeon/hazards.js');
|
||||
const gate = HAZARD_BUILDERS.gate(T, new Rng(3), [0.45, 0.45, 0.48]);
|
||||
gate.userData.home = { x: 0, z: 0, yaw: 0 };
|
||||
gate.userData.hazardS = 100;
|
||||
const liftOf = (partyS) => {
|
||||
updateHazards({ hazards: { items: [gate] }, t: 10, heroS: partyS[0], partyS });
|
||||
return gate.userData.liftBars.position.y;
|
||||
};
|
||||
// Hero under the gate: fully raised.
|
||||
assert.ok(liftOf([100]) > 2.3, 'gate not raised for the hero');
|
||||
// Hero through, one trailer still under it: must stay fully raised.
|
||||
assert.ok(liftOf([104, 100.5, 108]) > 2.3, 'gate dropped on the trailer');
|
||||
// Whole party clear: back down.
|
||||
assert.ok(liftOf([112, 116, 120]) < 0.1, 'gate stays up with nobody near');
|
||||
// Hero-only fallback still behaves (no partyS passed).
|
||||
updateHazards({ hazards: { items: [gate] }, t: 10, heroS: 100 });
|
||||
assert.ok(gate.userData.liftBars.position.y > 2.3, 'hero-only lift broken');
|
||||
});
|
||||
|
||||
test('roomAt/zoneAt follow the route through rooms and corridors', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const seenRooms = new Set(), seenZones = new Set();
|
||||
let corridor = false;
|
||||
for (let s = 0; s < m.route.totalLen; s += 1) {
|
||||
const r = roomAt(m, s);
|
||||
const z = zoneAt(m, s);
|
||||
assert.ok(Number.isInteger(r) && r >= -1 && r < m.rooms.length, `bad room ${r}`);
|
||||
assert.ok(typeof z === 'string' && z.length > 0, `bad zone ${z}`);
|
||||
if (r >= 0) seenRooms.add(r);
|
||||
else corridor = true;
|
||||
seenZones.add(z);
|
||||
}
|
||||
assert.equal(seenRooms.size, m.rooms.length, 'route samples must cover every room');
|
||||
assert.ok(corridor, 'route never leaves rooms — corridors unused?');
|
||||
assert.ok(seenZones.size >= 2, 'route crosses a single zone only');
|
||||
});
|
||||
|
||||
test('the cast assembles: enemies, critters and a wisp on open cells', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const kinds = m.items.entities.map((e) => e.kind);
|
||||
assert.ok(kinds.filter((k) => k === 'lurker').length >= 2, `lurkers: ${kinds.join(',')}`);
|
||||
assert.ok(kinds.includes('critter'), 'no critters');
|
||||
assert.ok(kinds.includes('wisp'), 'no wisp');
|
||||
assert.equal(m.items.entities.length, 6, `cast size ${m.items.entities.length}`);
|
||||
for (const e of m.items.entities) {
|
||||
assert.ok(['lurker', 'sentry', 'critter', 'wisp'].includes(e.kind), `unknown entity ${e.kind}`);
|
||||
const cx = Math.floor(e.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(e.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx] === 1, `${e.kind} on a solid cell`);
|
||||
assert.ok(Number.isFinite(e.s) && Number.isFinite(e.yaw), `${e.kind} missing s/yaw`);
|
||||
assert.ok(e.range >= 0 && Number.isFinite(e.speed) && Number.isFinite(e.phase),
|
||||
`${e.kind} missing gait params`);
|
||||
}
|
||||
});
|
||||
|
||||
test('loot sits a step off the walk, each cache earning its halt', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.ok(m.items.loot.length >= 2, `only ${m.items.loot.length} loot spots`);
|
||||
assert.ok(m.items.loot.length <= 3, 'loot glut');
|
||||
for (const l of m.items.loot) {
|
||||
assert.ok(['cache', 'relic', 'hoard'].includes(l.kind), `unknown loot ${l.kind}`);
|
||||
const cx = Math.floor(l.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(l.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx] === 1, `loot ${l.kind} on a solid cell`);
|
||||
}
|
||||
assert.equal(m.items.halts.length, m.items.loot.length, 'every cache earns a halt');
|
||||
for (let i = 0; i < m.items.halts.length; i++) {
|
||||
const h = m.items.halts[i];
|
||||
assert.ok(h.dur > 0 && h.dur <= 3, `halt dur ${h.dur}`);
|
||||
assert.equal(h.s, m.items.loot[i].s, 'halt must sit on its loot');
|
||||
for (let j = i + 1; j < m.items.halts.length; j++) {
|
||||
const L = m.route.totalLen;
|
||||
const d = Math.abs(h.s - m.items.halts[j].s) % L;
|
||||
assert.ok(Math.min(d, L - d) >= 15, 'halts overlap — the odometer could retreat');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('odometer holds at halts and never retreats', () => {
|
||||
const cruise = 1.4, L = 200;
|
||||
const halts = [{ s: 50, dur: 2.2 }, { s: 150, dur: 2.2 }];
|
||||
// Deterministic.
|
||||
assert.equal(odometer(cruise, halts, L, 10), odometer(cruise, halts, L, 10));
|
||||
// Unpaused stretch advances at gait.
|
||||
assert.ok(Math.abs(odometer(cruise, halts, L, 10) - cruise * 10) < 1e-9, 'gait wrong');
|
||||
// Arrival holds: from t_a to t_a+dur the reading stays on the loot.
|
||||
const ta = 50 / cruise;
|
||||
assert.ok(Math.abs(odometer(cruise, halts, L, ta + 1.1) - 50) < 1e-9, 'halt does not hold');
|
||||
assert.ok(Math.abs(odometer(cruise, halts, L, ta + 2.2) - 50) < 1e-9, 'halt releases early');
|
||||
// Monotonic everywhere, both gaits, both halt sets.
|
||||
for (const c of [0.4, 1.4, 2.6]) {
|
||||
let prev = odometer(c, halts, L, 0);
|
||||
for (let t = 0.05; t < 300; t += 0.05) {
|
||||
const s = odometer(c, halts, L, t);
|
||||
const d = (s - prev + L) % L;
|
||||
assert.ok(d < c * 0.06 + 1e-9, `retreat at t=${t.toFixed(2)} (cruise ${c})`);
|
||||
prev = s;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('entity poses are deterministic, bounded and hero-aware', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const L = m.route.totalLen;
|
||||
for (const e of m.items.entities) {
|
||||
const p1 = entityPose(e, 10, 5, 0, 0, L);
|
||||
const p2 = entityPose(e, 10, 5, 0, 0, L);
|
||||
assert.deepEqual(p1, p2, `${e.kind} pose unstable`);
|
||||
assert.ok(Math.hypot(p1.x - e.x, p1.z - e.z) <= e.range + 1.6,
|
||||
`${e.kind} wanders ${Math.hypot(p1.x - e.x, p1.z - e.z).toFixed(2)} from home`);
|
||||
assert.ok(p1.alert >= 0 && p1.alert <= 1, `${e.kind} alert out of range`);
|
||||
// Near the hero it notices; far away it doesn't.
|
||||
const near = entityPose(e, 10, e.s, e.x, e.z, L);
|
||||
const far = entityPose(e, 10, (e.s + L / 2) % L, e.x, e.z, L);
|
||||
assert.ok(near.alert >= far.alert, `${e.kind} ignores the hero`);
|
||||
assert.ok(near.alert > 0.5, `${e.kind} cold at point blank`);
|
||||
}
|
||||
});
|
||||
|
||||
const SLOW_DARK = {
|
||||
meanCentroid: 0.3, meanFlatness: 0.1, bpm: 68,
|
||||
dynamicRange: 0.7, meanLoudness: 0.3,
|
||||
};
|
||||
|
||||
function slowPersonalityAt(seed) {
|
||||
const rng = new Rng(seed);
|
||||
return generatePersonality(SLOW_DARK, rng.fork('personality'), () => 10, 4);
|
||||
}
|
||||
|
||||
test('VenueGenerator plans the dungeon map when the dungeon is picked', () => {
|
||||
const rng = new Rng(5);
|
||||
const story = {
|
||||
sections: [{ act: 'setup' }, { act: 'development' }, { act: 'climax' }, { act: 'resolution' }],
|
||||
};
|
||||
const vs = generateVenue({
|
||||
summary: SLOW_DARK, rng: rng.fork('venue'), personality: slowPersonalityAt(5),
|
||||
director: null, story, sections: [{}, {}, {}, {}],
|
||||
});
|
||||
assert.equal(vs.archetype, 'dungeon');
|
||||
assert.ok(vs.dungeon && vs.dungeon.map, 'dungeon venue must carry the map');
|
||||
assert.ok(vs.dungeon.map.rooms.length >= 3, 'map has no rooms');
|
||||
assert.ok(vs.dungeon.map.route.totalLen > 80, 'route too short');
|
||||
assert.equal(vs.population.kind, 'party', 'dungeon walks a party, not a crowd');
|
||||
assert.equal(vs.projection.kind, 'portal', 'dungeon projects on the portal');
|
||||
// Deterministic across regenerations.
|
||||
const again = generateVenue({
|
||||
summary: SLOW_DARK, rng: new Rng(5).fork('venue'), personality: slowPersonalityAt(5),
|
||||
director: null, story, sections: [{}, {}, {}, {}],
|
||||
});
|
||||
assert.deepEqual(again.dungeon, vs.dungeon);
|
||||
// Non-dungeon venues carry no dungeon plan.
|
||||
const fast = generateVenue({
|
||||
summary: { ...SLOW_DARK, bpm: 140 }, rng: new Rng(5).fork('venue'),
|
||||
personality: slowPersonalityAt(5), director: null, story, sections: [{}, {}, {}, {}],
|
||||
});
|
||||
if (fast.archetype !== 'dungeon') assert.equal(fast.dungeon, null);
|
||||
});
|
||||
@@ -262,3 +262,126 @@ test('ride declares directCamera and drives its own rig deterministically', () =
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user