John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
649 lines
40 KiB
HTML
649 lines
40 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, WARDROBE_BASE } 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';
|
||
// [R41 §41.4] the kit catalogue + its own drift check (kit41.kitAudit compares every hardcoded row
|
||
// against the live manifest and asserts the three traps Lane E flagged). Exposed for the harness.
|
||
import { KIT, POOLS, BOOTHS, CONTROL_NODES, kitAudit } from './js/interiors/kit41.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';
|
||
// [R39 §39.3] `?opshopdig=1` arms the op shop's rummage bin as a real `kind:'bin'` (the mechanism is
|
||
// built and measured; WHAT IS IN IT is John's call, so the default stays furniture). Both arms are
|
||
// demonstrable from this page, which is what makes an F gate on either of them non-vacuous.
|
||
const OPSHOP_DIG = (() => { const v = new URLSearchParams(location.search).get('opshopdig'); return v != null && v !== '0'; })();
|
||
if (STOCK_REAL) for (const t of ['record', 'book', 'toy']) preloadStockPack(t); // fail-soft per type
|
||
// [R41 §41.4 — THE WARDROBE] The op-shop pack lives under its OWN base (`assets/wardrobe/`) because it is
|
||
// not a GODVERSE crate: it is 52 named 1990s Australian garment layers off one 240 KB atlas, feeding the
|
||
// clothes rails and the shelves. Cache identity is (type, base) since R24, so it coexists with the
|
||
// town-wide packs. ONE fetch, only under ?stock=real — a default boot and ?noassets fetch nothing.
|
||
// → LANE F: the same one line in index.html turns it on in the game (LANE_C_NOTES §41).
|
||
// [R42 §42.2b] The literal used to be spelled out here as well as in `interior_mode.js` (F flagged it).
|
||
// It now lives once, in `stockpack.js` next to the loader that resolves it, and is imported above.
|
||
if (STOCK_REAL) preloadStockPack('opshop', { base: WARDROBE_BASE });
|
||
const realAdapterFor = (type) => (STOCK_REAL
|
||
? makeStockAdapter(getStockPack(type, type === 'opshop' ? WARDROBE_BASE : undefined))
|
||
: 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,
|
||
stockBase: (STOCK_REAL && type === 'opshop') ? WARDROBE_BASE : undefined, // [R41] the wardrobe pack
|
||
fittingOpts: OPSHOP_DIG ? { rummageBin: { dig: true } } : 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;
|
||
// [R41 §41.4] THE SWEEP WAS MEASURING A ROOM THAT WASN'T THERE. It removed every interior in the scene
|
||
// EXCEPT `current` — the live room the page is showing — so every per-type number carried a constant
|
||
// offset from whatever room the page last built (seed 1990, type = the first entry, a record shop). It
|
||
// looked harmless while nothing changed; the moment §41.4 furnished the record shop, `toy` — a type this
|
||
// round never touches — "gained" 4 draws, in both modes. The sweep now clears the scene of ALL interiors
|
||
// and puts `current` back at the end (rebuild()), so a type's number is that type's room and nothing else.
|
||
// Numbers therefore drop slightly against R39/R40 prints of this same instrument: that is the phantom
|
||
// leaving, not the rooms getting cheaper.
|
||
// [R42 §42.2b — THE 122-vs-123 RECONCILIATION, AND ITS FIX]
|
||
// F's §41.6 close read 123 @ dept/auto where this lane published 122, on the same tree and on both
|
||
// instruments, and the gap was never in the code: `renderer.info.render.calls` counts what SURVIVES
|
||
// FRUSTUM CULLING, and this camera keeps a fixed VERTICAL fov (70) and takes its aspect from the
|
||
// browser window. A wider window therefore sees more of the room and reads MORE draws. Measured, one
|
||
// tree, F's own sweep function, only the viewport changed:
|
||
// 1280×1024 aspect 1.250 → 116 @ dept/auto (GLB off 88 @ stall/hall)
|
||
// 1280× 800 aspect 1.600 → 122 @ dept/auto (GLB off 92 @ opshop/hall) ← C's R41 harness
|
||
// 1440× 900 aspect 1.600 → 122 @ dept/auto
|
||
// 1280× 720 aspect 1.778 → 123 @ dept/auto (GLB off 93 @ opshop/hall) ← F's R41 harness
|
||
// Both numbers were honest readings of an unpinned instrument. The budget must be the WORST a player
|
||
// can produce, and a player's window is wide, so the sweep now pins its own aspect at 16:9 and the
|
||
// answer stops depending on who ran it. `opts.aspect` overrides it (that is the control arm: pass
|
||
// 1.25 and watch the same tree read 116).
|
||
const MEASURE_ASPECT = 16 / 9;
|
||
async function drawSweep(opts = {}) {
|
||
if (current) { current.dispose(); current = null; clearDebug(); }
|
||
for (const s of scene.children.filter(o => o.userData && o.userData.kind === 'interior')) scene.remove(s);
|
||
const winAspect = camera.aspect;
|
||
const aspect = opts.aspect || MEASURE_ASPECT;
|
||
let manifest = null;
|
||
if (opts.glb) { manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null); }
|
||
// [R41] `opts.stock` measures the ?stock=real arm too — real covers/garments come off shared atlases as
|
||
// `buyMesh` meshes that batch.js deliberately leaves alone, so they are the one stock path whose draws
|
||
// scale with fittings rather than with materials. Worth its own number now that a clothes rail carries
|
||
// twelve of them. Needs the packs preloaded (the page does that under ?stock=real).
|
||
const measure = async (type, arch) => {
|
||
const r = buildInterior({ id: type, type, seed: 1990, storeys: 1 }, THREE,
|
||
{ archetype: arch, useGLB: opts.glb, manifest, noKit: opts.noKit,
|
||
stock: opts.stock ? 'real' : undefined,
|
||
stockBase: (opts.stock && type === 'opshop') ? WARDROBE_BASE : undefined });
|
||
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);
|
||
camera.aspect = aspect; camera.updateProjectionMatrix(); // [R42] pinned — see MEASURE_ASPECT
|
||
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;
|
||
}
|
||
camera.aspect = winAspect; camera.updateProjectionMatrix(); // hand the window's own aspect back
|
||
rebuild();
|
||
return { worst, worstAt, pass: worst <= DRAW_LAW, law: DRAW_LAW, perType, aspect };
|
||
}
|
||
|
||
// ── 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,
|
||
KIT, POOLS, BOOTHS, CONTROL_NODES, WARDROBE_BASE,
|
||
kitAudit: (m) => kitAudit(m || glbManifest), // [R41] catalogue-vs-manifest drift + trap asserts
|
||
DIG_ON, STOCK_REAL, OPSHOP_DIG, 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>
|