PROCITY/web/js/interiors/interiors.js
m3ultra 488067004c Lane C R28: audioEmitter — the till rings from the counter; the panner was inert
R28 ledger #3. My own idea, parked since R7. Brief stated acceptance only, so I scoped it.

THE TRAP MY OWN v3.0 NOTE SET: it said "userData.audioEmitter = musicKey so Lane B can
position/spatialise the bed". Both halves wrong. (1) Shape: the engine holds no reference to
the room group and would traverse a scene it cannot reach — emitters belong on room.audio as
data, riding the EXISTING playInterior() call (no lifecycle, no traverse, no dispose hook).
(2) Method: "spatialise" invites a PannerNode. NOTHING in this tree sets ctx.listener (zero
hits), so a panner resolves against a listener frozen at (0,0,0) — which, because rooms are
built room-local at the origin, is the room's own CENTRE, forever. Measured, OfflineAudioContext,
real pub stage bed:
    panner, listener never set (the engine as it SHIPS): RMS 0.106905 door / 0.106905 stage
                                                          -> ratio 1.0000, bit-identical
    panner + listener tracking the player:                RMS 0.055665 / 0.500000 -> 8.98x
It plays, throws nothing, warns nothing, and never changes by one sample as you cross the room
— while failing at the only thing it exists to do. "Routed through a panner?" yes. "Audible in
a venue?" yes. "In a shop?" yes. All green, all worthless.
-> ASSERT THE DELTA (gain at door != gain at stage), NEVER THE PRESENCE. Vacuous-gate law, 4th
application — and this one had no author to blame but me.
The house idiom was already right: tram/spill/gig-spill are all hand-rolled distance-gains vs
PROCITY.camera.position. No listener, no panner.

SHIPPED (C's files only):
- room.audio.emitters {stage?, counter?} — room-local, 3dp, 18/18 same-seed deep-equal. `r`
  (reach) is the ROOM DIAGONAL, not a constant: measured diagonals 9.81-15.63m, and scaling to
  the room holds door->stage proximity in a 0.42-0.59 band from a pokey band_room to a hall pub.
  `floor` is the room-filling residue, never 0 — a PA fills a pub; what changes as you walk in
  is the BALANCE, not the presence. Swing 2.65-4.52 dB (John R20: subtle changes are great).
- THE TILL RINGS FROM THE COUNTER — audible positioning in a normal shop with NO ENGINE CHANGE,
  via B's already-public playSfx(key,{gain}): C does its own distance math, hands B a scalar.
  Measured through the real BUY button, real record shop: 0.9489 @0.60m vs 0.6436 @4.19m =
  3.37 dB; negative control (no emitters) fires at exactly 1.0. sfx-till.ogg has been on disk
  since 15 Jul and had NEVER ONCE PLAYED. Routing, not content — as the brief said.
- ?mute=1 now actually silences the crate riffle (below).

WHY THE VENUE HALF IS AN ASK: a one-shot resolves its gain once, at fire time, from a position C
already knows. A bed must be re-gained every frame inside the engine's update() loop — B's file.
Structural, not political. B has a ~12-line paste-ready diff in the house idiom; audio.js
untouched. The letter of "consulted not modified" could have been met by reaching through B's
exposed state.layers diag to rewire its graph from outside — that backdoor is strictly worse
than the edit it avoids. Refused.

TWO BUGS FOUND:
- F DROPS EMITTERS ON THE ONE NIGHT THEY MATTER. interior_mode.js:289 rebuilds the spec on a gig
  night ({musicKey: ra.gigKey, toneKey: WALLA_KEY}) and every unnamed field dies. Measured on a
  real gig-night pub: quietNight_emittersSurvive TRUE, gigNight_emittersSurvive FALSE. Silent,
  fail-soft, no error. -> F: don't just spread `ra`; pass emitters DELIBERATELY, gig-night only —
  on a quiet night the radio + room-tone genuinely ARE room-filling.
- ?mute=1 NEVER SILENCED THE RIFFLE (R5->R28). dig.js opened a SECOND, private AudioContext
  straight to destination, breaking both stated house laws (ONE AudioContext; mute forces
  silence). It survived three epochs because NO GATE ASSERTS MUTE => SILENCE: the engine returns
  its silent surface and looks honest from its own side, while the sound came from somewhere it
  never knew existed. A gate that asks the engine whether it is muted can only ever answer yes.
  Fixed via B's PUBLIC .muted getter (consulted, not modified), URL fallback when standalone.
  Measured with a positive control so the probe provably CAN see sound: live 1 ctx / 1 source;
  muted 0 / 0. Blips stay procedural on purpose — zero-fetch, deterministic, still work under
  ?noassets=1 where a sampled riffle cannot. Only mute was broken; only mute was fixed.
  -> F: assert SILENCE, not muted-state.

HARNESS HONESTY: the first till rig reported the same clean 3.37 dB — from a CONTAMINATED rig
(createDig appends a panel per call, close() doesn't remove it, so querySelector clicked trial
1's stale button every time). Tell: panelUp false on all three, and the NEGATIVE CONTROL
returning 0.6436 where it must return 1.0. The gains matched the formula exactly; I refused them
anyway and re-ran isolated (panelCount 1, panelUp true, control 1.0). Never report a number from
a rig you have watched lie — even when you like the number.

QA GREEN 6/0/0/0. Docs: LANE_C_AUDIO.md -> v3.1 (contract + both asks + every measurement).
2026-07-17 17:39:54 +10:00

248 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane C — public API. The Vuntra move: every shop door opens into a unique, believable,
// seeded interior, generated on demand in <50ms, byte-identical every revisit (shop.seed), themed
// by shop.type. This is a standalone library + test page; Lane B wires it to real doors later via
// the procity:enterShop / procity:exitShop events.
//
// import { buildInterior } from './js/interiors/interiors.js';
// import * as THREE from 'three';
// const room = buildInterior(shop, THREE); // shop = a CityPlan shop record + its lot dims
// scene.add(room.group);
// // … player walks around, hits room.exits …
// room.dispose(); // frees all per-room geometry/material/textures
//
// buildInterior(shop, THREE, opts?) → {
// group, // THREE.Group, self-contained (lights included). Add to a scene.
// spawn: { x, z, ry }, // where/what-heading the player enters at (just inside the door)
// exits: [ { x, z, w, toStreet } ], // door(s) back to the street
// places: [ …meshes with userData ], // interactables: counter, bins, case, fridge, returns, exit
// counter: { mesh, pose, stand }, // pay point. stand:{x,z,ry} = where a shopkeeper (Lane D)
// // stands, in room-local space, facing the customer.
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
// // browser rigs, facing the goods (same frame + ry as counter.stand)
// audio: { musicKey, toneKey, gigKey? }, // R11/R12 — keys into Lane E manifest.audio (music bed +
// // room-tone), seeded per shop. musicKey may be null. gigKey set
// // only when opts.gig is on (venue) — F prefers gigKey over musicKey.
// watchPoints: [ {x,z,ry,dance,slotIndex} ], // VENUE (R12/13, else []) — audience poses facing the stage;
// // dance ~1/3 true. Cap by kind: pub/band_room 8, rsl 12. Same
// // frame + ry convention as counter.stand (rig-front = local Z).
// stage: { x,z,w,d,deckY,riserY,frontZ,bandPoses,backline }, // VENUE (else null) — the band stage. bandPoses[4]:
// // front-line trio (guitar/vocal/bass) on the deck + a drummer (role
// // 'drums', seated) up-stage on the riser. Each pose carries y (front
// // line = deckY, drummer = riserY). ry=π faces the audience. backline[]
// // (v3.1) = up-stage amp slots {x,z,ry,y} where Lane D plants the
// // guitar_amp GLB (primitive fallback under ?noassets).
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
// dispose(), // release GPU resources; removes group from its parent
// }
//
// `shop` is tolerant. Recognized fields: { id, type, name, seed, storeys, lot:{w,d} }.
// • seed : uint32 — the ONLY randomness source (falls back to a hash of id/name if absent)
// • type : any CITY_SPEC type or thriftgod alias (see theme.js); defaults to 'opshop'
// • lot : { w, d } metres — room adapts to it; if absent, archetype ranges decide the size
// • storeys: ceiling height driver (1 → 3.2m … up to 4.5m)
// opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, stock?: 'real', stockBase? }
// • stockBase (v5.0): which real-stock pack `stock:'real'` resolves. Omit → the town-wide packs
// (`assets/models/`, today's behaviour). Pass `assets/stock_godverse/<godverseShopId>/` for a shop
// with its own real crate. The pack cache is keyed by (type, base) — see stockpack.js + LANE_C_PUB §7.
//
// Registry override (Lane F): call mergeRegistry(registry) ONCE at init to let authored registry data
// override recipe fields. It is deliberately NOT a per-build option — a per-call mutation of the shared
// recipe table would break the same-seed determinism guarantee for every later build.
import { Ctx } from './context.js';
import { getRecipe, mergeRegistry, SHOP_TYPES } from './theme.js';
import { ARCHETYPE_KEYS, chooseArchetype, computeDims, buildShell } from './shell.js';
import { layout } from './layout.js';
import { batchRoom } from './batch.js';
import { preloadManifest, upgradeRoom } from './glb.js';
import { getStockPack, makeStockAdapter } from './stockpack.js';
import { xmur3 } from '../core/prng.js';
export { SHOP_TYPES, ARCHETYPE_KEYS, mergeRegistry };
export { preloadManifest } from './glb.js'; // Lane F: preload once so GLB upgrades apply synchronously
export { preloadStockPack, getStockPack, makeStockAdapter } from './stockpack.js'; // ?stock=real
let glbManifest = null; // resolved manifest cache (once the depot answers)
const _warned = new Set();
function warnOnce(msg) { if (!_warned.has(msg)) { _warned.add(msg); console.warn('[interiors] ' + msg); } }
function normalizeShop(shop) {
const s = shop || {};
let seed = s.seed;
if (seed == null) seed = xmur3(`${s.id ?? ''}:${s.name ?? ''}:${s.type ?? ''}`)();
const lot = s.lot && s.lot.w && s.lot.d ? { w: +s.lot.w, d: +s.lot.d }
: (s.lotW && s.lotD ? { w: +s.lotW, d: +s.lotD } : null);
return {
id: s.id ?? 'shop',
type: s.type ?? 'opshop',
name: s.name ?? '',
seed: seed >>> 0,
storeys: s.storeys ?? 1,
lot,
raw: s,
};
}
// ── interior audio contract (round 11) ───────────────────────────────────────────────
// buildInterior returns room.audio = { musicKey, toneKey } — keys into Lane E's manifest.audio (a music
// bed + an interior room-tone). Lane F's interior_mode plays them on enter / fades on exit; Lane B's
// audio.js resolves key → file. Missing key / ?mute=1 / ?noassets=1 → silence (house audio law), so this
// is pure data: buildInterior stays synchronous and asset-free, it only names what SHOULD play.
// The maps mirror the manifest's per-type `types` arrays so the type→bed grouping has one source of truth.
const ROOMTONE_BY_TYPE = { // manifest.audio.ambience entries with scope:'interior'
milkbar: 'roomtone-milkbar',
video: 'roomtone-video', dept: 'roomtone-video', arcade: 'roomtone-video',
}; // default → 'roomtone-retail'
const MUSIC_BY_TYPE = { // manifest.audio.music dedicated beds
record: 'record-shop', // "record shop plays music" — always on
milkbar: 'milkbar', video: 'video-synth',
dept: 'arcade', arcade: 'arcade',
}; // default → null (no dedicated bed)
// The genre a venue kind plays — mirrors registry.SHOP_TYPES[kind].genre so C resolves the gigKey even
// standalone (the gig plan carries shop.genreKey; this is the fallback when a test builds a bare venue
// without A's conversion). ROUND13 §Lane C: pub → pubrock, band_room → grunge, rsl → covers.
const GENRE_BY_TYPE = { pub: 'pubrock', band_room: 'grunge', rsl: 'covers' };
function audioFor(type, ctx, opts, shop) {
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
let musicKey = MUSIC_BY_TYPE[type] || null;
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
// manifest "general" tag). Seeded on its own sub-stream so the same shop is silent-or-playing every
// revisit and adding it never shifts another subsystem's seeded picks.
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
const audio = { musicKey, toneKey };
// Venue gig (R12/13): Lane F passes the live gig state via opts.gig; F should prefer gigKey over musicKey
// while the gig is on. A quiet-night venue has no opts.gig → normal interior (room-tone + maybe radio).
// The genre resolves gig.genreKey → the venue's own shop.genreKey (A sets it) → the kind default. The
// manifest key IS the gigKey, canonical form `gig-<genreKey>` — no mapping table (ROUND13 debt #1). This
// string is byte-identical to citygen's `gigKeyFor(genreKey)` (selfcheck asserts it); it is hand-built,
// NOT imported, on purpose — the interiors lib stays standalone (never imports citygen). Keep the form,
// don't privately rename it (the R12 trap) and don't import citygen (breaks standalone).
const gig = opts && opts.gig;
if (gig && (gig.on || gig.genreKey)) {
const genreKey = gig.genreKey || (shop && shop.genreKey) || GENRE_BY_TYPE[type] || 'pubrock';
audio.gigKey = 'gig-' + genreKey;
}
return audio;
}
// ── audio EMITTERS (round 28) — WHERE a key sounds from ────────────────────────────
// room.audio.emitters says which room-local point each bed/sfx belongs to, so the gig comes from the
// stage and the till from the counter instead of from the whole room. Pure data on the EXISTING
// `playInterior(room.audio)` call — no new lifecycle, no traverse, no dispose hook (the v3.0 note
// proposed `userData.audioEmitter` on a prop mesh; that shape is wrong — the engine holds no reference
// to the room group and would have to walk a scene it can't reach. The line was the stale thing).
//
// `r` is the REACH: distance at which the emitter has decayed to `floor`. It is the ROOM DIAGONAL, not
// a constant — measured across 8 types × 4 seeds, diagonals run 9.8115.63 m, and scaling by the room
// keeps the door→stage proximity in a consistent 0.420.59 band from a pokey band_room to a hall pub.
// `floor` is the room-filling RESIDUE and is never 0: a pub PA fills the room, what changes as you walk
// in is the BALANCE, not the presence. floor 0.35 ⇒ ~4 dB door→stage — audible, not a new game.
//
// NOTE FOR THE CONSUMER (measured, R28): do NOT spatialise these with a PannerNode. Nothing in the tree
// sets `ctx.listener`, so a panner resolves against a listener frozen at (0,0,0) — which, because rooms
// are built in room-local coords at the origin, is the room's own centre, forever. Measured via
// OfflineAudioContext: stage bed RMS 0.106905 at the door, 0.106905 at the stage — ratio 1.0000, no
// error, no warning, and it never changes as you cross the room. Use a distance-gain against
// PROCITY.camera.position, the way the tram/spill/gig-spill already do. See LANE_C_AUDIO.md §Emitters.
const EMITTER_FLOOR = { stage: 0.35, counter: 0.15 }; // PA fills a room; a till is a point source
const round = (v) => Math.round(v * 1000) / 1000; // same 3-dp form layout.js publishes poses in
function emittersFor(lay, dims) {
const r = round(Math.hypot(dims.W, dims.D)); // the room's own reach
const em = {};
if (lay.stage) {
// ear height above the deck, at the band's centre — not the deck surface
em.stage = { x: round(lay.stage.x), y: round((lay.stage.deckY || 0) + 1.2), z: round(lay.stage.z),
r, floor: EMITTER_FLOOR.stage };
}
if (lay.counter) {
em.counter = { x: lay.counter.pose.x, y: 1.1, z: lay.counter.pose.z,
r, floor: EMITTER_FLOOR.counter };
}
return em;
}
export function buildInterior(shop, THREE, opts) {
opts = opts || {}; // tolerate undefined AND explicit null
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
const norm = normalizeShop(shop);
const recipe = getRecipe(norm.type);
const ctx = new Ctx(THREE, norm.seed);
// room shape → dims → shell
const archetype = chooseArchetype(recipe, ctx.stream('archetype'), opts.archetype);
const dims = computeDims(ctx, recipe, archetype, norm.lot, norm.storeys);
const shell = buildShell(ctx, { recipe, archetype, dims });
// fittings + wall decor + stock + walkable-path guarantee. ?stock=real feeds a real-stock pack through
// the stockAdapter seam (preload it first); missing pack → parody canvas (warn once).
// `opts.stockBase` (v5.0) selects WHICH pack: omit it for the town-wide packs, or pass a per-shop base
// (`assets/stock_godverse/<godverseShopId>/`) for a shop with its own real crate. The pack cache is keyed
// by (type, base), so a per-shop pack and the town-wide pack coexist — see stockpack.js.
let adapter = opts.stockAdapter || null;
if (!adapter && opts.stock === 'real') {
const pack = getStockPack(recipe.key, opts.stockBase);
if (pack) adapter = makeStockAdapter(pack);
else warnOnce(`stock=real: no pack for "${recipe.key}" @${opts.stockBase || 'default base'} (preload it) — using parody canvas`);
}
const lay = layout(ctx, { recipe, dims, shell, adapter, shop: norm });
// DRAW BATCHING (round 6, ≤350 draws/room law): merge the many same-material stock/fixture meshes
// inside each fitting group into a few merged geometries. Fitting groups (and their userData) are
// kept, so bins stay raycast targets for ?dig=1. Runs BEFORE the GLB upgrade so its frame-hide sees
// the merged fixture meshes. opts.noBatch disables it (diagnostics only).
if (!opts.noBatch) batchRoom(ctx, shell.group);
// OPTIONAL GLB hero-prop upgrade (off by default). Placeholder-persists: primitives stay until (and
// unless) the depot answers. `opts.useGLB` enables it; the manifest may be preloaded (glb.preloadManifest)
// or passed as opts.manifest. Never blocks the build; never required for a fully-usable room.
let glbReady = Promise.resolve(); // resolves when GLB upgrades settle (immediately if off)
if (opts.useGLB) {
const manifest = opts.manifest || (glbManifest && glbManifest.__resolved) || null;
if (manifest) glbReady = upgradeRoom(ctx, lay.placed, manifest, recipe.key);
else glbReady = preloadManifest(opts.manifestUrl).then(m => {
if (m && !ctx._disposed) { glbManifest = { __resolved: m }; return upgradeRoom(ctx, lay.placed, m, recipe.key); }
});
}
const group = shell.group;
const buildMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0;
let disposed = false;
const api = {
group,
spawn: shell.spawn,
exits: shell.exits,
places: lay.places,
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
watchPoints: lay.watchPoints || [], // venue: [ {x,z,ry,dance,slotIndex} ] audience poses, cap by kind (pub/band_room 8, rsl 12); else []
stage: lay.stage || null, // venue: { x,z,w,d,deckY,riserY,frontZ,bandPoses[4],backline[] } (4-piece + up-stage amp slots); else null
// { musicKey, toneKey, gigKey?, emitters } — Lane E manifest.audio keys (R11/R12) + WHERE they sound
// from (R28). emitters.stage is venue-only; emitters.counter is always present. Absent emitter ⇒
// room-filling, which stays the right default for a shop radio (the v3.0 call, unchanged).
audio: { ...audioFor(recipe.key, ctx, opts, norm.raw), emitters: emittersFor(lay, dims) },
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,
pathOK: lay.pathOK,
carved: lay.carved,
glbReady, // Promise — resolves when opts.useGLB hero-prop swaps have settled
buildMs,
_debug: { grid: lay.grid, spawnCell: lay.spawnCell, targetCell: lay.targetCell, ctx },
counts: () => ctx.counts(),
dispose() {
if (disposed) return;
disposed = true;
if (group.parent) group.parent.remove(group);
// drop lights/children refs, then free tracked GPU resources
ctx.disposeAll();
},
};
return api;
}