You can boot redhill_godverse, walk down Musgrave Road into Monster Robot Party, riffle its own real crate and buy a real record. Proven falsifiably, not by eyeball: base assets/stock_godverse/3962749/ (its own, not the town-wide) TITLES 120/120 the shop's atlas · 0/120 the generic pack TEXTURES 2 from stock_godverse/3962749/*.webp · 0 generic — the pixels' own file BUY rec_0000 "Abstract Latin Lounge II (Part II)" @ $15 → cash 191→176, exact REBOOT 191/0 restored — gone-this-session, back next boot Ladder: ?noassets → ZERO network requests · keyed-but-atlas-less shop → 404 → parody, live · newtown_real → base null, 350 items: the wire is a NO-OP off the godverse path. flags_check GREEN (0 fails, 0 warns). #6a THE WIRE: godverseBaseFor(shop), exported and used by BOTH the shell's boot preload and the room's lookup — C's cache identity is the LITERAL (type, base) pair, so two sides building that string differently would drop a real shop to parody, a bug indistinguishable from "no stock" by eye. One function, one string, one key. Gated on stockReal, never the id alone. #6b THE GATE: smoke_real_crate + DBG.stockInfo() — permanent, and it cannot pass without touching its subject (SKIPs with a printed reason when the town/atlas is absent). FINDING — THE BRIEF'S OWN HONEST-GATE SPEC WAS VACUOUS. It specified id equality. Both packs number items POSITIONALLY (rec_0000…), so all 120 atlas ids are generic-pack ids too: an id-equality gate goes GREEN on the generic pack — the exact failure the vacuous-gate law was written to stop, reproduced inside its first application. Ids there are slot numbers, not identity. Titles (0 overlap) and the texture URL discriminate; F asserts on those. FINDING — THE ID SPACES COLLIDE, and it bit F's own selector inside the anti-vacuous gate. Plan ids (1..N) and godverse ids (31, 767, 3962749) share a numeric space: Silky Oak's godverse id is 31 and another shop's PLAN id is 31, so enterShop(31) silently entered "Wholefood Cafe" and the soft-fall assertion PASSED ON AN UNKEYED CAFE. Fixed both ends: 'g:<n>' selects a godverse id explicitly, a bare number stays the plan id and now REPORTS the collision, and the smoke asserts it landed in the shop it asked for before asserting anything about it. FINDING — F'S OWN GATE CRIED THE LOUDEST WOLF WE OWN. classic_regression reported "plan fingerprint != golden 0x3fa36874 — determinism/prime-law broken" while the covenant was provably intact. F wrote it in R16 as `hash_ok and returncode == 0` — two assertions, ONE verdict, reported with the fingerprint's message — so any unrelated selfcheck red screamed that the byte-identical-forever covenant had broken. Two assertions, two verdicts now. It isn't enough to fail for a reason; you have to fail for YOUR reason. FINDING — fail-soft-by-404 vs the 0-console-errors law. The boot preload must resolve a keyed shop's pack before the door opens (getStockPack is sync), so it probes blind: 9 of Red Hill's 10 keyed shops have no atlas → 404 → parody. That IS fail-soft working, and it is also 8 console errors no .catch() can suppress. Fix is not to loosen the law but to stop probing for what isn't there: an atlas index in G's namespace (or hasStock on the cache entry, A+G). Filed as a contract ask, not invented. Until then the smoke counts and NAMES the probes and fails on any other error — tolerance stated out loud. Verified the lift is clean: cache 10 keyed → plan 9 keyed, every one a real secondhand shop. No cafe keyed, zero duplicate ids — there is no mis-stocking. F did NOT touch A's identity sweep or E's validator. F was the party that would benefit, and changing a red gate to green in another lane's file to unblock F's own tag would retroactively devalue every green F has ever reported. Filed instead — and R25 has since landed A's reconciliation and the six pins, which is the right hands doing it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
24 KiB
JavaScript
404 lines
24 KiB
JavaScript
// PROCITY Lane F — interior_mode.js [integration glue, F-owned]
|
||
// Bridges Lane B's street shell to Lane C's buildInterior library — this is the wiring behind the
|
||
// headline "every door opens". Ownership stays clean: Lane B owns the shell + mode machine, Lane C
|
||
// owns the room, Lane F owns THIS bridge (a separate module so the shell edits are a thin, marked
|
||
// seam rather than logic sprinkled through B's files).
|
||
//
|
||
// Contract (verified against the real landed code, 2026-07-14):
|
||
// • buildInterior(shop, THREE) → { group(self-lit), spawn:{x,z,ry}, exits:[{x,z,w,toStreet}],
|
||
// _debug.grid:{W,D,cols,rows,cw,cd,occ,cellOf}, dispose() }
|
||
// • Room origin = centre, floor y=0. spawn is ~1.5m inside the front door facing −Z into the shop;
|
||
// the (only) street exit sits at the front wall, same x as spawn.
|
||
// • occ cells: 0 free · 1 fitting · 2 wall(border). We collide off occ==1 + room bounds, exactly
|
||
// like Lane C's interior_test.html walk loop, so behaviour matches the tester the room was tuned in.
|
||
//
|
||
// The interior renders into its OWN dark THREE.Scene (the street scene is frozen, not disposed —
|
||
// CITY_SPEC L3 "pause, not dispose"), so returning to the street is instant.
|
||
|
||
import { buildInterior, getStockPack, makeStockAdapter, preloadStockPack } from '../interiors/interiors.js';
|
||
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
|
||
import { GigCrew } from '../citizens/band.js'; // Lane D — band trio + audience (R12, ?gigs=1)
|
||
import { createDig, binSeed } from '../interiors/dig.js'; // Lane C — crate-riffle (v2, gated on ?dig=1)
|
||
import { collapseBuyItem } from '../interiors/stockpack.js'; // Lane C — R9 buy-anywhere (book spines / toy boxes)
|
||
|
||
const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
|
||
const RADIUS = 0.30; // player collision radius inside the room
|
||
const SPEED = 3.2; // interior walk speed (matches the Lane C tester)
|
||
const ARM_DIST = 2.0; // must step this far from the door before the exit arms (spawn is ~1.5m away)
|
||
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
|
||
|
||
// [Lane F R13 — debt #1 CLOSED] The gigKey contract is now canonical: a genre's live-bed manifest key IS
|
||
// `gig-<genreKey>` (Lane A exports gigKeyFor(); Lane C emits room.audio.gigKey; Lane E named the manifest
|
||
// beds gig-pubrock/gig-grunge/gig-covers to match). So the room's gigKey feeds the audio engine DIRECTLY,
|
||
// with NO mapping. The R12 `GIG_BED` bridge that rewrote gig-pubrock→pubrock-live is DELETED — and not a
|
||
// moment too soon: E renamed pubrock-live→gig-pubrock this round, so the old bridge had become a LIVE BUG
|
||
// (it pointed at a manifest key that no longer exists → the band would mime in a silent room). Contract line
|
||
// lives in CITY_SPEC §v3. See LANE_F_NOTES §12.
|
||
const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur layered under the gig bed while 'on'
|
||
|
||
// [Lane F R24 §6a] The per-shop stock base for a GODVERSE shop — A's `godverseShopId` → C's per-shop
|
||
// pack location (LANE_C_PUB §7.3/§7.5). Exported and used by BOTH the shell's boot preload and the room's
|
||
// lookup, deliberately: C's cache identity is the LITERAL (type, base) pair, so if the two sides built
|
||
// this string even slightly differently the lookup would miss and a real shop would fall silently to
|
||
// parody — a bug that looks exactly like "no stock" and would sail past a covers-render eyeball.
|
||
// One function, one string, one cache key. Shops without the field return undefined → town-wide packs.
|
||
export function godverseBaseFor(shop) {
|
||
const id = shop && shop.godverseShopId;
|
||
return id ? `assets/stock_godverse/${id}/` : undefined;
|
||
}
|
||
|
||
export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false,
|
||
dig: digEnabled = false, stockReal = false, wallet = null,
|
||
occupancyOf = null, gigState = null, rosterOf = null }) {
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color('#0e0b07');
|
||
|
||
// Lane D shopkeeper — one per interior, at the counter. With a rig `fleet` (§3.4) the keeper is a
|
||
// shared GLB actor; without one it's an asset-free placeholder. Either way it greets the player.
|
||
const keepers = new KeeperManager({ camera, citySeed: plan.citySeed, fleet });
|
||
// [Lane F R12] Lane D's band trio + audience. Built lazily on the first gig (a town has ONE venue and
|
||
// most rooms never see one), disposed with the room like the keepers.
|
||
let crew = null;
|
||
let crewInfo = null; // { band, crowd } — what actually spawned (F's smokes read this)
|
||
let applauded = false; // [R13] one-shot latch: the on→done applause sting has fired for this visit
|
||
|
||
let current = null; // active Lane C interior handle
|
||
let currentShop = null; // the shop record for the active interior (dig per-bin seeding)
|
||
let currentAdapter = null; // [F2 §stock] ?stock=real stockAdapter for this shop (null → parody fallback)
|
||
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit
|
||
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
|
||
let dig = null; // [F2] Lane C crate-riffle instance, created lazily on first use (only if ?dig=1)
|
||
|
||
const _fwd = new THREE.Vector3();
|
||
const _right = new THREE.Vector3();
|
||
const _up = new THREE.Vector3(0, 1, 0);
|
||
const _digRay = new THREE.Raycaster();
|
||
|
||
// [Lane F §F2] crate-riffle wiring (v2, ?dig=1). Lane C ships dig.js + gates the flag; F owns the
|
||
// in-game *input/mode* hook (its LANE_C_NOTES contract) — the same consumer pattern as Lane C's
|
||
// interior_test.html: aim at a record bin, press E → open the riffle, which owns the frame + cursor
|
||
// (pointer must be UNLOCKED — the shell handles that on procity:digOpen). Flag off ⇒ none of this runs.
|
||
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); // crosshair-centre ray
|
||
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;
|
||
}
|
||
// Fallback (matches Lane C's interior_test.html): nearest bin within reach. Uses the bin GROUP's
|
||
// world position, so it works even before the bin's GLB mesh has finished loading (async) and is
|
||
// forgiving of imperfect aim — stand next to a bin, press E.
|
||
const cp = camera.position, wp = new THREE.Vector3();
|
||
let best = null, bd = 2.5;
|
||
for (const b of bins) { b.getWorldPosition(wp); const d = wp.distanceTo(cp); if (d < bd) { bd = d; best = b; } }
|
||
return best;
|
||
}
|
||
|
||
function openDig(bin) {
|
||
if (!dig) dig = createDig(THREE, renderer);
|
||
if (dig.active) return;
|
||
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 pos)
|
||
dig.open({
|
||
seed: binSeed((currentShop && currentShop.seed) || 1, key), count: 16,
|
||
shopName: (current && current.recipe && current.recipe.label) || (currentShop && currentShop.name),
|
||
shop: currentShop,
|
||
stockAdapter: currentAdapter, // [F2 §stock=real] real covers in the riffle (null → parody)
|
||
// [F R8 — Lane C buy loop v0] bind the session wallet so pulling a sleeve deducts cash + banks it.
|
||
// dig removes the pulled sleeve from the bin; onBuy returns false when broke (no state change).
|
||
getCash: wallet ? () => wallet.cash() : undefined,
|
||
onBuy: wallet ? (item) => wallet.buy(item) : undefined,
|
||
onClose: () => window.dispatchEvent(new CustomEvent('procity:digClose')),
|
||
});
|
||
window.dispatchEvent(new CustomEvent('procity:digOpen')); // shell releases pointer-lock for the cursor
|
||
}
|
||
|
||
// [F R9 — Lane C buy-anywhere] E aimed at a book spine / toy box shelf (userData.buyMesh) buys the
|
||
// nearest item over the same wallet: collapse the quad in place + bank it. Parody stock has no buyMesh
|
||
// → no-op ("not for sale"). Records keep the dig. Reuses collapseBuyItem (Lane C stockpack export).
|
||
const _buyRay = new THREE.Raycaster(), _lp = new THREE.Vector3();
|
||
function buyShelfUnderAim() {
|
||
if (!wallet || !current) return;
|
||
const meshes = [];
|
||
current.group.traverse((o) => { if (o.userData && o.userData.buyMesh && o.userData.buyItems && o.userData.buyItems.length) meshes.push(o); });
|
||
if (!meshes.length) return;
|
||
_buyRay.setFromCamera({ x: 0, y: 0 }, camera);
|
||
const hit = _buyRay.intersectObjects(meshes, false)[0];
|
||
if (!hit || hit.distance > 3.0) return;
|
||
const mesh = hit.object;
|
||
_lp.copy(hit.point); mesh.worldToLocal(_lp);
|
||
let best = null, bd = Infinity;
|
||
for (const it of mesh.userData.buyItems) {
|
||
if (it.sold) continue;
|
||
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; }
|
||
}
|
||
if (!best) return;
|
||
const item = best.item || {};
|
||
if (wallet.buy({ title: item.title, artist: item.artist, price: item.price || 20 })) {
|
||
collapseBuyItem(mesh, best); // zero-area the bought quad in place
|
||
flashToast(`SOLD — ${item.title || 'item'} · $${item.price || 20} · $${wallet.cash()} left`);
|
||
} else {
|
||
flashToast('not enough cash');
|
||
}
|
||
}
|
||
|
||
if (digEnabled || wallet) {
|
||
addEventListener('keydown', (e) => {
|
||
if (e.code !== 'KeyE' || !current || (dig && dig.active)) return;
|
||
if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // bin → riffle
|
||
buyShelfUnderAim(); // shelf → buy
|
||
});
|
||
}
|
||
|
||
// a small unobtrusive banner (F-owned; the street HUD is hidden in interior mode)
|
||
const banner = document.createElement('div');
|
||
banner.style.cssText =
|
||
'position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:20;display:none;' +
|
||
'background:rgba(20,16,10,.82);color:#f4efe6;padding:8px 16px;border-radius:8px;' +
|
||
'font:14px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;text-shadow:0 1px 2px #000;pointer-events:none';
|
||
document.body.appendChild(banner);
|
||
|
||
// [F R9] transient buy-feedback toast (shelf-buy). Separate from the persistent banner.
|
||
const toast = document.createElement('div');
|
||
toast.style.cssText =
|
||
'position:fixed;left:50%;bottom:60px;transform:translateX(-50%);z-index:21;display:none;' +
|
||
'background:rgba(14,28,18,.92);color:#8fffb0;padding:8px 16px;border-radius:8px;' +
|
||
'font:600 14px -apple-system,Segoe UI,Roboto,sans-serif;text-shadow:0 1px 2px #000;pointer-events:none';
|
||
document.body.appendChild(toast);
|
||
let _toastT = 0;
|
||
function flashToast(msg) { toast.textContent = msg; toast.style.display = 'block'; _toastT = 1.6; }
|
||
|
||
// interior-grid collision — mirrors interior_test.html walkable(): room bounds + occ==1 fittings.
|
||
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; // walls are handled by the bounds clamp
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function nearestExitDist(px, pz) {
|
||
let best = Infinity;
|
||
for (const e of current.exits) {
|
||
if (e.toStreet === false) continue;
|
||
const d = Math.hypot(px - e.x, pz - e.z);
|
||
if (d < best) best = d;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// enter: build the room, show it, drop the player just inside the door. Returns the Lane C handle.
|
||
function enter(shop, name) {
|
||
if (current) exit(); // never stack interiors
|
||
// [F2 §stock=real] real GODVERSE sleeves in this shop's crates. makeStockAdapter(null) → null →
|
||
// Lane C fails soft to the parody canvas, so a missing/unpreloaded pack just shows placeholders.
|
||
// [R24 §6a — the per-shop base] A shop that carries A's `godverseShopId` is a REAL shop with its own
|
||
// real stock, so it resolves its OWN pack under its OWN base (C's §7.5 seam); everything else keeps
|
||
// the town-wide packs, byte-identically. Cache identity is (type, base) since C's R24 fix, so the two
|
||
// coexist instead of the per-shop pack silently inheriting the town-wide one (the R23 bug that held
|
||
// the tag). Missing atlas → 404 → null → parody: the fail-soft law runs through the SAME path.
|
||
// Gated on stockReal, never on the id alone: ?noassets ⇒ stockReal false ⇒ not one byte fetched.
|
||
const gBase = stockReal ? godverseBaseFor(shop) : undefined;
|
||
if (gBase) preloadStockPack(shop.type, { base: gBase }); // idempotent; re-entry is a cache hit
|
||
currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null;
|
||
// [Lane F R13 THE DISTRICT] Is THIS shop the venue with a gig on right now? Per-venue query — a town has
|
||
// 2–4 venues, each with its own latch keyed by shop id. Lane C only stamps room.audio.gigKey when we pass
|
||
// opts.gig, so pass NULL unless it's really on: a quiet-night venue then builds as a normal interior
|
||
// (stage + watch points still there, just nobody on them), the "quiet night" Lane C specced.
|
||
const gigOn = !!(gigState && gigState.onOf(shop.id));
|
||
const tonight = gigOn ? gigState.gigOf(shop.id) : null;
|
||
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter,
|
||
gig: gigOn ? { on: true, genreKey: tonight.genreKey || 'pubrock', gigId: tonight.gigId } : null });
|
||
currentShop = shop;
|
||
scene.add(current.group);
|
||
// [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine).
|
||
// Guarded + no-op when muted / ?noassets / pre-gesture / engine still async-loading — silent-and-happy.
|
||
// [R13] While the band is on, the LIVE BED plays over the room's radio: room.audio.gigKey is ALREADY the
|
||
// manifest key (gig-<genreKey> — debt #1 closed, no bridge), and the crowd WALLA rides the interior tone
|
||
// layer under it (you hear the room, not the fridge hum). Quiet night → the room's own seeded bed.
|
||
const ra = current.audio || {};
|
||
const gigAudio = gigOn && ra.gigKey ? { musicKey: ra.gigKey, toneKey: WALLA_KEY } : ra;
|
||
window.PROCITY.audio && window.PROCITY.audio.playInterior(gigAudio);
|
||
applauded = false; // [R13] re-arm the on→done applause sting for this visit
|
||
// Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable
|
||
const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
|
||
if (counter) {
|
||
const st = counter.userData.keeperStand;
|
||
keepers.spawn(current.group, { x: st.x, z: st.z, ry: st.ry, shopId: shop.id, type: shop.type });
|
||
}
|
||
// [F R9 — interior presence, C→D→F seam] browser rigs at C's browse points, one per patronage
|
||
// occupant, capped at 3 (≤3 points). D owns occupancy truth + the rig (KeeperManager browse pose,
|
||
// ~1 draw, no player-greet); F reads occupancy and places them. Freed with the room by exit()'s
|
||
// keepers.disposeAll(). occupants[i].pedIndex → the exact ped who ducked in off the street.
|
||
if (occupancyOf && current.browsePoints && current.browsePoints.length) {
|
||
const occ = occupancyOf(shop.id) || { count: 0, occupants: [] };
|
||
const pts = current.browsePoints.slice(0, Math.min(occ.count | 0, 3));
|
||
pts.forEach((pt, i) => keepers.spawn(current.group, {
|
||
x: pt.x, z: pt.z, ry: pt.ry, shopId: shop.id, browse: true,
|
||
pedIndex: occ.occupants && occ.occupants[i] ? occ.occupants[i].pedIndex : null,
|
||
seedKey: `${shop.id}#${i}`,
|
||
}));
|
||
}
|
||
// [Lane F R12 — the gig itself] Lane D's GigCrew at Lane C's poses: a 4-piece on the deck, up to 8/12 at
|
||
// the watch points (~⅓ seeded to dance — John's "a bit of both"). D's own spawn caps the crowd at
|
||
// watchPoints.length and reuses the gate-protected spawnRig path, so the R10 no-giants gate covers these
|
||
// rigs too; E's instrument GLBs ride in via D's instrumentFor seam.
|
||
// [Lane F R15 — identity continuity (ledger #3)] pass D's TONIGHT ROSTER so the front crowd slots ARE the
|
||
// punters who came in (surge occupants + queue admits, keyed by venue). D fills the rest with seeded
|
||
// strangers; an EMPTY roster is byte-identical to R13 (D's law), which is what a fresh/quiet venue gets.
|
||
crewInfo = null;
|
||
if (gigOn && current.stage && current.watchPoints && current.watchPoints.length) {
|
||
if (!crew) crew = new GigCrew({ citySeed: plan.citySeed, fleet });
|
||
crewInfo = crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints,
|
||
gig: tonight, roster: rosterOf ? rosterOf(shop.id) : [] });
|
||
}
|
||
doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y };
|
||
camera.position.set(current.spawn.x, EYE, current.spawn.z);
|
||
camera.rotation.set(0, current.spawn.ry, 0, 'YXZ'); // PointerLockControls re-reads this on next mouse move
|
||
exitArmed = false;
|
||
let hasBins = false;
|
||
if (digEnabled) current.group.traverse((o) => { if (o.userData && o.userData.kind === 'bin') hasBins = true; });
|
||
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`
|
||
+ (gigOn ? ` · 🎸 ${String(tonight.bandName).toUpperCase()} — LIVE` : '')
|
||
+ (hasBins ? ' · E: riffle a record bin' : '');
|
||
banner.style.display = 'block';
|
||
return current;
|
||
}
|
||
|
||
// update: walk + render the interior. Returns true once the player reaches a street exit
|
||
// (the shell then calls exit() + setMode('street') — update never disposes on its own).
|
||
function update(dt, keys) {
|
||
if (!current) return false;
|
||
if (_toastT > 0) { _toastT -= dt; if (_toastT <= 0) toast.style.display = 'none'; } // [R9] buy toast decay
|
||
// [F2] riffling: the dig owns the whole frame (its own scene/camera + cursor input). No interior
|
||
// walk, no exit check — the player is "in the crate" until they close it (Esc / WALK OUT button).
|
||
if (dig && dig.active) {
|
||
if (dig.update) dig.update(dt);
|
||
renderer.autoClear = true;
|
||
renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame
|
||
renderer.render(dig.scene, dig.camera);
|
||
return false;
|
||
}
|
||
const f = (keys.has('KeyW') || keys.has('ArrowUp') ? 1 : 0) - (keys.has('KeyS') || keys.has('ArrowDown') ? 1 : 0);
|
||
const s = (keys.has('KeyD') || keys.has('ArrowRight') ? 1 : 0) - (keys.has('KeyA') || keys.has('ArrowLeft') ? 1 : 0);
|
||
if (f || s) {
|
||
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/fittings
|
||
if (walkable(p.x, p.z + nz)) p.z += nz;
|
||
p.y = EYE;
|
||
}
|
||
|
||
keepers.update(dt); // Lane D: keeper idle + turn-to-greet (playerPos defaults to the camera)
|
||
if (crew) crew.update(dt); // [R12] Lane D: band strum-sway + crowd stand/dance (seeded phase per member)
|
||
// [Lane F R13] applause sting on on→done: crewInfo is non-null only when a gig crew spawned (gigOn at
|
||
// enter); when the clock then rolls the gig off (NIGHT→DAWN — the band's last song), the crowd claps
|
||
// ONCE. Fail-soft via the house audio law (no-op muted / ?noassets / pre-gesture). applauded guards re-fire.
|
||
if (crewInfo && !applauded && gigState && currentShop && !gigState.onOf(currentShop.id)) {
|
||
applauded = true;
|
||
window.PROCITY.audio && window.PROCITY.audio.playSfx && window.PROCITY.audio.playSfx('applause', { gain: 0.9 });
|
||
}
|
||
renderer.autoClear = true; // the composer may have left autoClear=false; force a clean interior frame
|
||
renderer.info.reset(); // autoReset is off (shell owns it for the composer); reset per interior frame so
|
||
// renderer.info.render.calls reports THIS frame's draws (HUD + the ≤350 room gate)
|
||
renderer.render(scene, camera);
|
||
|
||
const de = nearestExitDist(camera.position.x, camera.position.z);
|
||
if (!exitArmed && de > ARM_DIST) exitArmed = true;
|
||
return exitArmed && de < EXIT_DIST;
|
||
}
|
||
|
||
// exit: dispose the room (frees GPU + removes group), restore the player to the street door.
|
||
function exit() {
|
||
if (!current) return;
|
||
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
|
||
if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU)
|
||
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
|
||
if (crew) { crew.disposeAll(); crewInfo = null; } // [R12] band + crowd out before the room goes
|
||
current.dispose();
|
||
current = null;
|
||
currentShop = null;
|
||
currentAdapter = null;
|
||
banner.style.display = 'none';
|
||
toast.style.display = 'none'; _toastT = 0;
|
||
if (doorReturn) {
|
||
camera.position.set(doorReturn.x, doorReturn.y, doorReturn.z);
|
||
camera.rotation.set(0, doorReturn.ry, 0, 'YXZ');
|
||
}
|
||
}
|
||
|
||
return {
|
||
enter,
|
||
update,
|
||
exit,
|
||
scene,
|
||
keepers,
|
||
get active() { return !!current; },
|
||
get current() { return current; },
|
||
get crew() { return crew; }, // [R12] Lane D GigCrew (null until a gig has been entered)
|
||
get crewInfo() { return crewInfo; }, // [R12] { band, crowd } actually spawned — F's smokes assert on this
|
||
get digActive() { return !!(dig && dig.active); }, // [F2] shell reads this to keep pointer-lock/Esc sane while riffling
|
||
|
||
// [Lane F R24 — the honest-gate surface, per the vacuous-gate law] What stock is in THIS room, BY
|
||
// IDENTITY. R23's #7a could go green on "real covers render" while the room held the generic v2 pack
|
||
// and `stock_godverse/` was never fetched — presence, not identity. So the gate needs falsifiables:
|
||
// which base the room resolved, how many items, and the actual item ids on the rendered buy meshes
|
||
// (C tags those with `buyItems[].item`; a plain sleeve carries only `isStock`, so ids come from the
|
||
// buyables and the pack, never from "it looked right"). Null when no room is open.
|
||
get stockInfo() {
|
||
if (!current || !currentShop) return null;
|
||
const base = stockReal ? godverseBaseFor(currentShop) : undefined;
|
||
const pack = stockReal ? getStockPack(currentShop.type, base) : null;
|
||
const renderedIds = [], renderedTitles = [], texUrls = new Set();
|
||
let stockMeshes = 0;
|
||
scene.traverse((o) => {
|
||
const u = o.userData || {};
|
||
if (!u.isStock && !u.buyMesh) return;
|
||
stockMeshes++;
|
||
if (Array.isArray(u.buyItems)) {
|
||
for (const b of u.buyItems) if (b && b.item) {
|
||
if (b.item.id != null) renderedIds.push(String(b.item.id));
|
||
if (b.item.title) renderedTitles.push(b.item.title);
|
||
}
|
||
}
|
||
// The atlas the GPU is actually sampling. This is the ONLY assertion that survives everything:
|
||
// ids are positional slots (`rec_0000`) that BOTH packs reuse, so id equality cannot tell the
|
||
// real crate from the generic v2 pack — every atlas id is also a town-pack id (measured R24).
|
||
// A texture URL is the file the pixels come from. Falsifiable, and it cannot be faked by a
|
||
// pack that merely looks right.
|
||
for (const m of (Array.isArray(o.material) ? o.material : [o.material])) {
|
||
const src = m && m.map && m.map.image && (m.map.image.src || m.map.image.currentSrc);
|
||
if (src) texUrls.add(String(src));
|
||
}
|
||
});
|
||
return {
|
||
shopId: currentShop.id,
|
||
godverseShopId: currentShop.godverseShopId || null,
|
||
name: currentShop.name,
|
||
type: currentShop.type,
|
||
base: base || null, // null ⇒ the town-wide pack (or no stock at all)
|
||
packItems: pack && pack.items ? pack.items.length : 0,
|
||
packIds: pack && pack.items ? pack.items.map((i) => String(i.id)) : [],
|
||
packTitles: pack && pack.items ? pack.items.map((i) => i.title).filter(Boolean) : [],
|
||
stockMeshes,
|
||
renderedIds,
|
||
renderedTitles,
|
||
texUrls: [...texUrls],
|
||
};
|
||
},
|
||
};
|
||
}
|