PROCITY/web/interior_test.html

594 lines
35 KiB
HTML
Raw Permalink 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.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY · Lane C — Interior Test</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; font: 13px/1.4 -apple-system, system-ui, sans-serif; background: #14110c; color: #e8e0d0; }
#app { position: fixed; inset: 0; }
canvas { display: block; }
#ui {
position: fixed; top: 10px; left: 10px; z-index: 10; width: 268px;
background: rgba(24,20,14,0.92); border: 1px solid #4a4030; border-radius: 8px; padding: 12px;
backdrop-filter: blur(6px); box-shadow: 0 6px 24px rgba(0,0,0,0.5);
}
#ui h1 { margin: 0 0 8px; font-size: 14px; letter-spacing: .5px; color: #ffd75e; }
#ui .row { display: flex; gap: 6px; align-items: center; margin: 6px 0; }
#ui label { flex: 0 0 66px; color: #b7ab90; }
#ui select, #ui input { flex: 1; background: #211c14; color: #e8e0d0; border: 1px solid #4a4030; border-radius: 5px; padding: 4px 6px; font: inherit; }
#ui button { flex: 1; background: #3a3120; color: #ffe9a8; border: 1px solid #6a5a38; border-radius: 5px; padding: 6px; font: inherit; cursor: pointer; }
#ui button:hover { background: #4a3f28; }
#ui .btnrow { display: flex; gap: 6px; margin-top: 6px; }
#ui .toggles { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; color: #b7ab90; }
#ui .toggles label { flex: none; }
#hud { margin-top: 10px; padding-top: 8px; border-top: 1px solid #3a3226; font-size: 12px; color: #c9bfa4; white-space: pre-line; }
#hud b { color: #ffd75e; font-weight: 600; }
#soak { margin-top: 8px; font-size: 11.5px; color: #9fd6a0; white-space: pre-line; min-height: 14px; }
#soak.bad { color: #f3a0a0; }
#hint { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 10;
background: rgba(24,20,14,0.85); border: 1px solid #4a4030; border-radius: 6px; padding: 6px 12px; color: #b7ab90; }
#hint b { color: #ffd75e; }
</style>
</head>
<body>
<div id="app"></div>
<div id="ui">
<h1>PROCITY · Lane C</h1>
<div class="row"><label>Seed</label><input id="seed" type="number" value="1990" /></div>
<div class="row"><label>Type</label><select id="type"></select></div>
<div class="row"><label>Archetype</label><select id="arch"></select></div>
<div class="row"><label>Lot w×d</label><input id="lot" type="text" value="auto" /></div>
<div class="btnrow">
<button id="reroll">re-roll ⟳</button>
<button id="prev">◀ seed</button>
<button id="next">seed ▶</button>
</div>
<div class="toggles">
<label><input type="checkbox" id="tgWire"> wireframe</label>
<label><input type="checkbox" id="tgGrid"> occupancy</label>
<label><input type="checkbox" id="tgPath"> path</label>
<label><input type="checkbox" id="tgGLB"> GLB props</label>
</div>
<div class="btnrow"><button id="soakBtn">▶ 50-room soak test</button></div>
<div id="soak"></div>
<div id="hud"></div>
</div>
<div id="hint">Click to walk · <b>WASD</b> move · <b>mouse</b> look · <b>Esc</b> release · <b>G</b> grid · <b>F</b> wireframe</div>
<script type="importmap">
{
"imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } from './js/interiors/interiors.js';
import { createDig, binSeed } from './js/interiors/dig.js';
import { createWallet } from './js/interiors/wallet.js';
import { collapseBuyItem } from './js/interiors/stockpack.js';
import { createSell, sellOffer, sellableIn, nearCounter, SELL_DIST } from './js/interiors/sell.js';
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
if (STOCK_REAL) for (const t of ['record', 'book', 'toy']) preloadStockPack(t); // fail-soft per type
const realAdapterFor = (type) => (STOCK_REAL ? makeStockAdapter(getStockPack(type)) : null);
// ── buy loop v0: in-memory wallet + cash HUD + inventory (I). F wires the same wallet on the street. ──
const wallet = createWallet(1990);
const boughtByBin = {}; // bin key → count sold, so a re-dug bin depletes
const cashChip = document.createElement('div'); cashChip.id = 'cashChip';
const invPanel = document.createElement('div'); invPanel.id = 'invPanel';
const walletCss = document.createElement('style'); walletCss.textContent = `
#cashChip{position:fixed;top:12px;right:14px;z-index:70;background:rgba(24,20,14,.92);border:1px solid #6a5a38;border-radius:8px;padding:8px 13px;font:700 15px "Courier New",monospace;color:#3dff8b}
#invPanel{position:fixed;top:52px;right:14px;z-index:70;width:238px;max-height:62vh;overflow:auto;background:rgba(24,20,14,.95);border:1px solid #4a4030;border-radius:8px;padding:10px 12px;font:12px "Courier New",monospace;color:#d8d8e0;display:none}
#invPanel h4{margin:0 0 6px;color:#ffd75e}
#invPanel .row{display:flex;justify-content:space-between;gap:8px;padding:2px 0;border-bottom:1px solid #2a2418}`;
document.head.appendChild(walletCss); document.body.append(cashChip, invPanel);
function updateCash() { cashChip.textContent = `$${wallet.cash()} · 🛍 ${wallet.count()}`; }
function renderInv() {
const inv = wallet.inventory();
invPanel.innerHTML = `<h4>INVENTORY (${inv.length})</h4>` + (inv.length
? inv.map(it => `<div class="row"><span>${it.artist ? it.artist + ' — ' : ''}${it.title}</span><span>$${it.price}</span></div>`).join('')
: '<div style="color:#8a8a9a">empty — walk to a bin and press E, then BUY</div>');
}
wallet.onChange(() => { updateCash(); if (invPanel.style.display === 'block') renderInv(); });
updateCash();
addEventListener('keydown', e => { if (e.code === 'KeyI' && !e.target.matches('input,select')) { const on = invPanel.style.display === 'block'; invPanel.style.display = on ? 'none' : 'block'; if (!on) renderInv(); } });
// ── renderer / scene / camera ──────────────────────────────────────────────
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.05, 200);
const controls = new PointerLockControls(camera, renderer.domElement);
renderer.domElement.addEventListener('click', () => controls.lock());
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ── UI setup ───────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const typeSel = $('type'), archSel = $('arch');
for (const t of SHOP_TYPES) { const o = document.createElement('option'); o.value = t; o.textContent = t; typeSel.appendChild(o); }
archSel.appendChild(new Option('auto', 'auto'));
for (const a of ARCHETYPE_KEYS) archSel.appendChild(new Option(a, a));
let current = null, curSeed = 0; // the live interior + the seed it was built from (dig per-bin seeding)
let gridHelper = null, pathHelper = null;
let wire = false, showGrid = false, showPath = false, useGLB = false;
let glbManifest = null; // Lane E manifest → GLB props load via the live depot: path (all 22 GLBs published).
fetch('assets/manifest.json').then(r => r.ok ? r.json() : null) // for local-only testing add ?localdepot=1 to the URL
.then(m => { if (m) glbManifest = m; }).catch(() => {});
function parseLot(v) {
const m = String(v).match(/(\d+(?:\.\d+)?)\s*[x×,\s]\s*(\d+(?:\.\d+)?)/);
return m ? { w: +m[1], d: +m[2] } : null;
}
function rebuild() {
if (dig && dig.active) dig.close();
if (current) current.dispose();
clearDebug();
const seed = parseInt($('seed').value, 10) >>> 0;
curSeed = seed;
const type = typeSel.value;
const arch = archSel.value === 'auto' ? undefined : archSel.value;
const lot = parseLot($('lot').value);
const shop = { id: 's' + seed, type, name: type.toUpperCase() + ' ' + seed, seed, storeys: 1, lot };
current = buildInterior(shop, THREE, { archetype: arch, useGLB, manifest: useGLB ? glbManifest : undefined, stock: STOCK_REAL ? 'real' : undefined });
scene.add(current.group);
// place the player at the spawn
camera.position.set(current.spawn.x, 1.6, current.spawn.z);
camera.rotation.set(0, current.spawn.ry, 0);
applyWire();
if (showGrid) buildGridHelper();
if (showPath) buildPathHelper();
updateHUD();
// GLB props load async — re-render + refresh HUD once they settle (rAF is throttled in the preview)
if (useGLB && current.glbReady) current.glbReady.then(() => { if (current) { applyWire(); renderer.render(scene, camera); updateHUD(); } });
}
function updateHUD() {
const d = current.dims, c = current.counts();
$('hud').innerHTML =
`<b>${current.recipe.label}</b> · ${d.archetype}\n` +
`room <b>${d.W.toFixed(1)}×${d.D.toFixed(1)}×${d.H.toFixed(1)}</b> m\n` +
`build <b>${current.buildMs.toFixed(1)}</b> ms · path <b>${current.pathOK ? 'ok' : 'FAIL'}</b>${current.carved ? ' (carved)' : ''}\n` +
`geo ${c.geometries} · mat ${c.materials} · tex ${c.canvasTextures}\n` +
`places ${current.places.length} · counter ${current.recipe.counterPos}` +
(current.counter ? `\nkeeper stand ${current.counter.stand.x.toFixed(2)}, ${current.counter.stand.z.toFixed(2)} · ry ${current.counter.stand.ry.toFixed(2)}` : '') +
`\nbrowse points ${(current.browsePoints || []).length} (Lane D rigs)` +
(useGLB ? `\nGLB props: ${glbCount()} upgraded (local)` : '');
}
function glbCount() {
let n = 0; if (current) current.group.traverse(o => { if (o.userData && o.userData.glbUpgrade) n++; });
return n;
}
// ── debug overlays ───────────────────────────────────────────────────────────
function clearDebug() {
for (const h of [gridHelper, pathHelper]) if (h) { scene.remove(h); h.traverse(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); }); }
gridHelper = pathHelper = null;
}
function buildGridHelper() {
const g = current._debug.grid;
const grp = new THREE.Group();
const geo = new THREE.PlaneGeometry(g.cw * 0.92, g.cd * 0.92);
const matOcc = new THREE.MeshBasicMaterial({ color: '#d6402e', transparent: true, opacity: 0.4, side: THREE.DoubleSide });
const matWall = new THREE.MeshBasicMaterial({ color: '#2e6ad6', transparent: true, opacity: 0.25, side: THREE.DoubleSide });
for (let cz = 0; cz < g.rows; cz++) for (let cx = 0; cx < g.cols; cx++) {
const v = g.occ[cz * g.cols + cx];
if (v === 0) continue;
const m = new THREE.Mesh(geo, v === 2 ? matWall : matOcc);
m.rotation.x = -Math.PI / 2;
m.position.set(-g.W / 2 + (cx + 0.5) * g.cw, 0.03, -g.D / 2 + (cz + 0.5) * g.cd);
grp.add(m);
}
gridHelper = grp; scene.add(grp);
}
function buildPathHelper() {
const g = current._debug.grid;
const start = current._debug.spawnCell, target = current._debug.targetCell;
const grp = new THREE.Group();
const mk = (idx, col) => {
const cx = idx % g.cols, cz = (idx / g.cols) | 0;
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.05, 12), new THREE.MeshBasicMaterial({ color: col }));
m.position.set(-g.W / 2 + (cx + 0.5) * g.cw, 0.06, -g.D / 2 + (cz + 0.5) * g.cd);
grp.add(m);
};
mk(start, '#3ad66a'); mk(target, '#ffd75e');
// keeper stand pose (Lane D spawns a shopkeeper here) — magenta post + a white nose showing the
// facing (local Z = rig front, the convention keepers.js uses).
const ks = current.counter && current.counter.stand;
if (ks) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 1.6, 12),
new THREE.MeshBasicMaterial({ color: '#d63ad6', transparent: true, opacity: 0.6 }));
post.position.set(ks.x, 0.8, ks.z);
const noseGrp = new THREE.Group();
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.5), new THREE.MeshBasicMaterial({ color: '#ffffff' }));
nose.position.set(0, 0, -0.4); // local Z = the way the keeper faces
noseGrp.add(nose); noseGrp.position.set(ks.x, 1.4, ks.z); noseGrp.rotation.y = ks.ry;
grp.add(post, noseGrp);
}
// browse points (R9) — where Lane D stands browser rigs. Cyan post + white nose = facing the goods.
for (const bp of (current.browsePoints || [])) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 1.6, 12),
new THREE.MeshBasicMaterial({ color: '#2ec8d6', transparent: true, opacity: 0.6 }));
post.position.set(bp.x, 0.8, bp.z);
const noseGrp = new THREE.Group();
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.5), new THREE.MeshBasicMaterial({ color: '#ffffff' }));
nose.position.set(0, 0, -0.4); // local Z = facing (same convention as keeper)
noseGrp.add(nose); noseGrp.position.set(bp.x, 1.4, bp.z); noseGrp.rotation.y = bp.ry;
grp.add(post, noseGrp);
}
pathHelper = grp; scene.add(grp);
}
function applyWire() {
current.group.traverse(o => {
if (!o.isMesh) return;
const mats = Array.isArray(o.material) ? o.material : [o.material];
for (const m of mats) if (m && 'wireframe' in m) m.wireframe = wire;
});
}
// ── controls / events ─────────────────────────────────────────────────────────
$('reroll').onclick = () => { $('seed').value = (Math.floor(Math.random() * 1e6)); rebuild(); };
$('next').onclick = () => { $('seed').value = (+$('seed').value + 1); rebuild(); };
$('prev').onclick = () => { $('seed').value = Math.max(0, +$('seed').value - 1); rebuild(); };
typeSel.onchange = rebuild; archSel.onchange = rebuild;
$('seed').onchange = rebuild; $('lot').onchange = rebuild;
$('tgWire').onchange = e => { wire = e.target.checked; applyWire(); };
$('tgGrid').onchange = e => { showGrid = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
$('tgPath').onchange = e => { showPath = e.target.checked; clearDebug(); if (showGrid) buildGridHelper(); if (showPath) buildPathHelper(); };
$('tgGLB').onchange = e => { useGLB = e.target.checked; rebuild(); };
const keys = {};
addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'KeyG') { $('tgGrid').checked = !showGrid; $('tgGrid').onchange({ target: $('tgGrid') }); }
if (e.code === 'KeyF') { $('tgWire').checked = !wire; $('tgWire').onchange({ target: $('tgWire') }); }
});
addEventListener('keyup', e => { keys[e.code] = false; });
// ── first-person movement with occupancy collision + room clamp ────────────────
const RADIUS = 0.28, SPEED = 3.2;
function walkable(x, z) {
const g = current._debug.grid;
if (Math.abs(x) > g.W / 2 - RADIUS || Math.abs(z) > g.D / 2 - RADIUS) return false;
for (const [ox, oz] of [[RADIUS, 0], [-RADIUS, 0], [0, RADIUS], [0, -RADIUS]]) {
const [cx, cz] = g.cellOf(x + ox, z + oz);
if (g.occ[cz * g.cols + cx] === 1) return false; // fitting blocks; walls handled by bounds
}
return true;
}
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0);
function move(dt) {
if (!controls.isLocked || !current) return;
let f = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0);
let s = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
if (!f && !s) return;
camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
right.crossVectors(fwd, up).normalize();
const dx = (fwd.x * f + right.x * s), dz = (fwd.z * f + right.z * s);
const len = Math.hypot(dx, dz) || 1;
const step = SPEED * dt;
const nx = dx / len * step, nz = dz / len * step;
const p = camera.position;
if (walkable(p.x + nx, p.z)) p.x += nx; // per-axis → slide along walls
if (walkable(p.x, p.z + nz)) p.z += nz;
}
// ── draw sweep: worst-room draw count must be ≤350 (round-6 law, decision #2). Asserts so it
// can't regress; F mirrors this in the qa harness. Measures every type × archetype, GLB off AND on.
const DRAW_LAW = 350;
async function drawSweep(opts = {}) {
const wasCurrent = current;
for (const s of scene.children.filter(o => o.userData && o.userData.kind === 'interior')) if (s !== (current && current.group)) scene.remove(s);
let manifest = null;
if (opts.glb) { manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null); }
const measure = async (type, arch) => {
const r = buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE,
{ archetype: arch, useGLB: opts.glb, manifest });
scene.add(r.group); if (opts.glb) await r.glbReady;
camera.position.set(r.dims.W * 0.3, r.dims.H * 0.6, r.dims.D / 2 - 0.8); camera.lookAt(0, 0.9, -r.dims.D * 0.15);
renderer.info.reset(); renderer.render(scene, camera);
const d = renderer.info.render.calls; scene.remove(r.group); r.dispose(); return d;
};
let worst = 0, worstAt = ''; const perType = {};
for (const t of SHOP_TYPES) {
let tw = 0;
for (const a of [undefined, ...ARCHETYPE_KEYS]) { const d = await measure(t, a); if (d > tw) tw = d; if (d > worst) { worst = d; worstAt = `${t}/${a || 'auto'}`; } }
perType[t] = tw;
}
rebuild();
return { worst, worstAt, pass: worst <= DRAW_LAW, law: DRAW_LAW, perType };
}
// ── soak test: build+dispose 50 rooms, assert leak-free + <50ms + deterministic ──
async function soak(N = 50) {
const el = $('soak'); el.classList.remove('bad'); el.textContent = 'running soak…';
if (current) { current.dispose(); current = null; clearDebug(); }
await new Promise(r => requestAnimationFrame(r));
const sampleShop = (i) => ({ id: 'soak' + i, type: SHOP_TYPES[i % SHOP_TYPES.length],
name: 'SOAK ' + i, seed: (1000 + i * 2654435761) >>> 0, storeys: 1 + (i % 3 === 0 ? 1 : 0) });
// warmup so shared file textures are in cache before we take the baseline
const warm = buildInterior(sampleShop(0), THREE); scene.add(warm.group); renderer.render(scene, camera);
scene.remove(warm.group); warm.dispose();
await new Promise(r => requestAnimationFrame(r));
const baseGeo = renderer.info.memory.geometries, baseTex = renderer.info.memory.textures;
let total = 0, worst = 0, detFail = 0;
for (let i = 0; i < N; i++) {
const shop = sampleShop(i);
const t0 = performance.now();
const r = buildInterior(shop, THREE);
const ms = performance.now() - t0; total += ms; worst = Math.max(worst, ms);
scene.add(r.group); renderer.render(scene, camera); // force GPU upload so info.memory is real
// determinism: same seed twice ⇒ identical placement list
const r2 = buildInterior(shop, THREE);
if (JSON.stringify(r.placement) !== JSON.stringify(r2.placement)) detFail++;
r2.dispose();
scene.remove(r.group); r.dispose();
}
renderer.render(scene, camera);
const leakGeo = renderer.info.memory.geometries - baseGeo;
const leakTex = renderer.info.memory.textures - baseTex;
const avg = total / N;
const draws = await drawSweep(); // round-6 ≤350-draw law
const pass = leakGeo <= 0 && leakTex <= 0 && worst < 50 && detFail === 0 && draws.pass;
el.classList.toggle('bad', !pass);
el.textContent =
`${pass ? '✅ PASS' : '❌ CHECK'} · ${N} rooms\n` +
`avg ${avg.toFixed(1)}ms · worst ${worst.toFixed(1)}ms (budget 50)\n` +
`leak: geo ${leakGeo} · tex ${leakTex} (want ≤0)\n` +
`determinism: ${detFail === 0 ? 'identical ✓' : detFail + ' MISMATCH'}\n` +
`worst draws: ${draws.worst} @ ${draws.worstAt} (law ≤${draws.law}) ${draws.pass ? '✓' : '✗'}`;
rebuild();
}
$('soakBtn').onclick = () => soak(50);
// ── dig (?dig=1) — walk to a record bin, press E to riffle its sleeves (v2, flag-gated) ──
const DIG_ON = new URLSearchParams(location.search).has('dig');
let dig = null;
const _digRay = new THREE.Raycaster();
function binUnderAim() {
if (!current) return null;
const bins = []; current.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
if (!bins.length) return null;
_digRay.setFromCamera({ x: 0, y: 0 }, camera);
const hit = _digRay.intersectObjects(bins, true)[0];
if (hit && hit.distance < 3.2) { let o = hit.object; while (o && !(o.userData && o.userData.kind === 'bin')) o = o.parent; return o; }
let best = null, bd = 2.4; const cp = camera.position, p = new THREE.Vector3();
for (const b of bins) { b.getWorldPosition(p); const d = p.distanceTo(cp); if (d < bd) { bd = d; best = b; } }
return best;
}
function openDigOn(bin) {
if (!dig) dig = createDig(THREE, renderer);
if (controls.isLocked) controls.unlock();
const p = new THREE.Vector3(); bin.getWorldPosition(p);
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic position)
const sold = boughtByBin[key] || 0; // the bin depletes as you buy from it
dig.open({ seed: binSeed(curSeed, key), count: Math.max(3, 16 - sold), shopName: current.recipe.label, shop: { type: current.dims.type },
stockAdapter: realAdapterFor(current.dims.type),
getCash: wallet.cash,
onBuy: (o) => { const ok = wallet.buy(o); if (ok) boughtByBin[key] = (boughtByBin[key] || 0) + 1; return ok; },
onClose: () => {} });
}
if (DIG_ON) {
addEventListener('keydown', e => { if (e.code === 'KeyE' && current && (!dig || !dig.active)) { const b = binUnderAim(); if (b) openDigOn(b); } });
document.getElementById('hint').innerHTML += ' · <b>E</b> riffle a record bin · <b>I</b> bag';
}
// enter+leave a dig per record room, BUYING 2 items per visit, assert leak-free (round-8 buy loop)
async function digSoak(N = 15) {
if (!dig) dig = createDig(THREE, renderer);
const richWallet = createWallet(7); // fresh rich-ish wallet so buys land during the soak
let topUps = 0;
const buyer = (o) => { if (o.price > richWallet.cash()) { topUps++; return false; } return richWallet.buy(o); };
dig.open({ seed: 1, count: 16, getCash: richWallet.cash, onBuy: buyer }); renderer.render(dig.scene, dig.camera); dig.close(); renderer.render(scene, camera);
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
const cv = renderer.domElement;
let bought = 0;
for (let i = 0; i < N; i++) {
dig.open({ seed: (i * 7919 + 1) >>> 0, count: 10 + (i % 9), getCash: richWallet.cash, onBuy: buyer });
for (let k = 0; k < 8; k++) dig.update(0.03);
for (let b = 0; b < 2; b++) { // buy 2 per visit via the real pull+BUY path
cv.dispatchEvent(new PointerEvent('pointerdown', { clientX: innerWidth / 2, clientY: innerHeight / 2, bubbles: true }));
cv.dispatchEvent(new PointerEvent('pointerup', { clientX: innerWidth / 2, clientY: innerHeight / 2, bubbles: true }));
for (let k = 0; k < 4; k++) dig.update(0.03);
const btn = document.querySelector('.pcdg-panel button');
if (btn && !btn.disabled) { const before = richWallet.count(); btn.click(); if (richWallet.count() > before) bought++; }
for (let k = 0; k < 4; k++) dig.update(0.03);
}
renderer.render(dig.scene, dig.camera); dig.close();
}
renderer.render(scene, camera);
return { rooms: N, bought, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
}
// ── buy-anywhere (round 9 C2): aim at a real-stock book spine / toy box → pull card → BUY ──
// Records keep the deep dig; shelves get a light pull-and-buy over the SAME wallet. Reuses the
// batched-but-addressable buy meshes from stockpack.buildBuyableShelf. Parody stock has no buy mesh
// → no card ("not for sale"). UI is test-page-local; the reusable pieces are the mesh data + wallet.
const _buyRay = new THREE.Raycaster();
const _lp = new THREE.Vector3();
const shelfPanel = document.createElement('div'); shelfPanel.id = 'shelfBuy';
const shelfCss = document.createElement('style'); shelfCss.textContent = `
#shelfBuy{position:fixed;top:50%;right:26px;transform:translateY(-50%);z-index:72;width:230px;background:rgba(18,15,10,.96);border:1px solid #b58b3a;border-radius:10px;padding:14px 15px;font:12px "Courier New",monospace;color:#e8e2d4;display:none}
#shelfBuy h3{margin:0 0 2px;font-size:15px;color:#ffd75e}
#shelfBuy .sub{color:#b8b0a0;margin-bottom:8px}
#shelfBuy .price{font-size:20px;color:#3dff8b;margin-bottom:10px}
#shelfBuy button{width:100%;padding:9px;background:#1b2436;border:1px solid #3d5273;color:#cfe0ff;font:700 13px "Courier New",monospace;border-radius:6px;cursor:pointer}
#shelfBuy button:disabled{opacity:.45;cursor:not-allowed}
#shelfBuy .x{position:absolute;top:8px;right:11px;color:#8a8272;cursor:pointer}`;
document.head.appendChild(shelfCss); document.body.appendChild(shelfPanel);
let shelfOffer = null; // { mesh, item } currently on the card
// Collect buy meshes in the live room, raycast from crosshair, return the nearest unsold item.
function shelfUnderAim(maxDist = 3.0) {
if (!current) return null;
const meshes = []; current.group.traverse(o => { if (o.userData && o.userData.buyMesh && o.userData.buyItems.length) meshes.push(o); });
if (!meshes.length) return null;
_buyRay.setFromCamera({ x: 0, y: 0 }, camera);
const hit = _buyRay.intersectObjects(meshes, false)[0];
if (!hit || hit.distance > maxDist) return null;
const mesh = hit.object;
_lp.copy(hit.point); mesh.worldToLocal(_lp); // hit → mesh-local (== item centre space)
let best = null, bd = Infinity;
for (const it of mesh.userData.buyItems) { const d = (it.center.x - _lp.x) ** 2 + (it.center.y - _lp.y) ** 2 + (it.center.z - _lp.z) ** 2; if (d < bd) { bd = d; best = it; } }
return best ? { mesh, item: best } : null;
}
const priceOf = (it) => it.price || (it.price_band === 'grail' ? 60 : it.price_band === 'collector' ? 30 : 8);
function showShelfCard(offer) {
shelfOffer = offer; const it = offer.item.item;
const price = priceOf(it), afford = wallet.canBuy(price);
shelfPanel.style.display = 'block';
shelfPanel.innerHTML =
`<span class="x">×</span><h3>${it.title || 'Untitled'}</h3>` +
`<div class="sub">${it.artist ? it.artist + ' · ' : ''}${(it.price_band || 'stock')}</div>` +
`<div class="price">$${price}</div>` +
`<button ${afford ? '' : 'disabled'}>${afford ? 'BUY IT' : 'NOT ENOUGH CASH'}</button>`;
shelfPanel.querySelector('.x').onclick = closeShelfCard;
shelfPanel.querySelector('button').onclick = () => buyShelfOffer();
}
function closeShelfCard() { shelfPanel.style.display = 'none'; shelfOffer = null; }
// Buy the carded item: debit wallet, collapse its quad (removed from the shelf), drop it from the pool.
function buyShelfOffer() {
if (!shelfOffer) return false;
const { mesh, item } = shelfOffer, it = item.item, price = priceOf(it);
if (!wallet.buy({ t: it.title, a: it.artist, price, s: it.price_band })) return false;
collapseBuyItem(mesh, item);
const arr = mesh.userData.buyItems; const i = arr.indexOf(item); if (i >= 0) arr.splice(i, 1);
closeShelfCard();
return true;
}
// E interacts: a record bin opens the dig; a real shelf raises the buy card. Works with the dig's E too.
if (STOCK_REAL || DIG_ON) {
addEventListener('keydown', e => {
if (e.code !== 'KeyE' || e.target.matches('input,select')) return;
if (dig && dig.active) return;
if (DIG_ON) { const b = binUnderAim(); if (b) { openDigOn(b); return; } } // bins → dig first
const off = shelfUnderAim(); if (off) showShelfCard(off); // shelves → buy card
});
document.getElementById('hint').innerHTML += ' · <b>E</b> bin/shelf · <b>I</b> bag';
}
// Buy-anywhere soak: for each shelf shop type, enter, buy one shelf item, assert wallet+collapse+leak-free.
async function shelfBuySoak() {
const richWallet = createWallet(7);
const realBuy = (o) => richWallet.buy(o);
// warmup: build+render+dispose one book & toy room so the SHARED pack atlases are resident (and thus
// counted in the baseline) — else they'd read as a false texture "leak" on first render (like soak()).
for (const t of ['book', 'toy']) { const w = buildInterior({ id: 'warm', type: t, seed: 5 }, THREE, { stock: 'real' }); scene.add(w.group); renderer.render(scene, camera); scene.remove(w.group); w.dispose(); }
await new Promise(r => setTimeout(r, 0));
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
const out = { types: {}, bought: 0, broke: 0, noMesh: 0, collapseOk: true };
for (const type of ['book', 'toy']) {
for (const s of [1990, 7, 42]) {
const room = buildInterior({ id: type, type, seed: s }, THREE, { stock: 'real' });
scene.add(room.group); renderer.render(scene, camera);
// find a buy mesh + an item; buy it directly (headless — no raycast needed)
let mesh = null; room.group.traverse(o => { if (!mesh && o.userData && o.userData.buyMesh && o.userData.buyItems.length) mesh = o; });
if (!mesh) { out.noMesh++; out.types[type + '/' + s] = 'NO_BUY_MESH'; scene.remove(room.group); room.dispose(); continue; }
const before = mesh.userData.buyItems.length; const item = mesh.userData.buyItems[0]; const it = item.item;
const cashBefore = richWallet.cash(); const price = Math.min(priceOf(it), cashBefore); // never starve the soak
const posBefore = mesh.geometry.attributes.position.getX(item.vStart);
if (realBuy({ t: it.title, a: it.artist, price, s: it.price_band })) {
collapseBuyItem(mesh, item); mesh.userData.buyItems.splice(0, 1);
const pa = mesh.geometry.attributes.position; // all 4 verts must collapse to the centre (zero area)
for (let k = 0; k < 4; k++) if (Math.abs(pa.getX(item.vStart + k) - item.center.x) > 1e-4 || Math.abs(pa.getY(item.vStart + k) - item.center.y) > 1e-4) out.collapseOk = false;
out.bought++;
out.types[type + '/' + s] = { items: before, afterBuy: mesh.userData.buyItems.length, cashΔ: cashBefore - richWallet.cash() };
} else { out.broke++; out.types[type + '/' + s] = 'BROKE'; }
scene.remove(room.group); room.dispose();
}
}
renderer.render(scene, camera);
out.leakGeo = renderer.info.memory.geometries - bG; out.leakTex = renderer.info.memory.textures - bT;
return out;
}
// ── sell counter (R30, v7.0-alpha ledger #2) — walk to the counter holding items, press E → sell card ──
// GATE = the game layer's presence (window.PROCITY.game), exactly the in-game law: ?classic=1/?game=0
// build no game object, so no sell card, zero errors. F's save core (R30 ledger #1) hadn't landed when
// this was wired, so ?sellstub=1 STUBS the published contract shape ({ day, cash, collection[] } — the
// slice C reads) purely for this harness. When F's window.PROCITY.game is real, the stub retires; the
// sell path below is what F wires verbatim (LANE_C_PUB §9.3/§9.4).
const SELL_STUB = new URLSearchParams(location.search).has('sellstub');
if (SELL_STUB) {
window.PROCITY = window.PROCITY || {};
// STUB — R30 ledger #1's published shape, + the §9.2 asks (type, title, artist) the card needs.
window.PROCITY.game = window.PROCITY.game || { day: 1, cash: 0, collection: [
{ townKey: 'test', shopId: 'stub1', slotId: 'stub_0001', type: 'record', title: 'Servo at Midnight', artist: 'THE FIBROS', pricePaid: 24, dayFound: 1 },
{ townKey: 'test', shopId: 'stub2', slotId: 'stub_0002', type: 'record', title: 'Arvo', artist: 'GALAH', pricePaid: 1, dayFound: 1 }, // $1 → offer $0 → SELL disabled (§9.1)
{ townKey: 'test', shopId: 'stub3', slotId: 'stub_0003', type: 'book', title: 'The Long Paddock', artist: '', pricePaid: 15, dayFound: 1 },
{ townKey: 'test', shopId: 'stub4', slotId: 'sku_untyped', pricePaid: 30, dayFound: 1 }, // NO type → never sellable (§9.2 fail-closed)
] };
}
const sell = createSell();
function trySell() {
const game = window.PROCITY && window.PROCITY.game; // absent ⇒ no game layer ⇒ no card
if (!game || !Array.isArray(game.collection) || !current) return false;
if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench
const opened = sell.open({
shopType: current.dims.type, shopName: current.recipe.label,
items: game.collection, // READ-ONLY — sell.js cards a filtered copy
getCash: wallet.cash,
emitters: current.audio && current.audio.emitters,
// The consumer's two halves (§9.3): credit the wallet + remove from the collection. In-game the
// removal is F's, via the game API; HERE the collection is this harness's own stub, so the harness
// splices its own array. C's modules never touch it.
onSell: (item, offer) => {
wallet.sell(item, offer);
const i = game.collection.indexOf(item); if (i >= 0) game.collection.splice(i, 1);
return true;
},
});
if (opened && controls.isLocked) controls.unlock();
return opened;
}
addEventListener('keydown', e => {
if (e.code !== 'KeyE' || e.target.matches('input,select')) return;
if ((dig && dig.active) || sell.active) return;
if (DIG_ON && binUnderAim()) return; // bins → dig (that listener handles it)
if (shelfPanel.style.display === 'block') return; // shelf card already up
trySell(); // counter → sell card
});
if (SELL_STUB) document.getElementById('hint').innerHTML += ' · <b>E</b> at the counter: sell';
// ── loop ───────────────────────────────────────────────────────────────────────
let last = performance.now();
function frame() {
const now = performance.now(), dt = Math.min(0.05, (now - last) / 1000); last = now;
if (dig && dig.active) { dig.update(dt); renderer.render(dig.scene, dig.camera); }
else { move(dt); renderer.render(scene, camera); }
requestAnimationFrame(frame);
}
// expose for headless verification (screenshot harness, workflow checks)
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter,
shelfUnderAim, showShelfCard, buyShelfOffer, shelfBuySoak,
SELL_STUB, sell, trySell, sellOffer, sellableIn, nearCounter, SELL_DIST };
rebuild();
frame();
</script>
</body>
</html>