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:
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user