diff --git a/flow-state/DESIGN.md b/flow-state/DESIGN.md
index 32fb25c..29b365a 100644
--- a/flow-state/DESIGN.md
+++ b/flow-state/DESIGN.md
@@ -124,6 +124,17 @@ Actors and objects change only at camera cuts:
- Absolute animation speed follows the song: motion bias is
tempo-dominated and every `rate: true` param scales by per-track
`rateScale` — slow songs got fast scenes before this.
+- Venues have a per-track **character** the same way: the dungeon derives
+ sealed/murky/big (`dungeonCharacter`: tonal darkness, doubling-compensated
+ tempo, stillness, weight) from the measured summary, calibrated against
+ the bank because arrangement intent doesn't survive measurement (ambient
+ doubles tempo, hiss reads bright). Dark slow songs seal fully indoor;
+ blazing masters always crack open.
+- The dungeon also gets a per-track visual **theme** (`dungeonTheme`:
+ crypt/overgrown/ember/flooded/windswept), scored off the same summary
+ traits — one album must not render the same dungeon twice. Pace terms
+ gate on tempoConfidence (doubled detectors must not read fast), jitter
+ is small enough that decided races stay decided across seeds.
## 7. Grounds and composition
diff --git a/flow-state/dungeon-debug.html b/flow-state/dungeon-debug.html
index adfc1e9..96bb8a2 100644
--- a/flow-state/dungeon-debug.html
+++ b/flow-state/dungeon-debug.html
@@ -60,7 +60,7 @@ look.venue.module = dungeon;
look.venue.name = dungeon.name;
look.venue.archetype = dungeon.archetype;
const mapSeed = mapSeedParam !== null ? Number(mapseedParam) : (look.venue.seed ?? 7);
-const map = genDungeon({ seed: mapSeed });
+const map = genDungeon({ seed: mapSeed, summary: track.summary });
look.venue.dungeon = { map };
// ---------------------------------------------------------- 2D map
@@ -69,6 +69,9 @@ const mc = document.getElementById('map');
const mg = mc.getContext('2d');
const S = mc.width / map.w;
document.getElementById('maptitle').textContent = `${songName} · map seed ${mapSeed} · ${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u`;
+const ch = map.character || {};
+const chLine = `
characterseal ${(ch.indoorBias ?? 0).toFixed(2)} · murk ${(ch.murk ?? 0).toFixed(2)} · size ×${(ch.sizeMul ?? 1).toFixed(2)}
` +
+ `theme${map.theme ?? 'crypt'}
`;
for (let y = 0; y < map.h; y++) {
for (let x = 0; x < map.w; x++) {
if (!map.grid[y * map.w + x]) { mg.fillStyle = '#07090d'; }
@@ -109,6 +112,9 @@ for (const l of map.items.loot || []) {
mg.strokeStyle = '#fde047'; mg.lineWidth = 1; mg.beginPath();
mg.arc((l.x / 2 + map.w / 2) * S, (l.z / 2 + map.h / 2) * S, 5, 0, 7); mg.stroke();
}
+for (const t of map.items.timbers || []) {
+ dot(t.x, t.z, '#92400e', 2);
+}
if (map.portal) {
dot(map.portal.x, map.portal.z, '#f0abfc', 5);
mg.strokeStyle = '#f0abfc'; mg.lineWidth = 1.5; mg.beginPath();
@@ -123,7 +129,7 @@ mg.fillStyle = '#fff';
map.rooms.forEach((rm) => {
mg.fillText(String(rm.id), (rm.x + 0.3) * S, (rm.y + 1) * S);
});
-document.getElementById('mapmeta').innerHTML =
+document.getElementById('mapmeta').innerHTML = chLine +
map.rooms.map((rm) =>
`room ${rm.id} · ${rm.zone}${rm.indoor ? '' : ' · open'}${rm.w}×${rm.h}${map.watch[rm.id] ? ' · ⌂' : ''}${map.portal && map.portal.roomId === rm.id ? ' · ◉' : ''}
`).join('') +
`
@@ -135,7 +141,7 @@ document.getElementById('mapmeta').innerHTML =
veg · boulder ·
lurker · sentry ·
critter · wisp ·
- loot (halt)
`;
+ loot (halt) · timber`;
// ---------------------------------------------------------- 3D stills
// Fake viz feed so windows/portal/puddles show their IMAGE (the compositor
diff --git a/flow-state/src/venues/VenueGenerator.js b/flow-state/src/venues/VenueGenerator.js
index 2ddd835..759add2 100644
--- a/flow-state/src/venues/VenueGenerator.js
+++ b/flow-state/src/venues/VenueGenerator.js
@@ -171,12 +171,13 @@ export function generateVenue({ summary = {}, rng, personality = null, director
// Dungeon sub-plans: the floor plan itself — grid, rooms, walls, route,
// items, portal, watch cells. Pure data, seeded, serialisable; the facade
// builds it. Other archetypes get dungeon:null. Forked so existing draws
- // don't move (same rule as venue:pop).
+ // don't move (same rule as venue:pop). The track summary tunes the
+ // dungeon's character (sealed/murky/big for dark slow songs).
let dungeonPlan = null;
if (picked.archetype === 'dungeon') {
const dRng = rng.fork('dungeon:plan');
dungeonPlan = {
- map: genDungeon({ seed, rng: dRng }),
+ map: genDungeon({ seed, rng: dRng, summary }),
};
}
diff --git a/flow-state/src/venues/dungeon.js b/flow-state/src/venues/dungeon.js
index fb65cbf..01e291b 100644
--- a/flow-state/src/venues/dungeon.js
+++ b/flow-state/src/venues/dungeon.js
@@ -15,7 +15,7 @@ import { actorToGeometry } from '../actors/meshes.js';
import { generateActor } from '../actors/ActorGenerator.js';
import { buildSky, paintSky } from './sky.js';
import { Rng } from '../engine/rng.js';
-import { genDungeon, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from './dungeon/map.js';
+import { genDungeon, THEMES, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from './dungeon/map.js';
import { PROP_BUILDERS, PROP_TINTS, buildPortal, animateDressing } from './dungeon/props.js';
import { HAZARD_BUILDERS, HAZARD_TINTS, updateHazards, avoidanceOffset } from './dungeon/hazards.js';
@@ -143,6 +143,9 @@ export const dungeon = {
// The map IS the dungeon: grid first, everything built from it.
const map = (vsDun && vsDun.map) || genDungeon({ seed, rng });
const L = map.route.totalLen;
+ // Visual identity for this song: the theme record tints every
+ // material below, so two songs never share the same dungeon.
+ const theme = THEMES[map.theme] ?? THEMES.crypt;
// Stone + flame shared materials (tinted per frame in update).
const stoneMat = new T.MeshStandardMaterial({
@@ -155,7 +158,7 @@ export const dungeon = {
});
const flameMat = new T.MeshStandardMaterial({
color: new T.Color(0.1, 0.05, 0.02),
- emissive: new T.Color(1.0, 0.55, 0.18), emissiveIntensity: 2.2,
+ emissive: new T.Color(theme.torch[0], theme.torch[1], theme.torch[2]), emissiveIntensity: 2.2,
roughness: 0.4,
});
const windowMat = new T.MeshStandardMaterial({
@@ -163,6 +166,12 @@ export const dungeon = {
emissive: new T.Color(1, 1, 1), emissiveIntensity: 1.1,
roughness: 0.5, metalness: 0.1,
});
+ // Timber: theme wood for door frames and wall beams. Static colour —
+ // wood reads as wood on every palette; torchlight does the rest.
+ const woodMat = new T.MeshStandardMaterial({
+ color: new T.Color(theme.wood[0], theme.wood[1], theme.wood[2]), roughness: 0.8, metalness: 0.0,
+ emissive: new T.Color(theme.wood[0] * 0.24, theme.wood[1] * 0.24, theme.wood[2] * 0.24), emissiveIntensity: 1.0,
+ });
// Floor: one continuous slab under the whole grid. A plane cannot
// have holes, so the walk always has ground; walls sit on top.
@@ -197,13 +206,22 @@ export const dungeon = {
}
scene.add(shell);
+ // Timber framing: oak door frames on the gates, posts + plates on
+ // the walls. One shared material, static boxes from the plan.
+ for (const tb of map.items.timbers || []) {
+ const beam = new T.Mesh(new T.BoxGeometry(tb.sx, tb.sy, tb.sz), woodMat);
+ beam.position.set(tb.x, tb.y, tb.z);
+ beam.rotation.y = tb.yaw || 0;
+ scene.add(beam);
+ }
+
// Dressing, all seated once from map cells — static architecture, so
// nothing here ever moves; per-frame work is glow only (see update).
// Torches burn on the walls the route passes; where the route runs
// open ground they stand on iron posts instead of floating.
const dressing = [];
for (const tc of map.items.torches) {
- const torch = PROP_BUILDERS.torch(T, rng, PROP_TINTS.torch);
+ const torch = PROP_BUILDERS.torch(T, rng, theme.torch);
torch.position.set(tc.x, 0, tc.z);
torch.rotation.y = tc.yaw;
if (tc.post) {
@@ -435,7 +453,7 @@ export const dungeon = {
key.position.set(3.5, 5.0, 6.0);
key.castShadow = false;
scene.add(key);
- const torch = new T.PointLight(0xff9a3c, 2.0, 12);
+ const torch = new T.PointLight(new T.Color(theme.torch[0], theme.torch[1], theme.torch[2]), 2.0, 12);
scene.add(torch);
// Lantern fill: a warm light riding with the camera (see update).
const camFill = new T.PointLight(0xffe0b8, 0.7, 20);
@@ -458,7 +476,7 @@ export const dungeon = {
scene.add(party);
const inst = {
- map, shell, stoneMat, floorMat, flameMat, windowMat,
+ map, shell, stoneMat, floorMat, flameMat, windowMat, woodMat,
puddleMat, puddles, portal, portalDiscMat: portal.discMat,
hero, party, torch, dressing, hazards, hazardItems, hazardGates,
cast, lootGroups,
@@ -500,6 +518,11 @@ export const dungeon = {
const L = instance.L;
const c0 = pal[0];
const fieldLight = vState ? vState.light : 0.7;
+ // Murk from the song's character: dark songs dim the whole venue
+ // and pull the fog close. The hero's torch stays brightest — gloom
+ // with one warm light reads dungeon, not black screen.
+ const murk = (instance.map.character && instance.map.character.murk) || 0;
+ const murkDim = 1 - 0.45 * murk;
// --- walk clock: the interrupted odometer. Gait is constant per
// video (see build); halts pause it at loot. Pure f(t).
@@ -535,30 +558,35 @@ export const dungeon = {
// Stone takes the palette; torch flames arc with the section light.
// Generous emissive floor (chamber-junk pattern): on the darkest
// songs lit-only stone goes black and the walk moves through nothing.
+ // Stone takes the palette times the theme: the palette keeps songs
+ // distinct, the theme keeps dungeons distinct — multiplicative, so
+ // neither can wash the other out.
const tintM = 0.45 + fieldLight * 0.75;
- instance.stoneMat.color.setRGB(c0[0] * 0.42 * tintM + 0.07, c0[1] * 0.42 * tintM + 0.07, c0[2] * 0.46 * tintM + 0.08);
- instance.stoneMat.emissive.setRGB(c0[0] * 0.16, c0[1] * 0.16, c0[2] * 0.18);
- instance.floorMat.color.setRGB(c0[0] * 0.3 * tintM + 0.05, c0[1] * 0.3 * tintM + 0.05, c0[2] * 0.34 * tintM + 0.06);
- instance.floorMat.emissive.setRGB(c0[0] * 0.16, c0[1] * 0.16, c0[2] * 0.18);
- if (instance.key) instance.key.intensity = 1.0 + fieldLight * 1.2;
- if (instance.ambient) instance.ambient.intensity = 0.6 + fieldLight * 0.55;
+ const theme = THEMES[instance.map.theme] ?? THEMES.crypt;
+ const sm = theme.stoneMul, fm = theme.floorMul;
+ instance.stoneMat.color.setRGB((c0[0] * 0.42 * tintM + 0.07) * sm[0], (c0[1] * 0.42 * tintM + 0.07) * sm[1], (c0[2] * 0.46 * tintM + 0.08) * sm[2]);
+ instance.stoneMat.emissive.setRGB(c0[0] * 0.16 * sm[0], c0[1] * 0.16 * sm[1], c0[2] * 0.18 * sm[2]);
+ instance.floorMat.color.setRGB((c0[0] * 0.3 * tintM + 0.05) * fm[0], (c0[1] * 0.3 * tintM + 0.05) * fm[1], (c0[2] * 0.34 * tintM + 0.06) * fm[2]);
+ instance.floorMat.emissive.setRGB(c0[0] * 0.16 * fm[0], c0[1] * 0.16 * fm[1], c0[2] * 0.18 * fm[2]);
+ if (instance.key) instance.key.intensity = (1.0 + fieldLight * 1.2) * murkDim;
+ if (instance.ambient) instance.ambient.intensity = (0.6 + fieldLight * 0.55) * murkDim;
// Lantern fill riding with the camera: nearby stone always reads,
// whichever way the key falls. Positioned from the camera, which is
// itself analytic f(t) — seek === playback still holds.
if (instance.camFill) {
instance.camFill.position.set(camera.position.x, 1.2, camera.position.z + 1.5);
- instance.camFill.intensity = 0.8 + fieldLight * 0.6;
+ instance.camFill.intensity = (0.8 + fieldLight * 0.6) * murkDim;
}
// Song sky + matched fog, same contract as chamber/stage/ride.
- paintSky(instance.skyMat, { palette: pal, light: fieldLight * zoneLightMul, style, seed: instance.seed });
+ paintSky(instance.skyMat, { palette: pal, light: fieldLight * zoneLightMul * murkDim, style, seed: instance.seed });
if (scene.fog) {
const fogMul = (vState ? vState.fog : 0.35) * zoneFogMul;
- const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
+ const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45) * (1 - 0.35 * murk);
scene.fog.near = fogDepth;
scene.fog.far = fogDepth + 30;
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
- if (mid) scene.fog.color.copy(mid);
- else scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07);
+ if (mid) scene.fog.color.copy(mid).multiply(new T.Color(theme.fogTint[0], theme.fogTint[1], theme.fogTint[2]));
+ else scene.fog.color.setRGB(c0[0] * 0.05 * theme.fogTint[0], c0[1] * 0.05 * theme.fogTint[1], c0[2] * 0.07 * theme.fogTint[2]).multiplyScalar(1 - 0.3 * murk);
}
// Window slits + portal disc glow with the section light; the IMAGE
// is the bound viz texture.
@@ -684,14 +712,15 @@ export const dungeon = {
// The bearer never renders from his own eyes: first-person hides
// him (a body at the lens clips); the party leads ahead carrying
// lanterns — far enough ahead and aside that no companion fills
- // the lens. Planted shots show the whole party trailing.
+ // the lens even where the route bends (chords run shorter than
+ // arcs). Planted shots show the whole party trailing.
instance.hero.visible = opacity > 0.01 && !firstPerson;
const kids = instance.party.children;
for (let k = 0; k < kids.length; k++) {
- const off = firstPerson ? (4.5 + k * 2.0) : -(1.9 + k * 1.7);
+ const off = firstPerson ? (6.0 + k * 2.0) : -(1.9 + k * 1.7);
const cs = wrapS(heroS + off, L);
const cp = pathAt(instance.map, cs);
- const aside = firstPerson ? (k % 2 === 0 ? -0.7 : 0.7) : 0;
+ const aside = firstPerson ? (k % 2 === 0 ? -0.9 : 0.9) : 0;
kids[k].position.set(
cp.x + avoid * 0.5 + aside + Math.sin(t * 0.5 + k * 2.1) * 0.15,
Math.abs(Math.sin(t * 3.4 + k * 1.4)) * 0.04,
@@ -843,11 +872,13 @@ export const dungeon = {
}
}
// The torch follows the bearer and breathes with the section light —
- // and burns higher when something out there notices him back.
+ // and burns higher when something out there notices him back. Murk
+ // dims it only slightly: the hero's flame is the one light a dark
+ // dungeon keeps.
instance.torch.position.set(
instance.hero.position.x + 0.42, 1.5, instance.hero.position.z);
instance.torch.intensity = (1.6 + fieldLight * 2.0) * (0.9 + 0.1 * Math.sin(t * 9.3))
- * (1 + proxMax * 0.9);
+ * (1 + proxMax * 0.9) * (1 - 0.2 * murk);
if (instance.flameMat) {
instance.flameMat.emissiveIntensity = 2.2 * (0.4 + 0.6 * fieldLight)
* (0.88 + 0.12 * Math.sin(t * 9.3));
diff --git a/flow-state/src/venues/dungeon/map.js b/flow-state/src/venues/dungeon/map.js
index 6fcc190..a43c153 100644
--- a/flow-state/src/venues/dungeon/map.js
+++ b/flow-state/src/venues/dungeon/map.js
@@ -13,32 +13,201 @@
// (BFS-verified), not a ribbon bent through blockwork.
//
// Contract:
-// genDungeon({ seed, rng }) -> DungeonPlan (pure data, serialisable)
+// dungeonCharacter(summary) -> { indoorBias, murk, sizeMul, dark, slow }
+// genDungeon({ seed, rng, summary }) -> DungeonPlan (pure data, serialisable)
// pathAt(plan, s) -> { x, yaw } (world units, wraps)
// roomAt(plan, s) -> roomId (>=0) | -1 (corridor)
// zoneAt(plan, s) -> zone name
// CELL: world units per cell
//
+// Song → dungeon mapping (see dungeonCharacter): dark/slow/heavy songs get
+// sealed, murky, big-roomed dungeons with no outside views; bright/fast
+// songs get airier ruins with forest and wallwalk openings. Same seed +
+// same summary is the same dungeon; the seed alone is not.
+//
// DungeonPlan:
-// { seed, w, h, grid, rooms, wallRuns, ceilRects, route, items, portal, watch }
+// { seed, w, h, grid, rooms, wallRuns, ceilRects, route, items, portal, watch, character, theme }
// grid: Uint8Array open cells; rooms: [{id,x,y,w,h,cx,cy,zone,indoor}]
// wallRuns: [{x0,z0,x1,z1,h}] world-space merged runs (axis aligned)
// ceilRects: [{x0,z0,x1,z1,y}] world-space ceiling slabs (indoor only)
// route: { pts:[{x,z}], cum:[...], totalLen, roomIds:[], zones:[] }
-// items: { props, torches, puddles, windows, gates, gaps, patrols }
+// items: { props, torches, puddles, windows, gates, gaps, patrols, ... }
// portal: {x,z,yaw,roomId} | null; watch: { roomId: {x,z,lookX,lookZ} }
+// character: { indoorBias, sealed, murk, sizeMul, dark, slow } — the mapping above
+// theme: one of THEME_NAMES — the venue's visual identity for this song
import { Rng } from '../../engine/rng.js';
import { smooth01 } from './shared.js';
export const CELL = 2.0;
+const FLOOR_Y = -1.15;
const GW = 44;
const GH = 44;
const ROOM_TARGET = 8;
-const ROOM_MIN = 4;
-const ROOM_MAX = 8;
+const ROOM_MIN = 5;
+const ROOM_MAX = 10;
+const ROOM_ATTEMPTS = 500;
+
+const clamp01 = (x) => Math.max(0, Math.min(1, x));
+
+/**
+ * Song → dungeon character. Pure f(summary); every field that matters about
+ * the track's sound is read here once, so the whole venue derives from one
+ * documented mapping instead of scattered heuristics. Calibrated against the
+ * measured 17-song bank, not arrangement intent — two measurement quirks
+ * shape the design:
+ *
+ * - Tempo doubling: beatless/broken tracks read ~2x (drone 62→170). bpmF
+ * folds readings above 150 back down; the seal veto below still uses the
+ * unfolded reading, because doubling only ever inflates — 160+ measured
+ * is genuinely fast either way.
+ * - Hiss masking: noisy tracks read spectrally bright whatever sits under
+ * the hiss (murk 0.915 ≈ glare 0.912), so darkness alone cannot find
+ * murk's quadrant; flatness + pace + weight can.
+ *
+ * - indoorBias 0..1: how sealed the dungeon is. Dark + slow songs approach
+ * 1 (mostly stone, few openings); blazing masters (centroid ≈ 1) are
+ * cracked open toward ~0.2 regardless of pace.
+ * - sealed bool: fully indoor — every room and corridor roofed, no outside
+ * views. Darkest tonal master (dark ≥ 0.5), or hissy slow weight
+ * (flat > 0.55, slow > 0.3, heavy > 0.4, un vetoed). In the bank exactly
+ * dusk + murk seal; fast hiss (grit/plate) stays open and moves.
+ * - murk 0..1: gloom the facade applies as dimmer lights + closer, darker
+ * fog. Tonal darkness first, hiss and stillness behind it.
+ * - sizeMul 1..1.45: room footprint multiplier. Slow + loud songs get
+ * fewer, bigger masses instead of small rooms.
+ */
+export function dungeonCharacter(summary = {}) {
+ const t = summaryTraits(summary);
+ let indoorBias = clamp01(0.3 + t.dark * 0.65 + t.slow * 0.3 + t.still * 0.1);
+ if (t.dark < 0.05) indoorBias *= 0.55;
+ return {
+ indoorBias: t.sealed ? 1 : indoorBias,
+ sealed: t.sealed,
+ murk: clamp01(t.dark * 0.8 + Math.min(1, (summary?.meanFlatness ?? 0.15) * 1.4) * 0.15 + t.still * 0.15),
+ sizeMul: 1 + clamp01(t.slow * 0.55 + t.heavy * 0.45) * 0.45,
+ dark: t.dark,
+ slow: t.slow,
+ };
+}
+
+/**
+ * Shared audio traits for the venue mapping — one source of truth for
+ * dungeonCharacter (layout) and dungeonTheme (identity) so the two can
+ * never disagree about what a song sounds like. Pace terms gate on
+ * tempoConfidence: doubled detectors report high bpm with low confidence
+ * (drone 170 @ 0.24), and trusting those readings raw would call every
+ * ambient track fast.
+ */
+function summaryTraits(summary = {}) {
+ const s = summary || {};
+ const bpm = s.bpm ?? 120;
+ const dark = 1 - Math.min(1, (s.meanCentroid ?? 0.5) * 1.4);
+ const bpmF = bpm > 150 ? bpm / 2 : bpm;
+ const slow = clamp01((115 - bpmF) / 45);
+ const still = clamp01(((s.dynamicRange ?? 0.5) - 0.35) / 0.55);
+ const heavy = clamp01(((s.meanLoudness ?? 0.004) - 0.0002) / 0.0018);
+ const noisy = (s.meanFlatness ?? 0.15) > 0.55;
+ const paceTrust = clamp01(((s.tempoConfidence ?? 0.5) - 0.3) / 0.4);
+ const fast = clamp01((bpm - 120) / 60) * paceTrust;
+ const bright = clamp01(((s.meanCentroid ?? 0.5) - 0.5) * 2);
+ const dyn = clamp01(((s.dynamicRange ?? 0.5) - 0.3) / 0.5);
+ return {
+ dark, slow, still, heavy, noisy, fast, bright, dyn,
+ sealed: dark >= 0.5
+ || (noisy && slow > 0.3 && heavy > 0.4 && bpm <= 160),
+ };
+}
+
+/**
+ * The five dungeon themes — the venue's visual identity per song. Each
+ * record is pure data (colors as [r,g,b] arrays, pick lists with repetition
+ * as weights, density multipliers), so the plan stays serialisable and the
+ * facade just builds materials from it:
+ * - stoneMul/floorMul: multipliers on the palette-driven stone/floor colors
+ * - torch: absolute flame emissive + torch-light color
+ * - fogTint: multiplier on the fog color (atmosphere grade per theme)
+ * - wood: absolute timber color
+ * - propIndoor/propOutdoor: weighted pick lists for room dressing
+ * - vegMul: boundary planting density · timberMul: wall-beam budget share
+ * - puddleMul: puddle count share
+ */
+export const THEMES = {
+ crypt: {
+ stoneMul: [1.0, 1.0, 1.05], floorMul: [1.0, 1.0, 1.05],
+ torch: [1.0, 0.55, 0.18], fogTint: [1.0, 0.95, 0.9], wood: [0.21, 0.13, 0.07],
+ propIndoor: ['rubble', 'furniture', 'rubble', 'banner', 'stalagmite'],
+ propOutdoor: ['trees', 'rubble', 'rampart', 'trees'],
+ vegMul: 1.0, timberMul: 1.0, puddleMul: 1.0,
+ },
+ overgrown: {
+ stoneMul: [0.85, 1.05, 0.85], floorMul: [0.85, 1.05, 0.85],
+ torch: [0.4, 0.9, 0.6], fogTint: [0.9, 1.0, 0.9], wood: [0.25, 0.18, 0.1],
+ propIndoor: ['rubble', 'banner', 'stalagmite', 'rubble', 'bush'],
+ propOutdoor: ['trees', 'trees', 'bush', 'boulder', 'trees'],
+ vegMul: 1.5, timberMul: 0.7, puddleMul: 1.0,
+ },
+ ember: {
+ stoneMul: [1.05, 0.85, 0.8], floorMul: [1.05, 0.85, 0.8],
+ torch: [1.0, 0.3, 0.12], fogTint: [1.0, 0.85, 0.8], wood: [0.12, 0.08, 0.06],
+ propIndoor: ['rubble', 'stalagmite', 'stalagmite', 'rubble', 'banner'],
+ propOutdoor: ['boulder', 'rubble', 'rampart', 'boulder'],
+ vegMul: 0.5, timberMul: 0.6, puddleMul: 0.5,
+ },
+ flooded: {
+ stoneMul: [0.95, 1.0, 1.1], floorMul: [0.9, 1.0, 1.1],
+ torch: [0.6, 0.8, 1.0], fogTint: [0.9, 0.95, 1.05], wood: [0.2, 0.16, 0.12],
+ propIndoor: ['furniture', 'banner', 'rubble', 'furniture'],
+ propOutdoor: ['bush', 'rampart', 'trees', 'bush'],
+ vegMul: 0.8, timberMul: 0.8, puddleMul: 2.0,
+ },
+ windswept: {
+ stoneMul: [1.1, 1.05, 0.95], floorMul: [1.1, 1.05, 0.95],
+ torch: [1.0, 0.9, 0.75], fogTint: [1.05, 1.0, 0.95], wood: [0.3, 0.22, 0.14],
+ propIndoor: ['furniture', 'rubble', 'banner', 'rubble'],
+ propOutdoor: ['boulder', 'bush', 'rampart', 'boulder'],
+ vegMul: 0.7, timberMul: 0.5, puddleMul: 0.7,
+ },
+};
+export const THEME_NAMES = Object.keys(THEMES);
+
+/**
+ * Pick the venue's visual identity for a song. Pure f(summary, seed):
+ * audio scores pick the region, a small seeded jitter keeps neighbouring
+ * songs from collapsing onto one theme. Calibrated corners:
+ * dark+slow → crypt, hissy → overgrown, heavy/fast → ember,
+ * bright+dynamic → flooded, bright+fast → windswept.
+ *
+ * Two honest-measurement rules shape the weights (same findings as
+ * dungeonCharacter): pace terms gate on tempoConfidence, so doubled
+ * detectors don't read fast; crypt is penalised for blazing masters, so
+ * bright ambient opens instead of entombing. Jitter is 0.15 — enough to
+ * vary re-rolls of genuinely ambiguous songs, too small to flip a decided
+ * race. Calibrated against the measured 17-song bank: crypt×4,
+ * overgrown×4, ember×4, flooded×4, windswept×1.
+ */
+export function dungeonTheme(summary = {}, seed = 1) {
+ const t = summaryTraits(summary);
+ const s = summary || {};
+ // Brightness gate kept separate from darkness: a pale but dynamic
+ // ballad is flooded, never windswept; speed alone never floods.
+ const bright = clamp01(((s.meanCentroid ?? 0.5) - 0.5) * 2);
+ const scores = {
+ crypt: t.dark * 0.7 + t.slow * 0.4 + t.still * 0.3 - t.bright * 0.4,
+ overgrown: (t.noisy ? 1 : 0) * 0.9 + t.slow * 0.2 + (1 - t.dark) * 0.15 - t.fast * 0.4,
+ ember: t.heavy * 0.8 + t.fast * 0.3 + (t.noisy ? 1 : 0) * 0.15,
+ flooded: t.bright * 0.7 + t.dyn * 0.3,
+ windswept: t.fast * 0.9 + t.bright * 0.45,
+ };
+ const rng = new Rng((((seed >>> 0) || 1) ^ 0x7e3a9) >>> 0 || 1);
+ let best = 'crypt', bestScore = -Infinity;
+ for (const name of THEME_NAMES) {
+ const s = scores[name] + rng.next() * 0.15;
+ if (s > bestScore) { bestScore = s; best = name; }
+ }
+ return best;
+}
-const ZONE_NAMES = ['hall', 'room', 'forest', 'wallwalk'];
const zoneIndoor = (z) => z === 'hall' || z === 'room';
const idx = (w, x, y) => y * w + x;
@@ -121,16 +290,25 @@ function losClear(open, w, h, x0, y0, x1, y1) {
// ------------------------------------------------------------------ plan
-export function genDungeon({ seed = 1, rng = null } = {}) {
+export function genDungeon({ seed = 1, rng = null, summary = null } = {}) {
const r = rng ? rng.fork('dungeon:map') : new Rng((seed >>> 0) || 1).fork('dungeon:map');
const sd = (seed >>> 0) || 1;
+ const character = dungeonCharacter(summary);
+ const sizeMul = character.sizeMul;
+ // Visual identity for this song: the theme record drives dressing mix
+ // and densities below; the facade builds materials from the same record.
+ // Seeded off the map seed so the identity never shifts sibling streams.
+ const themeName = dungeonTheme(summary, sd);
+ const theme = THEMES[themeName] ?? THEMES.crypt;
// --- rooms: rejection-sampled rects with a 1-cell moat between them, so
// corridors always have somewhere to go and rooms never share walls.
+ // Footprints scale with the song's heft (sizeMul): heavy songs get
+ // masses, not cells.
const rooms = [];
- for (let attempt = 0; attempt < 240 && rooms.length < ROOM_TARGET; attempt++) {
- const w = r.int(ROOM_MIN, ROOM_MAX);
- const h = r.int(ROOM_MIN, ROOM_MAX);
+ for (let attempt = 0; attempt < ROOM_ATTEMPTS && rooms.length < ROOM_TARGET; attempt++) {
+ const w = Math.max(3, Math.round(r.int(ROOM_MIN, ROOM_MAX) * sizeMul));
+ const h = Math.max(3, Math.round(r.int(ROOM_MIN, ROOM_MAX) * sizeMul));
const x = r.int(1, GW - w - 1);
const y = r.int(1, GH - h - 1);
let ok = true;
@@ -207,18 +385,39 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
}
}
- // --- zones: dealt round-robin off a seeded shuffle, so every map with
- // 4+ rooms holds halls, rooms, forest and castle walls alike.
+ // --- zones: per-room draw against the song's indoorBias. A sealed
+ // character closes every room; otherwise indoor rooms split hall/room
+ // and the rest open to forest/wallwalk. With 4+ rooms and bias < 1, one
+ // room is forced each way so mixed maps always have both stone and sky.
const rZones = new Rng((sd ^ 0x20e5) >>> 0 || 1);
const order = rooms.slice();
for (let i = order.length - 1; i > 0; i--) {
const j = rZones.int(0, i);
[order[i], order[j]] = [order[j], order[i]];
}
+ const indoorPool = ['hall', 'room'];
+ const outdoorPool = ['forest', 'wallwalk'];
+ let sealAll = character.sealed === true;
order.forEach((rm, i) => {
- rm.zone = ZONE_NAMES[i % ZONE_NAMES.length];
- rm.indoor = zoneIndoor(rm.zone);
+ let zone;
+ if (sealAll || rZones.next() < character.indoorBias) {
+ zone = indoorPool[i % indoorPool.length];
+ } else {
+ zone = outdoorPool[i % outdoorPool.length];
+ }
+ rm.zone = zone;
+ rm.indoor = zoneIndoor(zone);
});
+ if (!sealAll && rooms.length >= 4 && !rooms.some((rm) => !rm.indoor)) {
+ const pick = order[rZones.int(0, order.length - 1)];
+ pick.zone = outdoorPool[rZones.int(0, outdoorPool.length - 1)];
+ pick.indoor = false;
+ }
+ if (!sealAll && rooms.length >= 4 && !rooms.some((rm) => rm.indoor)) {
+ const pick = order[rZones.int(0, order.length - 1)];
+ pick.zone = indoorPool[rZones.int(0, indoorPool.length - 1)];
+ pick.indoor = true;
+ }
const zoneOf = new Array(GW * GH).fill('hall');
const indoorOf = new Uint8Array(GW * GH);
for (const rm of rooms) {
@@ -476,10 +675,14 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
// --- items.
const rItems = new Rng((sd ^ 0x171e5) >>> 0 || 1);
- const items = { props: [], torches: [], puddles: [], windows: [], gates: [], gaps: [], patrols: [], vegetation: [] };
+ const items = { props: [], torches: [], puddles: [], windows: [], gates: [], gaps: [], patrols: [], vegetation: [], timbers: [] };
// Boundary planting: bushes, trees and boulders where an outdoor wall
// would stand — forest edges grow green, castle-wall edges pile rock.
// Kind and jitter hash from the cell — pure f(cell), no stream.
+ const plantAt = (x, y, kind, s, jx, jz) => {
+ const p = toWorld(x, y);
+ items.vegetation.push({ x: p.x + jx, z: p.z + jz, kind, s });
+ };
for (const { x, y, zone } of vegCells.values()) {
const hsh = ((x * 73856093) ^ (y * 19349663)) >>> 0;
const p = toWorld(x, y);
@@ -487,15 +690,44 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
// and throw jitter and scale out of range on high-bit cells.
const jx = (((hsh >>> 0) % 100) / 100 - 0.5) * 0.8;
const jz = (((hsh >>> 8) % 100) / 100 - 0.5) * 0.8;
+ // Theme density: ember barrens plant half the edges; above 1.0 the
+ // extra share grows undergrowth beside the main plant. Hash-fraction,
+ // so the same cells plant on every run.
+ if ((((hsh >>> 24) % 100) / 100) > Math.min(1, theme.vegMul)) continue;
const rocky = zone === 'wallwalk';
const kind = rocky
? (hsh % 3 === 2 ? 'bush' : 'boulder')
: (hsh % 3 === 0 ? 'tree' : 'bush');
- items.vegetation.push({
- x: p.x + jx, z: p.z + jz,
- kind,
- s: 0.8 + (((hsh >>> 16) % 100) / 100) * 0.6,
- });
+ const sc = 0.8 + (((hsh >>> 16) % 100) / 100) * 0.6;
+ plantAt(x, y, kind, sc, jx, jz);
+ if (theme.vegMul > 1 && (((hsh >>> 20) % 100) / 100) < theme.vegMul - 1) {
+ plantAt(x, y, 'bush', sc * 0.6, -jx * 0.5, -jz * 0.5);
+ }
+ }
+ // Coverage guarantee: thinning must never leave an outdoor edge bare.
+ // Sweep every open-outdoor edge; plant a bush on the solid neighbour
+ // wherever no plant stands within reach. Fixed iteration order, one
+ // plant per solid cell, so the fill is as deterministic as the rest.
+ const filled = new Set();
+ for (let y = 0; y < GH; y++) {
+ for (let x = 0; x < GW; x++) {
+ if (!open[idx(GW, x, y)] || indoorOf[idx(GW, x, y)]) continue;
+ const w0 = toWorld(x, y);
+ const edges = [
+ [w0.x - CELL / 2, w0.z, x - 1, y], [w0.x + CELL / 2, w0.z, x + 1, y],
+ [w0.x, w0.z - CELL / 2, x, y - 1], [w0.x, w0.z + CELL / 2, x, y + 1],
+ ];
+ for (const [ex, ez, nx, ny] of edges) {
+ if (inBounds(GW, GH, nx, ny) && open[idx(GW, nx, ny)]) continue;
+ if (!inBounds(GW, GH, nx, ny) || filled.has(idx(GW, nx, ny))) continue;
+ const covered = items.vegetation.some((v) => Math.hypot(v.x - ex, v.z - ez) < 1.6);
+ if (!covered) {
+ filled.add(idx(GW, nx, ny));
+ const np = toWorld(nx, ny);
+ items.vegetation.push({ x: np.x, z: np.z, kind: 'bush', s: 0.9 });
+ }
+ }
+ }
}
// Portal first: largest indoor room, the cell farthest off-route. Props
// placed afterwards keep one cell of clearance round it.
@@ -538,9 +770,11 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
if (distToRoute(x, y) < 2) continue;
if (portalCell && Math.abs(portalCell.x - x) + Math.abs(portalCell.y - y) < 2) continue;
const p = toWorld(x, y);
+ // Theme dressing: each theme restocks the room vocabulary, so an
+ // ember vault reads as stalagmites and a flooded hall as furniture.
const type = rm.indoor
- ? rItems.pick(['rubble', 'furniture', 'rubble', 'banner', 'stalagmite'])
- : rItems.pick(['trees', 'rubble', 'rampart', 'trees']);
+ ? rItems.pick(theme.propIndoor)
+ : rItems.pick(theme.propOutdoor);
items.props.push({ x: p.x, z: p.z, yaw: rItems.next() * Math.PI * 2, type });
placed++;
}
@@ -590,9 +824,10 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
yaw: Math.atan2(-px, -pz),
});
}
- // Puddles: 4 spread round the loop, slightly off the walked line.
- for (let k = 0; k < 4; k++) {
- const i = Math.floor((k + 0.5) / 4 * pts.length) % pts.length;
+ // Puddles spread round the loop, slightly off the walked line —
+ // more of them in the flooded theme, fewer in the ember barrens.
+ for (let k = 0, nP = Math.max(2, Math.round(4 * theme.puddleMul)); k < nP; k++) {
+ const i = Math.floor((k + 0.5) / nP * pts.length) % pts.length;
const p = pts[i], q = pts[(i + 1) % pts.length];
const dx = q.x - p.x, dz = q.z - p.z;
const l = Math.hypot(dx, dz) || 1;
@@ -640,6 +875,54 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
items.patrols.push({ x: px, z: pz, yaw: Math.atan2(q.x - p.x, q.z - p.z), s });
}
+ // Timber framing: oak door frames on every gate (two posts + a
+ // lintel the walk passes under) and posts + top plates along a
+ // seeded share of indoor wall runs. World-space boxes; the facade
+ // builds them with one shared wood material. Counts capped so the
+ // mesh budget stays flat across seeds.
+ {
+ const rTimber = new Rng((sd ^ 0x71be5) >>> 0 || 1);
+ for (const g of items.gates) {
+ const fx = Math.sin(g.yaw), fz = Math.cos(g.yaw);
+ const rx = Math.cos(g.yaw), rz = -Math.sin(g.yaw);
+ for (const side of [-1.15, 1.15]) {
+ items.timbers.push({
+ x: g.x + rx * side, y: FLOOR_Y + 1.35, z: g.z + rz * side,
+ sx: 0.24, sy: 2.7, sz: 0.24, yaw: g.yaw,
+ });
+ }
+ items.timbers.push({
+ x: g.x, y: FLOOR_Y + 2.75, z: g.z,
+ sx: 2.75, sy: 0.32, sz: 0.32, yaw: g.yaw,
+ });
+ }
+ let budget = Math.round(120 * theme.timberMul);
+ const order = rTimber.shuffle(wallRuns.slice());
+ for (const run of order) {
+ if (budget <= 0) break;
+ const lenX = run.x1 - run.x0, lenZ = run.z1 - run.z0;
+ const len = Math.max(lenX, lenZ);
+ if (len < 4 || rTimber.next() > 0.6) continue;
+ const alongX = lenX >= lenZ;
+ const yaw = alongX ? 0 : Math.PI / 2;
+ const n = Math.max(2, Math.floor(len / 3));
+ for (let k = 0; k <= n && budget > 0; k++) {
+ const f = n === 0 ? 0.5 : k / n;
+ items.timbers.push({
+ x: run.x0 + lenX * f, y: FLOOR_Y + run.h / 2, z: run.z0 + lenZ * f,
+ sx: 0.3, sy: run.h, sz: 0.3, yaw: 0,
+ });
+ budget--;
+ }
+ if (budget <= 0) break;
+ items.timbers.push({
+ x: (run.x0 + run.x1) / 2, y: FLOOR_Y + run.h - 0.15, z: (run.z0 + run.z1) / 2,
+ sx: len, sy: 0.28, sz: 0.36, yaw,
+ });
+ budget--;
+ }
+ }
+
// --- cast: entities that live on the map, loot worth stopping for,
// and the halts where the walk breaks step. All analytic f(t): entity
// poses are pure functions of time (see entityPose), halts bend the
@@ -816,7 +1099,7 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
rooms: rooms.map((rm) => ({ id: rm.id, x: rm.x, y: rm.y, w: rm.w, h: rm.h, zone: rm.zone, indoor: rm.indoor })),
wallRuns, ceilRects,
route: { pts, cum, totalLen, roomIds: sampleRoom, zones: sampleZone },
- items, portal, watch,
+ items, portal, watch, character, theme: themeName,
};
}
diff --git a/flow-state/test/dungeon-modules.test.js b/flow-state/test/dungeon-modules.test.js
index 8030489..99d2bd6 100644
--- a/flow-state/test/dungeon-modules.test.js
+++ b/flow-state/test/dungeon-modules.test.js
@@ -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');
+});
diff --git a/flow-state/test/venue.test.js b/flow-state/test/venue.test.js
index f631c1b..2d27a99 100644
--- a/flow-state/test/venue.test.js
+++ b/flow-state/test/venue.test.js
@@ -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');
+ }
});
});