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).
421 lines
26 KiB
JavaScript
421 lines
26 KiB
JavaScript
// 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 ? '' : 'bin’s 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 => ({ '&':'&','<':'<','>':'>','"':'"' }[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.83–8.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;
|
||
}
|