import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Rng } from '../src/engine/rng.js'; import { genDungeon, dungeonCharacter, dungeonTheme, THEMES, THEME_NAMES, 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 === Math.max(2, Math.round(4 * THEMES[m.theme].puddleMul)), `puddle count ${m.items.puddles.length} ignores theme ${m.theme}`); 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, }; // Summaries that pin the character mapping at its corners: a sealed crypt // (dark + slow) and an airy ruin (bright + fast). const SEALED = { bpm: 70, meanCentroid: 0.05, meanFlatness: 0.3, meanLoudness: 0.008, dynamicRange: 0.4 }; const AIRY = { bpm: 140, meanCentroid: 0.9, meanFlatness: 0.7, meanLoudness: 0.0014, dynamicRange: 0.89 }; test('dungeonCharacter maps song to dungeon in documented ranges', () => { for (const summary of [{}, SEALED, AIRY, SLOW_DARK, { bpm: 200, meanCentroid: 5, meanLoudness: -1 }]) { const c = dungeonCharacter(summary); for (const k of ['indoorBias', 'murk', 'sizeMul', 'dark', 'slow']) { assert.ok(Number.isFinite(c[k]), `${k} not finite for ${JSON.stringify(summary)}`); } assert.ok(c.indoorBias >= 0 && c.indoorBias <= 1, 'indoorBias out of range'); assert.ok(c.murk >= 0 && c.murk <= 1, 'murk out of range'); assert.ok(c.sizeMul >= 1 && c.sizeMul <= 1.45, 'sizeMul out of range'); } // Defaults are the neutral middle, not an extreme. const d0 = dungeonCharacter({}); assert.ok(d0.indoorBias > 0.3 && d0.indoorBias < 0.9, 'default bias extreme'); assert.ok(d0.murk > 0.1 && d0.murk < 0.8, 'default murk extreme'); // Monotonic in darkness: darker songs seal and murk harder. const brighter = dungeonCharacter({ ...SEALED, meanCentroid: 0.5 }); const sealed = dungeonCharacter(SEALED); assert.ok(sealed.indoorBias >= brighter.indoorBias, 'darker song less sealed'); assert.ok(sealed.murk >= brighter.murk, 'darker song less murky'); // Monotonic in tempo: slower songs seal harder and build bigger. // (bpm 140 stays unfolded — above 150 the detector doubles, so the // comparison stays in honest tempo territory.) const faster = dungeonCharacter({ ...SEALED, bpm: 140 }); assert.ok(sealed.indoorBias >= faster.indoorBias, 'slower song less sealed'); assert.ok(sealed.sizeMul >= faster.sizeMul, 'slower song builds smaller'); }); test('dark slow songs seal the dungeon: no outside views', () => { const m = genDungeon({ seed: 7, rng: new Rng(7), summary: SEALED }); assert.equal(m.character.indoorBias, 1, 'sealed summary must pin bias'); assert.ok(m.rooms.length >= 3, 'sealed map needs rooms too'); for (const rm of m.rooms) { assert.ok(rm.indoor, `sealed room ${rm.id} left open (${rm.zone})`); } for (let s = 0; s < m.route.totalLen; s += 2) { const z = zoneAt(m, s); assert.ok(z === 'hall' || z === 'room', `sealed route sees outside: ${z}`); } }); test('bright fast songs open the dungeon: some outside views', () => { const m = genDungeon({ seed: 7, rng: new Rng(7), summary: AIRY }); assert.ok(m.character.indoorBias < 0.5, 'airy summary must drop bias'); assert.ok(m.rooms.some((rm) => !rm.indoor), 'airy map sealed shut'); assert.ok(m.rooms.some((rm) => rm.indoor), 'airy map has no interior at all'); }); test('heavy songs build bigger rooms than light ones, same seed', () => { const heavy = genDungeon({ seed: 7, rng: new Rng(7), summary: { bpm: 70, meanCentroid: 0.3, meanLoudness: 0.01 } }); const light = genDungeon({ seed: 7, rng: new Rng(7), summary: { bpm: 140, meanCentroid: 0.6, meanLoudness: 0.001 } }); assert.ok(heavy.character.sizeMul > light.character.sizeMul + 0.2, 'heft must move sizeMul'); const mean = (p) => p.rooms.reduce((a, r) => a + r.w * r.h, 0) / p.rooms.length; assert.ok(mean(heavy) > mean(light) * 1.2, `heavy mean area ${mean(heavy).toFixed(1)} vs light ${mean(light).toFixed(1)}`); assert.ok(heavy.rooms.length >= 3 && light.rooms.length >= 3, 'both maps need rooms'); }); test('timber frames every gate and beams some walls, bounded', () => { const m = genDungeon({ seed: 7, rng: new Rng(7) }); assert.ok(m.items.gates.length > 0, 'seed 7 needs gates for this test'); assert.ok(m.items.timbers.length > 0, 'no timber at all'); assert.ok(m.items.timbers.length <= 140, `timber blowout: ${m.items.timbers.length}`); for (const tb of m.items.timbers) { for (const k of ['x', 'y', 'z', 'sx', 'sy', 'sz', 'yaw']) { assert.ok(Number.isFinite(tb[k]), `timber ${k} not finite`); } assert.ok(tb.sx > 0 && tb.sy > 0 && tb.sz > 0, 'timber with dead dimension'); } // Every gate gets a door frame: two posts flanking it plus a lintel over. for (const g of m.items.gates) { const rx = Math.cos(g.yaw), rz = -Math.sin(g.yaw); const near = m.items.timbers.filter((tb) => Math.hypot(tb.x - g.x, tb.z - g.z) < 1.6); assert.ok(near.length >= 3, `gate at ${g.x.toFixed(1)},${g.z.toFixed(1)} has ${near.length} timbers`); const posts = near.filter((tb) => Math.abs(tb.sy - 2.7) < 1e-9); assert.equal(posts.length, 2, `gate frame wants 2 posts, has ${posts.length}`); for (const side of [-1.15, 1.15]) { assert.ok(posts.some((tb) => Math.abs((tb.x - g.x) * rx + (tb.z - g.z) * rz - side) < 0.35), `gate frame missing post at lateral ${side}`); } assert.ok(near.some((tb) => Math.abs(tb.sx - 2.75) < 1e-9 && tb.y > -1.15 + 2.4), 'gate frame missing its lintel'); } // Wall beams sit on wall runs (plate centres), never floating. const beams = m.items.timbers.filter((tb) => Math.abs(tb.sx - 0.3) > 1e-9 || Math.abs(tb.sz - 0.3) > 1e-9); assert.ok(beams.length > 0, 'no wall beams sampled'); const onRun = (tb) => m.wallRuns.some((r) => { const len = Math.max(r.x1 - r.x0, r.z1 - r.z0); const d = len > 0 ? Math.abs((tb.x - r.x0) * (r.z1 - r.z0) - (tb.z - r.z0) * (r.x1 - r.x0)) / len : Math.hypot(tb.x - r.x0, tb.z - r.z0); const within = tb.x >= Math.min(r.x0, r.x1) - 0.4 && tb.x <= Math.max(r.x0, r.x1) + 0.4 && tb.z >= Math.min(r.z0, r.z1) - 0.4 && tb.z <= Math.max(r.z0, r.z1) + 0.4; return within && d < 0.35; }); for (const tb of beams) { // Door-frame pieces live at gates, not on runs — skip those. const atGate = m.items.gates.some((g) => Math.hypot(tb.x - g.x, tb.z - g.z) < 1.6); if (atGate) continue; assert.ok(onRun(tb), `beam at ${tb.x.toFixed(1)},${tb.z.toFixed(1)} floats off its wall`); } }); 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); }); const THEME_CORNERS = { crypt: { bpm: 62, meanCentroid: 0.02, meanFlatness: 0.3, meanLoudness: 0.008, dynamicRange: 0.9 }, overgrown: { bpm: 100, meanCentroid: 0.45, meanFlatness: 0.85, meanLoudness: 0.0005, dynamicRange: 0.5 }, ember: { bpm: 150, meanCentroid: 0.5, meanFlatness: 0.3, meanLoudness: 0.0022, dynamicRange: 0.4 }, flooded: { bpm: 120, meanCentroid: 0.9, meanFlatness: 0.2, meanLoudness: 0.001, dynamicRange: 0.9 }, windswept: { bpm: 170, meanCentroid: 0.85, meanFlatness: 0.2, meanLoudness: 0.001, dynamicRange: 0.4 }, }; test('dungeonTheme is deterministic and names a known theme', () => { for (const s of [{}, ...Object.values(THEME_CORNERS)]) { assert.equal(dungeonTheme(s, 7), dungeonTheme(s, 7), 'theme unstable across calls'); assert.ok(THEME_NAMES.includes(dungeonTheme(s, 7)), `unknown theme for ${JSON.stringify(s)}`); assert.ok(THEMES[dungeonTheme(s, 7)], 'theme record missing'); } // Same song, different seeds may vary (jitter), but the pick stays valid. for (const seed of [1, 2, 3, 99, 12345]) { assert.ok(THEME_NAMES.includes(dungeonTheme(THEME_CORNERS.ember, seed))); } }); test('each theme wins its home corner', () => { for (const [want, s] of Object.entries(THEME_CORNERS)) { assert.equal(dungeonTheme(s, 7), want, `${want} corner picked ${dungeonTheme(s, 7)}`); assert.equal(dungeonTheme(s, 99), want, `${want} corner unstable across seeds`); } }); test('theme sweep reaches every theme', () => { const seen = new Set(); for (let bpm = 60; bpm <= 180; bpm += 20) { for (const cent of [0.1, 0.4, 0.7, 0.95]) { for (const flat of [0.1, 0.6, 0.9]) { for (const loud of [0.0005, 0.002]) { seen.add(dungeonTheme({ bpm, meanCentroid: cent, meanFlatness: flat, meanLoudness: loud, dynamicRange: 0.5 }, 7)); } } } } assert.deepEqual([...seen].sort(), [...THEME_NAMES].sort(), `sweep reaches only ${[...seen].join(',')}`); }); test('theme records are well-formed', () => { for (const [name, th] of Object.entries(THEMES)) { for (const k of ['stoneMul', 'floorMul', 'torch', 'fogTint', 'wood']) { assert.ok(Array.isArray(th[k]) && th[k].length === 3, `${name}.${k} not an RGB triple`); assert.ok(th[k].every((v) => Number.isFinite(v) && v >= 0 && v <= 1.2), `${name}.${k} out of range`); } for (const k of ['propIndoor', 'propOutdoor']) { assert.ok(Array.isArray(th[k]) && th[k].length > 0, `${name}.${k} empty`); assert.ok(th[k].every((t) => PROP_BUILDERS[t]), `${name}.${k} names an unbuildable prop`); } for (const k of ['vegMul', 'timberMul', 'puddleMul']) { assert.ok(Number.isFinite(th[k]) && th[k] > 0, `${name}.${k} not a positive density`); } } }); test('theme reshapes the plan: dressing, planting, timber, puddles', () => { // Ember-forcing vs crypt-forcing summaries, same seed: only the theme differs. const ember = genDungeon({ seed: 7, rng: new Rng(7), summary: THEME_CORNERS.ember }); const crypt = genDungeon({ seed: 7, rng: new Rng(7), summary: THEME_CORNERS.crypt }); assert.equal(ember.theme, 'ember'); assert.equal(crypt.theme, 'crypt'); const stal = (m) => m.items.props.filter((p) => p.type === 'stalagmite').length; assert.ok(stal(ember) >= stal(crypt), `ember ${stal(ember)} vs crypt ${stal(crypt)} stalagmites`); assert.ok(ember.items.puddles.length <= crypt.items.puddles.length, `ember ${ember.items.puddles.length} vs crypt ${crypt.items.puddles.length} puddles`); assert.ok(ember.items.timbers.length < crypt.items.timbers.length, `ember ${ember.items.timbers.length} vs crypt ${crypt.items.timbers.length} timbers`); }); test('facade builds themed materials and lights', async () => { const T = await import('three'); const { dungeon } = await import('../src/venues/dungeon.js'); const summary = THEME_CORNERS.ember; const map = genDungeon({ seed: 7, rng: new Rng(7), summary }); const theme = THEMES[map.theme]; const scene = new T.Scene(); const instance = dungeon.build({ scene, seed: 7, params: {}, actorSpec: null, venueSpec: { seed: 5, dungeon: { map }, population: { count: 2 }, paletteMap: [0, 1, 2, 3], perSection: [] }, THREE: T, }); assert.ok(instance, 'build returned nothing'); const flame = instance.flameMat.emissive; assert.ok(Math.abs(flame.r - theme.torch[0]) < 1e-6 && Math.abs(flame.g - theme.torch[1]) < 1e-6 && Math.abs(flame.b - theme.torch[2]) < 1e-6, `flame ${flame.r.toFixed(2)},${flame.g.toFixed(2)},${flame.b.toFixed(2)} != theme ${theme.torch}`); const tl = instance.torch.color; assert.ok(Math.abs(tl.r - theme.torch[0]) < 1e-6 && Math.abs(tl.g - theme.torch[1]) < 1e-6 && Math.abs(tl.b - theme.torch[2]) < 1e-6, 'torch light ignores theme'); // Per-frame palette tint carries the theme multipliers. dungeon.update({ instance, scene, camera: new T.PerspectiveCamera(), timeline: { time: 1, frame: 60, progress: 0.01 }, features: {}, params: {}, palette: [[1, 1, 1]], personality: null, framing: null, opacity: 1, actorSpec: null, venueSpec: instance.venueSpec, venueState: null, THREE: T, }); const st = instance.stoneMat.color; const fx = theme.stoneMul[0]; assert.ok(Math.abs(st.r / fx - (0.42 * (0.45 + 0.7 * 0.75) + 0.07)) < 0.05, 'stone tint does not carry the theme multiplier'); });