PROCITY/web/js/interiors/dig.js
m3ultra 4f0adf12d8 Lane C R26 (v5.0-beta): three contract lines + the dig price sticker (ledgers #1, #4, #6)
Wave 1 — contract lines published first; G and E build to them.

#1 THE BRIEF SAID `tier`. IT CAN'T BE (§7.2b). G's atlases already emit tier: 1 = the CHARTER'S
LADDER RUNG (0 parody / 1 static-real / 2 live), and `kind` is already the stock type. A mint atlas
is ALSO rung 1 — same mechanism (a statically baked per-shop atlas); only the data's origin differs.
Real-vs-mint is an orthogonal axis, not a rung — overloading `tier` puts two meanings in one name,
the same species as the plan-id/godverse-id collision just standing-noted, and as R25's positional
ids. RULING: top-level `sourcing: "real"|"mint"`, REQUIRED; `tier` stays the numeric rung. A mint
index MUST NOT carry POS-snapshot fields (the separation is the honesty). Gates read `sourcing`;
absent => FAIL.

#4 `condition` ISN'T IN THE INDEX (§7.2c) — the ledger assumed it was. The index carries
artist/title/price/price_band; G's SNAPSHOT has condition + sleeve_cond and drops them at emit, so
pickRealOffers mapped cond:'' and the card rendered dangling separators around a hole. That WAS the
"database row". Ruled optional index fields, emitted when the source has them; no invented grades on
mint items (a plausible-but-fictional grade is what `sourcing` exists to prevent).

#6 THE ATLAS MANIFEST (§7.2d): stock_godverse/index.json is the authority for which shops carry
atlases — no entry => no fetch => zero 404s, so the zero-console-errors law holds with no
attribution exception. Shape + both-ways consistency specified. No manifest + no atlases = a
legitimate silent zero-shop state, not an error.

THE PRICE STICKER (code): the pull panel is now the shop's price sticker — manila card, rotation +
shadow, typewriter caps, italic title, a stamped condition grade, and the round yellow $ sticker the
sleeve art already uses (the panel echoes dig.js's own canvas language instead of fighting it).
Fields compose only when present: a missing field never leaves a stray separator.

VERIFIED (fresh context, 5 card shapes, both star branches): real grail VG+/VG + ★GRAIL; real
standard VG+ + STANDARD; real ungraded COLLECTOR (grade omitted cleanly); parody NM + 1973 PUNK;
parody rare (seed 4) VG + 1973 POP + ★RARE. No dangling separators anywhere.

The verification caught a defect in MY OWN card: a real grail read "GRAIL ★ GRAIL" — `s` carried the
price_band AND `rare` derived from that same band (parody is immune: its `s` is a genre). Fixed at
the mapping. And I didn't leave the parody-rare branch on assumption — scanned seeds until a rare
pull appeared and asserted it says RARE, not GRAIL. An unexercised branch is an unproven one.

Budgets: the card is DOM — zero draws, zero tris, no 3D change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:38:42 +10:00

359 lines
21 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;
// 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 ──
let actx;
function blip(kind) {
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; }
blip('thunk');
showToast(`SOLD — ${o.a} · $${o.price}`);
removeEntry(entry);
};
}
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).
function pickRealOffers(pack, seed, n) {
const rr = mulberry32(seed >>> 0), out = [];
for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0];
// 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 })
function open(opts = {}) {
active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || 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;
}