Dungeon themes: per-song visual identity (crypt/overgrown/ember/flooded/windswept)

One album must not render the same dungeon twice. dungeonTheme() scores
the measured summary per theme — pace terms gate on tempoConfidence so
doubled detectors never read fast, crypt is penalised for blazing
masters — plus small seeded jitter so ambiguous songs vary across seeds
while decided races stay decided. The plan carries the theme name;
dressing mix, planting/timber/puddle densities follow it, and the facade
builds all materials (stone/floor multipliers, torch + light color, wood,
fog tint) from the same record.

Calibrated bank spread is 4/4/4/4/1 (windswept holds runner); corners
stable across seeds; plan reshaping, facade wiring, and murk-vs-clear
gates covered by tests.
This commit is contained in:
Dejvino
2026-10-08 22:20:04 +02:00
parent c96e810d44
commit 6a2af2e45f
7 changed files with 648 additions and 55 deletions
+223 -2
View File
@@ -2,7 +2,7 @@ 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 { 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';
@@ -179,7 +179,8 @@ test('items sit on open cells, off the walked line', () => {
}
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');
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');
}
@@ -320,6 +321,115 @@ const SLOW_DARK = {
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);
@@ -353,3 +463,114 @@ test('VenueGenerator plans the dungeon map when the dungeon is picked', () => {
});
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');
});
+41 -1
View File
@@ -265,7 +265,7 @@ function awaitImportThree() {
test('dungeon declares directCamera and plants its cameras on open cells', () => {
const THREE = awaitImportThree();
return THREE.then((T) => {
return THREE.then(async (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');
@@ -383,5 +383,45 @@ test('dungeon declares directCamera and plants its cameras on open cells', () =>
assert.ok(Math.abs(h - en.height) < 0.05,
`${en.spec.kind} stands ${h.toFixed(2)} tall, not ${en.height}`);
}
// Murk dims the venue: the same update on a murky map must read
// darker lights and closer fog than on a clear one.
{
const { genDungeon } = await import('../src/venues/dungeon/map.js');
const murky = genDungeon({ seed: 4242, summary: { bpm: 70, meanCentroid: 0.05, meanLoudness: 0.008 } });
const clear = genDungeon({ seed: 4242, summary: { bpm: 170, meanCentroid: 0.6, meanLoudness: 0.001 } });
assert.ok(murky.character.murk > clear.character.murk + 0.3, 'murk spread too small');
const buildMurky = (map) => {
const sc = new T.Scene();
const spec = { ...dungeonSpec, seed: 6, dungeon: { map } };
const inst = dungeonMod.build({
scene: sc, seed: 4242, params: {}, actorSpec: actors.monolith,
venueSpec: spec, THREE: T,
});
const cam = stubCam();
dungeonMod.update({
instance: inst, scene: sc, camera: cam,
timeline: { time: 10, frame: 600, progress: 0.05 },
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: spec,
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
THREE: T,
});
return { inst, fogNear: sc.fog ? sc.fog.near : Infinity };
};
const murkyBuilt = buildMurky(murky);
const clearBuilt = buildMurky(clear);
const murkyInst = murkyBuilt.inst, clearInst = clearBuilt.inst;
assert.ok(murkyInst.ambient.intensity < clearInst.ambient.intensity,
'murk must dim ambient');
assert.ok(murkyInst.key.intensity < clearInst.key.intensity,
'murk must dim the key');
assert.ok(murkyInst.camFill.intensity < clearInst.camFill.intensity,
'murk must dim the lantern fill');
assert.ok(murkyBuilt.fogNear < clearBuilt.fogNear,
'murk must pull fog closer');
}
});
});