// 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'; import { BANDS, guideText } from './bible.js'; // [R32] THE BIBLE — band ranges + the card's guide stamp 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']]; // [R35] exported: the shell seeds a fresh game's FIRST WANT from this roster (THE FIRST FIVE MINUTES). export 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. // [R32 — THE BIBLE + THE GEM] Every parody sleeve now rolls a seeded BAND (the guide's range) and an // ASKING price with seeded variance: non-gems price INSIDE their band; a 10% gem roll (collector/grail // only — an under-band bargain is not a thrill) prices UNDER the band's low edge, which is the entire // op-shop fantasy made mechanical. Draw COUNT is fixed per item (9 rolls, outcome-independent) so the // stream stays deterministic and re-orderable never. `rare` (the ★) now means grail, matching the real // packs — the card never says "gem"; spotting the asking-under-guide gap is the player's skill. const PARODY_BAND = (roll) => (roll < 0.25 ? 'bargain' : roll < 0.70 ? 'standard' : roll < 0.92 ? 'collector' : 'grail'); const GRAIL_ASK_HI = 120; // parody grail asking ceiling (band renders "$60+") 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]; const a = ARTISTS[(r() * ARTISTS.length) | 0]; const t = TITLES[(r() * TITLES.length) | 0]; const y = 1971 + ((r() * 28) | 0); const cond = CONDS[(r() * CONDS.length) | 0]; const band = PARODY_BAND(r()); const [lo, hi] = BANDS.record[band]; const priceRoll = r(), gemRoll = r(), gemPriceRoll = r(); // always drawn — fixed stream shape const gem = gemRoll < 0.10 && (band === 'collector' || band === 'grail'); // gem asking sits STRICTLY under the keeper's own bandLow offer (min(lo−1, floor(lo/2)) — sell.js), // so a spotted gem always nets margin: collector asks $2–9 vs offer $12 · grail $5–20 vs $30. const price = gem ? (band === 'grail' ? 5 + ((gemPriceRoll * 16) | 0) : 2 + ((gemPriceRoll * 8) | 0)) : lo + ((priceRoll * ((hi == null ? GRAIL_ASK_HI : hi) - lo + 1)) | 0); out.push({ i, kind: 'record', a, t, y, s: g[0], color: g[1], cond, price, band, rare: band === 'grail' }); } 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 onWantCb = null, isWantedCb = null; // [R33] the wantlist seams — absent (?game=0/classic) ⇒ no WANT DOM at all 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 .want{margin-top:7px;text-align:center;font:700 11px "Courier New",monospace;letter-spacing:.08em;color:#8a7f66;cursor:pointer;user-select:none} .pcdg-panel .want:hover{color:#241f18} .pcdg-panel .want.on{color:#b3121f;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(`${esc(o.cond)}${o.sleeveCond ? '/' + esc(o.sleeveCond) : ''}`); const line = [o.y, o.s].filter(Boolean).join(' '); // "1974 SOUL" | "SOUL" | "" — never " · " if (line) stamps.push(`${esc(line)}`); if (o.rare) stamps.push(`★ ${o.real ? 'GRAIL' : 'RARE'}`); // [R32 THE BIBLE] the guide stamp — a RANGE off the 90s guide book, never a verdict. Present-fields- // only law holds: an unknown band leaves no stamp (guideText returns ''). The asking price sits on // the sticker; whether it's under the guide is for the PLAYER to notice — the card never says gem. const guide = guideText('record', o.band); if (guide) stamps.push(`${esc(guide)}`); // [R33] the WANT line — only when the game layer wired the seams (construction-gated DOM: a // ?game=0 / classic card carries no wantlist trace). Already-wanted reads as a stamp, not a button. const wanted = isWantedCb ? !!isWantedCb(o) : false; const wantLine = onWantCb ? `