PROCITY/web/js/interiors/dig.js
m3ultra 6960bdd279 Lane C R27 (v5.0): the tier-2 seam ruling + LANE_C_PUB v5-FROZEN (ledgers #2, #6)
SEAM RULING (§8): bless the direct wire — NO lifecycle hook. F already holds currentAdapter
(interior_mode.js:67) and passes it to dig.open() PER DIG (:111), so the live read lands, F
updates the pack, the next riffle picks it up; not-landed/slow/dead => the adapter is simply the
tier-1 pack => the dig never blocks (charter risk #1). No C lifecycle change.

BUT THE QUESTION UNDERNEATH IT IS C'S, AND THE OBVIOUS WIRING IS WRONG. pickRealOffers indexes
pack.items[(rr()*len)|0], so FILTERING THE ARRAY RESHUFFLES THE CRATE. Measured, 5 crates: one
record selling changed 21 OTHER records (6/2/2/6/5 collateral). Pick-then-omit (draw from the
stable full list, drop gone after) = 0 collateral: a crate with 3 sold shows 13, not a different
16. Selling one record must remove one record, not re-stock the shop.

THE TRAP: both forms hide the sold record — so F's planned assertion ("a real gone item never
renders") GOES GREEN ON THE WRONG WIRING. It cannot see the 21. -> F: assert on COLLATERAL (the
survivors are unchanged across a gone delta), not absence. Vacuous-gate law, third application,
and the cheapest to miss because it passes.

LANDED (dig.js): pack.gone (Set|array) omitted at PICK time. F's invariant published: update item
FIELDS in place (live price) freely; NEVER add/remove/reorder items[] — that identity+order IS the
seeded pick's stability (R25's positional-id lesson, one layer up).

DETERMINISM BOUNDARY sits exactly here: server down => byte-identical tier 1 (gate-able); server
up => the crate legitimately changes (tier 2 is non-deterministic BY DESIGN), so no gate may
assert dig determinism with the server up, and pack.gone never enters a seeded stream or golden.

SCOPED: the bin fan is decorative (a different seeded pick from the dig's offers) — a sold cover
may linger there until rebuild. That's when I'd publish a post-build hook; the epoch's claim
doesn't need it, because the crate you RIFFLE is true.

VERIFIED: pure-function measurement for the collateral claim, then end-to-end through the real
dig's public surface (pull -> Esc -> scroll -> pull): sold record gone, survivors 4/4, collateral
0, items[] untouched at 120, live in-place price shows on the next pull. (My first harness
returned the same title 4x — I never un-pulled — so its number was an artifact; re-ran it properly
rather than report a metric I'd just watched lie.)

DOCS FREEZE (§6): LANE_C_PUB is v5.0-FROZEN, whole document, with the case law at the top: three
amendments here exist because a rule of mine met a case written AFTER it, and both times the lane
building to my rule took the red. Header now states it: when a line meets a case it didn't
foresee, the line is the stale thing — amend it, don't bend the case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:53:02 +10:00

370 lines
22 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).
// 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 })
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;
}