PROCITY/web/js/interiors/dig.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

421 lines
26 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 — crate riffle, ported from 90sDJsim/web/world/dig.js (itself from thriftgod/RecordGod).
// v2 headline behind `?dig=1`: walk to a record bin → flip through its sleeves, pull one, "buy" it.
//
// Scope (round 5): the riffled items are the EXISTING procedural stock — a seeded sleeve list per bin
// (same seed ⇒ same order). Real records feed in later via `stockAdapter(shop, 'sleeve')` (round 6+,
// GODVERSE/mint). **Buy is STUBBED** (a toast + remove-from-bin; getCash returns a constant).
//
// Self-contained: own scene + camera, procedural fwip/thunk audio (no assets), and LEAK-FREE — every
// geometry/material/CanvasTexture minted here is tracked and freed on close()/dispose(). Nothing here
// runs unless the shell/test-page opens it, so `?dig=0` is byte-identical to v1.
import { mulberry32, xmur3 } from '../core/prng.js';
const MAXFLIP = 1.95, BACK = -0.12;
const SLEEVE = [0.31, 0.31, 0.0060]; // w, h, thickness (packed a touch chunkier than vinyl)
// 90s-Australian crate genres → sleeve card colour.
const GENRE = [['ROCK', '#7a2a3a'], ['SOUL', '#b0894a'], ['JAZZ', '#2a4a6a'], ['DISCO', '#8a3a7a'],
['COUNTRY', '#6a5a2a'], ['PUNK', '#3a3a3a'], ['REGGAE', '#2a6a4a'], ['POP', '#b05a8a']];
const ARTISTS = ['THE VERANDAHS', 'DEZ & THE UTES', 'MERINO', 'HILLS HOIST', 'THE ESKIES', 'GOON BAG',
'STOBIE POLES', 'THE FIBROS', 'WARATAH', 'THONGS', 'THE CLOTHESLINE', 'GALAH'];
const TITLES = ['Sunburnt', 'Back o Bourke', 'Servo at Midnight', 'Long Weekend', 'Tinny',
'Southerly Buster', 'Milk Bar Dreams', 'Cane Toad Blues', 'Ute Muster', 'Arvo'];
const CONDS = ['VG+', 'VG', 'G+', 'NM', 'EX'];
// Deterministic sleeve list for a bin — the "existing procedural stock" as riffle-able offers.
function proceduralSleeves(seed, n) {
const r = mulberry32(seed >>> 0);
const out = [];
for (let i = 0; i < n; i++) {
const g = GENRE[(r() * GENRE.length) | 0];
out.push({
i, kind: 'record',
a: ARTISTS[(r() * ARTISTS.length) | 0],
t: TITLES[(r() * TITLES.length) | 0],
y: 1971 + ((r() * 28) | 0),
s: g[0], color: g[1],
cond: CONDS[(r() * CONDS.length) | 0],
price: 2 + ((r() * 28) | 0),
rare: r() < 0.12,
});
}
return out;
}
export function createDig(THREE, renderer) {
// ── tracked resources (leak-free) ──
// Persistent (p*): the fixed set — floor, backdrop, shared sleeve geometry — freed only on dispose().
// Per-open (o*): sleeve textures/materials + crate parts — freed on EVERY close() so repeated
// open/close (e.g. the soak entering a dig per interior) returns to baseline.
const pGeo = new Set(), pMat = new Set(), pTex = new Set();
const oGeo = new Set(), oMat = new Set(), oTex = new Set();
const trackGeo = g => (pGeo.add(g), g);
const trackMat = m => (pMat.add(m), m);
const trackTex = t => (pTex.add(t), t);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x14110c);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.01, 50);
camera.position.set(0, 0.5, 0.92);
camera.lookAt(0, 0.12, -0.2);
const onResize = () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); };
addEventListener('resize', onResize);
scene.add(new THREE.AmbientLight(0xfff4de, 0.55));
const spot = new THREE.SpotLight(0xfff2e6, 26, 5, 0.7, 0.5, 1.2);
spot.position.set(0.4, 1.5, 0.9); spot.target.position.set(0, 0, -0.3);
scene.add(spot, spot.target);
const floor = new THREE.Mesh(trackGeo(new THREE.BoxGeometry(1.4, 0.02, 1.4)),
trackMat(new THREE.MeshStandardMaterial({ color: 0x2a2318, roughness: 0.9 })));
floor.position.set(0.3, -0.011, -0.35); scene.add(floor);
// a warm gradient backdrop (canvas — no external art), so the sleeves stay the subject
const back = new THREE.Mesh(trackGeo(new THREE.PlaneGeometry(5.5, 3.1)),
trackMat(new THREE.MeshBasicMaterial({ map: backdropTex() })));
back.position.set(0, 0.35, -2.8); scene.add(back);
function backdropTex() {
const cv = document.createElement('canvas'); cv.width = 64; cv.height = 48;
const x = cv.getContext('2d');
const g = x.createLinearGradient(0, 0, 0, 48);
g.addColorStop(0, '#2a2016'); g.addColorStop(1, '#0e0b07');
x.fillStyle = g; x.fillRect(0, 0, 64, 48);
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return trackTex(t);
}
// ── sleeve art (cardboard card + genre + $ sticker) — matches the interior stock aesthetic ──
function sleeveTexture(o) {
const cv = document.createElement('canvas'); cv.width = cv.height = 256;
const c = cv.getContext('2d');
c.fillStyle = o.color; c.fillRect(0, 0, 256, 256);
c.fillStyle = 'rgba(0,0,0,.22)'; c.fillRect(0, 0, 256, 12); c.fillRect(0, 244, 256, 12);
c.strokeStyle = 'rgba(255,255,255,.28)'; c.lineWidth = 3; c.strokeRect(14, 14, 228, 228);
c.fillStyle = 'rgba(255,255,255,.92)'; c.textAlign = 'center';
c.font = '700 22px "Courier New",monospace'; c.fillText(o.a.slice(0, 18), 128, 80);
c.fillStyle = 'rgba(255,255,255,.75)'; c.font = '17px "Courier New",monospace';
c.fillText('“' + o.t + '”', 128, 116);
c.fillStyle = 'rgba(0,0,0,.4)'; c.font = '14px "Courier New",monospace';
c.fillText(`${o.y} · ${o.s} · ${o.cond}`, 128, 210);
c.beginPath(); c.arc(210, 48, 30, 0, 7); c.fillStyle = '#f4e11a'; c.fill(); // price sticker
c.fillStyle = '#c1121f'; c.font = '700 20px Arial'; c.fillText('$' + o.price, 210, 55);
if (o.rare) { c.fillStyle = '#ffd23d'; c.font = '20px monospace'; c.fillText('★', 34, 232); }
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4;
oTex.add(t); return t; // per-open → freed on close()
}
let recs = [], cursor = 0, vel = 0, active = false, pulled = null, lastFloor = -1;
let crate = null, offers = [], onCloseCb = null, onBuyCb = null, getCashCb = null;
let emitters = null; // [R28] room.audio.emitters, per open — where the till sounds from. Optional.
// shared across opens (persistent): sleeves are all the same box; sides + crate timber never vary
const sleeveGeo = trackGeo(new THREE.BoxGeometry(SLEEVE[0], SLEEVE[1], SLEEVE[2]));
const sideMat = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a8070, roughness: 0.85 }));
const crateWood = trackMat(new THREE.MeshStandardMaterial({ color: 0x8a6a45, roughness: 0.85 }));
// ── DOM overlay (hint / cursor label / pull panel / walk-out) ──
const el = (tag, cls, txt) => { const e = document.createElement(tag); e.className = cls; if (txt) e.textContent = txt; return e; };
const css = el('style'); css.textContent = `
.pcdg-hint{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#d8d8e0;padding:6px 14px;font:13px "Courier New",monospace;pointer-events:none;display:none;z-index:60}
.pcdg-cur{position:fixed;left:50%;top:64px;transform:translateX(-50%);color:#ffd23d;font:700 15px "Courier New",monospace;text-shadow:0 1px 4px #000;pointer-events:none;text-align:center;max-width:80%;display:none;z-index:60}
/* The pull panel is the shop's PRICE STICKER, not a data readout: manila card, typewriter ink, the
round yellow $ sticker that already sits on the sleeve art (see the sleeve canvas above), a rubber-
stamped condition grade. Fields render only when present — a missing one leaves no stray separator. */
.pcdg-panel{position:fixed;right:6%;top:50%;transform:translateY(-50%) rotate(-1.1deg);width:262px;background:#f4ecd8;border:1px solid #cbbf9e;box-shadow:0 10px 26px rgba(0,0,0,.55);padding:15px 16px 14px;color:#241f18;font:14px "Courier New",monospace;display:none;z-index:61}
.pcdg-panel .sticker{float:right;width:54px;height:54px;border-radius:50%;background:#f4e11a;border:1px solid #d3bf00;color:#c1121f;font:700 18px Arial;line-height:54px;text-align:center;transform:rotate(5deg);margin:-2px -4px 4px 10px;box-shadow:0 1px 3px rgba(0,0,0,.25)}
.pcdg-panel h3{margin:.1em 0 3px;color:#241f18;font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:.02em}
.pcdg-panel .ttl{color:#4c4436;font-size:13px;font-style:italic;margin:0 0 9px;line-height:1.25}
.pcdg-panel .stamps{clear:both;display:flex;gap:6px;align-items:center;flex-wrap:wrap;min-height:1px}
.pcdg-panel .grade{border:1.5px solid #b3121f;color:#b3121f;padding:1px 6px;font:700 11px "Courier New",monospace;letter-spacing:.08em;transform:rotate(-2.5deg)}
.pcdg-panel .tag{color:#6f6553;font-size:11px;letter-spacing:.05em}
.pcdg-panel .star{color:#b3121f;font-size:13px}
.pcdg-panel button{margin-top:13px;width:100%;padding:9px;border:1px solid #8a7f66;background:#e7dcc2;color:#241f18;font:700 13px "Courier New",monospace;letter-spacing:.06em;cursor:pointer}
.pcdg-panel button:hover:not(:disabled){background:#241f18;color:#f4e11a;border-color:#241f18}
.pcdg-panel button:disabled{color:#9a917c;border-color:#c3b99f;cursor:default}
.pcdg-panel .x{position:absolute;top:4px;left:8px;cursor:pointer;color:#a89e86;font-size:13px}
.pcdg-out{position:fixed;left:16px;top:16px;background:#1d1d2b;border:1px solid #666;color:#d8d8e0;padding:8px 14px;font:13px "Courier New",monospace;cursor:pointer;display:none;z-index:60}
.pcdg-out:hover{border-color:#3dff8b;color:#3dff8b}
.pcdg-toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#0e0e16;border:1px solid #3dff8b;color:#3dff8b;padding:14px 22px;font:700 15px "Courier New",monospace;z-index:62;display:none}`;
document.head.appendChild(css);
const hint = el('div', 'pcdg-hint', 'scroll / drag to riffle · click the front sleeve to pull it · Esc / WALK OUT to leave');
const curLbl = el('div', 'pcdg-cur');
const panel = el('div', 'pcdg-panel');
const outBtn = el('div', 'pcdg-out', '← WALK OUT');
const toast = el('div', 'pcdg-toast');
outBtn.onclick = () => close();
document.body.append(hint, curLbl, panel, outBtn, toast);
// ── procedural audio ──
// [R28] HOUSE AUDIO LAW (audio.js, Lane B): (1) ONE AudioContext; (2) ?mute=1 forces silence. This
// file broke both from R5 to R28 — a second, private AudioContext wired straight to `destination`,
// so the riffle sang right through ?mute=1 and never saw B's master gain. It survived three epochs
// because NO gate asserts mute ⇒ silence: the engine dutifully returns its silent surface, `?mute`
// looks honest from the engine's side, and the sound came from somewhere the engine never knew about.
// A gate that asks the engine whether it's muted can only ever answer yes. -> F: assert SILENCE, not
// muted-state (there is no second AudioContext ⇔ the law holds).
// The blips STAY procedural on purpose: zero-fetch, deterministic, and they still work under
// ?noassets=1, where a sampled riffle cannot. Only mute was broken; fix only mute.
// Routing them through B's bus (so master gain applies) needs a ctx/bus seam this lane must not open
// unilaterally — published as the R28 ask in LANE_C_AUDIO.md §Emitters.
const MUTED_BY_URL = (() => {
try { const p = new URLSearchParams(location.search).get('mute'); return p != null && p !== '0'; }
catch { return false; }
})();
function muted() {
const eng = window.PROCITY && window.PROCITY.audio; // B's PUBLIC getter — consulted, not modified
if (eng && typeof eng.muted === 'boolean') return eng.muted;
return MUTED_BY_URL; // standalone interior_test: no engine to ask
}
let actx;
function blip(kind) {
if (muted()) return;
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
if (kind === 'fwip') {
const n = Math.floor(actx.sampleRate * 0.05), buf = actx.createBuffer(1, n, actx.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.sin(i * 0.7) * (1 - i / n)) * 0.6; // deterministic-ish fwip (no Math.random)
const s = actx.createBufferSource(); s.buffer = buf;
const f = actx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 2100; f.Q.value = 0.7;
const g = actx.createGain(); g.gain.value = 0.2;
s.connect(f); f.connect(g); g.connect(actx.destination); s.start();
} else {
const o = actx.createOscillator(); o.type = 'sine'; o.frequency.value = 105;
const g = actx.createGain(); g.gain.setValueAtTime(0.0001, actx.currentTime);
g.gain.exponentialRampToValueAtTime(0.5, actx.currentTime + 0.005);
g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + 0.28);
o.connect(g); g.connect(actx.destination); o.start(); o.stop(actx.currentTime + 0.3);
}
} catch (e) {}
}
function buildCrate(depth) {
if (crate) { scene.remove(crate); crate = null; }
crate = new THREE.Group();
const [w, h] = SLEEVE;
const W = w + 0.06, H = h * 0.72, D = depth + 0.10, th = 0.016;
const add = (bw, bh, bd, x, y, z) => { const g = new THREE.BoxGeometry(bw, bh, bd); oGeo.add(g); const m = new THREE.Mesh(g, crateWood); m.position.set(x, y, z); crate.add(m); };
add(W, th, D, 0, -th / 2, -D / 2 + 0.05);
add(th, H, D, -W / 2, H / 2, -D / 2 + 0.05); add(th, H, D, W / 2, H / 2, -D / 2 + 0.05);
add(W, H, th, 0, H / 2, -D + 0.05); add(W, H * 0.55, th, 0, H * 0.275, 0.05);
scene.add(crate);
}
function buildStack(list) {
recs.forEach(r => scene.remove(r.group)); recs = [];
const [w, h, th] = SLEEVE;
let z = -0.05;
list.forEach((o, i) => {
const grp = new THREE.Group(); grp.position.set(0, 0, z);
let mesh;
if (o.real) { // ?stock=real: a cover PLANE sampling the pack atlas
const g = new THREE.PlaneGeometry(w, h); oGeo.add(g);
const [u0, v0, u1, v1] = o.pack.uvRect(o.item);
const a = g.attributes.uv; a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0); a.needsUpdate = true;
mesh = new THREE.Mesh(g, o.pack.material(o.item)); // shared atlas material (not disposed here)
} else {
const face = new THREE.MeshStandardMaterial({ map: sleeveTexture(o), roughness: 0.75 }); oMat.add(face);
mesh = new THREE.Mesh(sleeveGeo, [sideMat, sideMat, sideMat, sideMat, face, sideMat]); // shared sleeve geo
}
mesh.position.y = h / 2;
grp.add(mesh); grp.rotation.x = BACK;
grp.rotation.z = (((i * 2654435761) >>> 0) % 100 - 50) / 1800; // each sleeve leans its own way (seeded by index)
grp.position.x = (((i * 40503) >>> 0) % 100 - 50) / 12000;
scene.add(grp);
recs.push({ group: grp, offer: o, angle: BACK, homeZ: z });
z -= th + 0.010;
});
buildCrate(Math.max(0.2, -z));
cursor = 0; vel = 0; lastFloor = -1; updateCur();
}
function updateCur() {
const r = recs[Math.round(cursor)];
curLbl.textContent = r ? `${r.offer.a} — “${r.offer.t}` : (recs.length ? '' : 'bins cleaned out');
}
function removeEntry(entry) {
const idx = recs.indexOf(entry); if (idx < 0) return;
scene.remove(entry.group); recs.splice(idx, 1);
let z = -0.05;
recs.forEach(x => { x.homeZ = z; x.group.position.z = z; z -= SLEEVE[2] + 0.010; });
cursor = Math.min(cursor, Math.max(0, recs.length - 1));
pulled = null; panel.style.display = 'none'; updateCur();
}
function showPanel(entry) {
const o = entry.offer;
const cash = getCashCb ? getCashCb() : 999;
// A price sticker, composed from what this item ACTUALLY has. Real pack items carry no year (and
// may carry no grade); procedural ones carry both. Build the stamp row from present parts only —
// never emit a separator around a hole (that's what read as a database row). §7.2c.
const esc = (s) => String(s).replace(/[&<>"]/g, m => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[m]));
const stamps = [];
if (o.cond) stamps.push(`<span class="grade">${esc(o.cond)}${o.sleeveCond ? '/' + esc(o.sleeveCond) : ''}</span>`);
const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · "
if (line) stamps.push(`<span class="tag">${esc(line)}</span>`);
if (o.rare) stamps.push(`<span class="star">★ ${o.real ? 'GRAIL' : 'RARE'}</span>`);
panel.innerHTML = `<div class="x">✕</div>`
+ `<div class="sticker">$${esc(o.price)}</div>`
+ `<h3>${esc(o.a)}</h3>`
+ (o.t ? `<div class="ttl">“${esc(o.t)}”</div>` : '')
+ (stamps.length ? `<div class="stamps">${stamps.join('')}</div>` : '')
+ `<button ${o.price > cash ? 'disabled' : ''}>${o.price > cash ? 'NOT ENOUGH CASH' : 'BUY IT'}</button>`;
panel.style.display = 'block';
panel.querySelector('.x').onclick = unpull;
const b = panel.querySelector('button');
if (b && !b.disabled) b.onclick = () => {
// STUB economy (round 6+): onBuy may veto; default is a toast + remove-from-bin.
const ok = onBuyCb ? onBuyCb(o) : true;
if (ok === false) { unpull(); return; }
// [R28] the sale rings on the till across the shop; the thunk stays as the no-engine fallback so
// the standalone harness (and ?noassets) keeps its sold cue.
if (!sfxFrom('till', emitters && emitters.counter)) blip('thunk');
showToast(`SOLD — ${o.a} · $${o.price}`);
removeEntry(entry);
};
}
// [R28 audioEmitter] The till rings from the COUNTER, not from inside your head. You riffle at a bin,
// so the counter is 2.838.15 m away (measured, 8 types × 4 seeds) — a real distance to carry.
// This needs NO engine seam: B's playSfx(key,{gain}) is already public, so C does its own distance
// math and hands B a scalar. That is the whole reason a ONE-SHOT is tractable and a BED is not — a
// one-shot resolves its gain once, at fire time, from a position C already knows; a bed has to be
// re-gained every frame from inside the engine's update loop, which is B's file. Hence the split ask.
// `sfx-till.ogg` has been on disk since 15 Jul and has never once played: this is routing, not content.
// No emitter (F hasn't plumbed it, or no counter) ⇒ gain 1 ⇒ a plain, un-positioned till. No engine
// ⇒ silence. Both fail soft, both still ring true.
function emitterGain(em) {
if (!em) return 1;
const cam = window.PROCITY && window.PROCITY.camera && window.PROCITY.camera.position;
if (!cam) return 1; // no camera to measure from → room-filling
const d = Math.hypot(cam.x - em.x, cam.z - em.z); // emitters are ROOM-LOCAL; so is the interior camera
const prox = Math.max(0, Math.min(1, (em.r - d) / em.r));
return em.floor + (1 - em.floor) * prox; // never 0 — a till across the shop is quiet, not absent
}
function sfxFrom(key, em) {
const eng = window.PROCITY && window.PROCITY.audio;
if (!eng || !eng.playSfx) return false; // standalone/no engine → silent-and-happy
eng.playSfx(key, { gain: emitterGain(em) }); // ?mute / ?noassets / pre-gesture handled inside
return true;
}
let toastT = null;
function showToast(msg) { toast.textContent = msg; toast.style.display = 'block'; clearTimeout(toastT); toastT = setTimeout(() => (toast.style.display = 'none'), 1400); }
function pull() { const r = recs[Math.round(cursor)]; if (!r) return; pulled = r; blip('thunk'); showPanel(r); }
function unpull() { if (pulled) pulled.group.position.set(pulled.group.position.x, 0, pulled.homeZ); pulled = null; panel.style.display = 'none'; updateCur(); }
function update(dt) {
if (!active) return;
vel *= Math.pow(0.0008, dt);
cursor += vel * dt;
if (cursor < 0) { cursor = 0; vel = 0; }
if (cursor > recs.length - 1) { cursor = Math.max(0, recs.length - 1); vel = 0; }
const fl = Math.floor(cursor);
if (fl !== lastFloor) { lastFloor = fl; blip('fwip'); updateCur(); }
recs.forEach((r, i) => {
const f = THREE.MathUtils.smoothstep(cursor - i, -0.4, 1.2);
const target = BACK + f * (MAXFLIP - BACK);
r.angle += (target - r.angle) * Math.min(1, dt * 12);
if (r !== pulled) r.group.rotation.x = r.angle;
});
if (pulled) {
pulled.group.position.lerp(new THREE.Vector3(0, 0.42, 0.55), Math.min(1, dt * 5));
pulled.group.rotation.x += (0 - pulled.group.rotation.x) * Math.min(1, dt * 6);
}
}
// ── input ──
let dragY = null, moved = 0;
const onWheel = e => { if (!active) return; e.preventDefault(); vel += e.deltaY * 0.012; };
const onDown = e => { if (!active) return; dragY = e.clientY; moved = 0; };
const onMove = e => { if (!active || dragY == null) return; const dy = e.clientY - dragY; moved += Math.abs(dy); cursor += dy * 0.02; vel = dy * 0.6; dragY = e.clientY; };
const onUp = () => { if (!active) return; if (moved < 4 && !pulled) pull(); dragY = null; };
const onKey = e => { if (!active) return; if (e.key === 'Escape') { e.stopPropagation(); if (pulled) unpull(); else close(); } };
renderer.domElement.addEventListener('wheel', onWheel, { passive: false });
renderer.domElement.addEventListener('pointerdown', onDown);
renderer.domElement.addEventListener('pointermove', onMove);
renderer.domElement.addEventListener('pointerup', onUp);
document.addEventListener('keydown', onKey, true);
// ?stock=real: seeded pick of real pack items (same bin seed → same crate contents).
// TIER-2 SEAM (v5.0): `pack.gone` = ids the live read says sold IN THE REAL SHOP. We pick from the
// FULL, stable `pack.items` and omit gone ids AFTER the draw — never from a filtered array.
// Measured (R27, 5 crates): filtering the array first reshuffles the crate — one sale changed 21 OTHER
// records, because every pick indexes by position. Pick-then-omit: 0 collateral, the sold record simply
// leaves and the survivors keep their slots (a crate with 3 sold shows 13, not a different 16).
// Both forms hide the sold record, so "the gone item never renders" cannot tell them apart — the
// collateral is what a gate must assert on. F: update item FIELDS in place (live price) freely; do NOT
// add, remove, or reorder `pack.items` — that identity/order IS the seeded pick's stability.
function pickRealOffers(pack, seed, n) {
const rr = mulberry32(seed >>> 0), out = [];
const gone = pack.gone instanceof Set ? pack.gone
: (Array.isArray(pack.gone) ? new Set(pack.gone) : null);
for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0];
if (gone && gone.has(it.id)) continue; // sold in the real shop → this slot is simply empty
// Real fields straight off the atlas index (§7.2c): condition/sleeve_cond are OPTIONAL — present on
// POS-sourced items, absent on mint ones that had no genuine grade. `y` (year) is never in the index,
// so it stays empty and the card simply omits it. No invented values: an unstated grade stays unstated.
// `grail` becomes the ★ stamp, so it must NOT also ride in `s` — otherwise the card reads
// "GRAIL ★ GRAIL". (Procedural sleeves are unaffected: their `s` is a genre, not the band.)
const band = (it.price_band || '').toUpperCase();
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
s: band === 'GRAIL' ? '' : band, y: '',
cond: it.condition || '', sleeveCond: it.sleeve_cond || '',
rare: it.price_band === 'grail' });
}
return out;
}
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash, emitters })
// `emitters` [R28, OPTIONAL] — pass `room.audio.emitters` and the till rings from the counter at the
// right distance. Omit it and the till still rings, just un-positioned. Never required.
function open(opts = {}) {
active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
emitters = opts.emitters || null;
const count = opts.count || 16;
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count);
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
buildStack(offers);
if (opts.shopName) curLbl.textContent = opts.shopName + ' — ' + curLbl.textContent;
}
function freeOpen() { // free per-open GPU resources → each open/close is leak-free
recs.forEach(r => scene.remove(r.group)); recs = [];
if (crate) { scene.remove(crate); crate = null; }
for (const g of oGeo) g.dispose(); for (const m of oMat) m.dispose(); for (const t of oTex) t.dispose();
oGeo.clear(); oMat.clear(); oTex.clear();
}
function close() {
if (!active) return;
active = false; pulled = null;
[hint, curLbl, panel, outBtn, toast].forEach(e => e.style.display = 'none');
freeOpen();
const cb = onCloseCb; onCloseCb = null;
if (cb) cb();
}
// full teardown — frees the persistent GPU resources + removes listeners/DOM (call when the shell
// is done with the dig, e.g. leaving interior mode entirely).
function dispose() {
close();
removeEventListener('resize', onResize);
renderer.domElement.removeEventListener('wheel', onWheel);
renderer.domElement.removeEventListener('pointerdown', onDown);
renderer.domElement.removeEventListener('pointermove', onMove);
renderer.domElement.removeEventListener('pointerup', onUp);
document.removeEventListener('keydown', onKey, true);
for (const g of pGeo) g.dispose(); for (const m of pMat) m.dispose(); for (const t of pTex) t.dispose();
pGeo.clear(); pMat.clear(); pTex.clear();
[css, hint, curLbl, panel, outBtn, toast].forEach(e => e.remove());
}
return { scene, camera, open, close, update, dispose, get active() { return active; } };
}
// A stable per-bin seed from the shop + a bin index, so a given bin riffles the same order every time.
export function binSeed(shopSeed, binId) {
return (xmur3(`dig:${shopSeed}:${binId}`)()) >>> 0;
}