PROCITY/web/interior_test.html
m3ultra 3724a65505 Lane C R7 C1: ?stock=real — real GODVERSE sleeves in the crates
The stockAdapter seam built in v1 finally eats. Lane E's stock pack (parody-
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig
riffle behind ?stock=real. Economy stays a stub (buying = toast).

- Contract handshake (task 1, unblocked E): confirmed E's index schema as-is
  in LANE_C_NOTES (items[{id,title,artist,price,price_band,atlas,uv}]; uv
  origin top-left, C flips V for WebGL; fail-soft; per-type packs).
- stockpack.js (new): preloadStockPack/getStockPack (cached) + makeStockAdapter.
  Atlas texture + material are shared/cached (like depot GLBs); each sleeve is
  a cover PLANE sampling the atlas at its baked UV, so all real sleeves in a
  room batch to ONE draw (record: 59 draws).
- stock.js binFan: real cover planes (else parody canvas). dig.js: riffles real
  covers, pull card shows title/artist/$price/band. interiors.js: opts.stock=
  'real' wires the adapter (warn-once + parody fallback if pack absent). Test
  page reads ?stock=real, preloads record/book/toy, passes the adapter to the dig.

Validated (E's staged 24-sample; real covers drop in unchanged when E gets the
DB): determinism 0/30, leak-free rooms + real dig (0/0 over 25 cycles), draws
<=139 all rooms (record 59), fail-soft on no-pack types, ?dig=1&stock=real
leak-free. Flag-off + ?noassets untouched (procedural path byte-identical).
qa.sh --strict GREEN. F smoke hook + E contract in LANE_C_NOTES. Shot
docs/shots/laneC/stock_real_r7.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:23:23 +10:00

385 lines
20 KiB
HTML
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.

<!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';
// ?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);
// ── 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)}` : '') +
(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);
}
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)
dig.open({ seed: binSeed(curSeed, key), count: 16, shopName: current.recipe.label, shop: { type: current.dims.type },
stockAdapter: realAdapterFor(current.dims.type), getCash: () => 50, onBuy: () => true, 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';
}
// enter+leave a dig per record room, assert leak-free (round-5 acceptance)
async function digSoak(N = 15) {
if (!dig) dig = createDig(THREE, renderer);
dig.open({ seed: 1, count: 16 }); renderer.render(dig.scene, dig.camera); dig.close(); renderer.render(scene, camera);
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
for (let i = 0; i < N; i++) { dig.open({ seed: (i * 7919 + 1) >>> 0, count: 10 + (i % 9) }); renderer.render(dig.scene, dig.camera); dig.close(); }
renderer.render(scene, camera);
return { rooms: N, 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, digSoak, openDigOn, binUnderAim, get dig() { return dig; } };
rebuild();
frame();
</script>
</body>
</html>