layout.js placeBrowsePoints(): 1–3 deterministic floor poses in front of
browsable floor fittings, facing the goods. Guarantees (all asserted):
walkable (occ-free, not reserved), reachable from spawn (door→counter
flood-fill), ≥0.6m apart + clear of keeper stand, every archetype (rich
candidate ring — 4 sides×2 gaps + diagonals — plus a nearest-free-cell
fallback so tight/mismatched rooms still yield ≥1). Pure data: no meshes,
no draws; ≤350 law untouched. Exposed as room.browsePoints[]
{x,z,ry,atKind,slotIndex}, same coord frame + ry convention as
counter.stand (contract in LANE_C_NOTES).
Validated headless: 180 type×archetype×seed combos → 0 empties (177×3,
3×1 via fallback), determinism 0 fails, min-sep 0.7m, 0 keeper clashes.
Test page: cyan browse-post markers on the 'path' overlay + HUD count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
441 lines
24 KiB
HTML
441 lines
24 KiB
HTML
<!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';
|
||
|
||
// ?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 };
|
||
}
|
||
|
||
// ── 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 };
|
||
|
||
rebuild();
|
||
frame();
|
||
</script>
|
||
</body>
|
||
</html>
|