// 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 } 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) import { createSell, nearCounter } from '../interiors/sell.js'; // Lane C — R30 sell counter (F owns this hook, §9.4) 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-` (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; } // [Lane F R27 §2 — THE TIER-2 READER] Live stock, per C's §8 ruling: direct wire, no lifecycle hook. // F already holds `currentAdapter` and hands it to `dig.open()` per dig, so a fresh read is picked up by // the NEXT dig for free. The read is fire-and-forget: if it hasn't landed, hasn't finished, or failed, // the adapter is simply the tier-1 pack. THE DIG NEVER BLOCKS ON THE NETWORK (charter risk #1). // // What it may touch is deliberately tiny, and C's §8.3 is the reason: `items[]` identity and ORDER are // the pick's stability. So we set `pack.gone` (C picks from the full list and omits after — filtering the // array instead reshuffles the crate: C measured ONE sale changing 21 other records) and we update // `price` IN PLACE. We never add, remove, or reorder items[]. Live values are consumed at pick time and // never persisted — no golden, no seeded stream (charter law #2, the determinism boundary sits here). const LIVE_TIMEOUT_MS = 1200; // beyond this the shop is tier 1 for this dig; the player never waits const _liveCache = new Map(); // godverseShopId → { etag, gone:Set, prices:Map } (etag-cached per §4) let _liveDown = false; // circuit breaker: one failure and we stop asking until reload async function readLiveStock(base, shop, pack) { if (_liveDown || !base || !shop.godverseShopId) return null; const id = shop.godverseShopId; const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), LIVE_TIMEOUT_MS); try { const prev = _liveCache.get(id); const r = await fetch(`${base}/godverse/v1/shop/${id}/stock`, { signal: ctl.signal, headers: prev ? { 'If-None-Match': prev.etag } : {} }); if (r.status === 304 && prev) { applyLive(pack, prev); return prev; } if (!r.ok) return null; // 404 = not a real shop. Tier 1. Silently. const d = await r.json(); const live = { etag: d.etag, gone: new Set(d.gone || []), prices: new Map((d.items || []).map((i) => [i.id, i.price])) }; _liveCache.set(id, live); applyLive(pack, live); return live; } catch (e) { // Dead / unreachable / slow / aborted → tier 1 EXACTLY, and we stop asking. The offline law is not // a fallback path here, it is the same path: the pack was already complete before we asked. _liveDown = true; return null; } finally { clearTimeout(timer); } } function applyLive(pack, live) { if (!pack) return; pack.gone = live.gone; // C omits these AFTER the draw (§8.2) for (const it of (pack.items || [])) { // live price, IN PLACE (§8.3) — no reshuffle const p = live.prices.get(it.id); if (p != null) it.price = p; } } export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false, dig: digEnabled = false, stockReal = false, wallet = null, occupancyOf = null, gigState = null, rosterOf = null, liveBase = null, sourcingOf = null, game = 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 currentStockDay = null; // [R30 §30.3] the day salt for THIS room's stock streams (null = no rotation: // no game, day 1, or a REAL-sourced crate — real stock never rotates) 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) let sellCard = null; // [R30 §9.4] Lane C sell card, created lazily on first counter-E (only when the game is on) 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. // [R30 §9.4 amendment] strictAim skips the proximity fallback. The fallback ignores aim entirely // (nearest bin within 2.5 m of the CAMERA), so at a counter that sits near a bin it would swallow // the E and the sell card could never open there. The routing below therefore asks twice: first // AIMED-only (the contract's "aimed bin → dig"), then — only after the sell trigger has declined — // the forgiving nearest-bin pick, so imperfect aim at a bin still opens the riffle away from a sale. function binUnderAim(strictAim = false) { 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; } if (strictAim) return null; // 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) // [R30 §30.3] the riffle rotates with the day: salt the bin key when this room's stock rotates // (currentStockDay is null for no-game / day-1 / REAL-sourced crates — those riffle exactly as pre-v7). const dayTag = currentStockDay != null ? '@d' + currentStockDay : ''; dig.open({ seed: binSeed((currentShop && currentShop.seed) || 1, key + dayTag), 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). // [R30 — THE COLLECTION] a successful buy also records the find into game.collection (§30.1 entry // shape): a dig pull is a 'record' wherever it happens (an opshop bin is still a record crate); // sku = the pack item id when real/mint, else slotId = the bin's day-salted key + offer index. getCash: wallet ? () => wallet.cash() : undefined, onBuy: wallet ? (o) => { const ok = wallet.buy(o); if (ok && game) game.recordFind(currentShop, { type: 'record', sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined, slotId: o && o.item && o.item.id != null ? undefined : `${key}${dayTag}#${(o && o.i) ?? 0}`, title: o && (o.t || o.title), artist: o && (o.a || o.artist), price: (o && o.price) || 0, }); return ok; } : 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 })) { // [R30 — THE COLLECTION] record the find (§30.1): a shelf buy's type is the pack's slot type, // which is the shop type by construction (SLOT_FOR: book→spine, toy→box — the only shelf packs). if (game) game.recordFind(currentShop, { type: currentShop && currentShop.type, sku: item.id != null ? String(item.id) : undefined, slotId: item.id != null ? undefined : `shelf#${(best.vStart | 0)}`, 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'); } } // [Lane F R30 — THE SELL ROUTING (C's §9.4, F's held join)] E at the keeper's counter holding items // this shop trades in → C's sell card. F does both halves of the §9.3 seam, in this order: removal // via THE GAME API first (identity — removeFind false ⇒ keeper veto, nothing moves, and no credit // can ever be minted for an item that is not in the collection), then the credit through the SAME // wallet facade every buy seam uses. C's card never touches game state; this closure is the only // place a sale moves money or the collection. No game layer ⇒ trySell is a no-op (classic-pure, C §9.5). function trySell() { if (!game || !wallet || !current || !currentShop) return false; if (!nearCounter(current, camera.position)) return false; // §9.4: within SELL_DIST of the bench if (!sellCard) sellCard = createSell(); if (sellCard.active) return true; const opened = sellCard.open({ shopType: currentShop.type, shopName: (current.recipe && current.recipe.label) || currentShop.name, items: game.collection, // READ-ONLY — sell.js cards a filtered copy (§9.3) getCash: () => wallet.cash(), emitters: current.audio && current.audio.emitters, onSell: (item, offer) => { if (!game.removeFind(item)) return false; // not in the collection ⇒ veto: card unchanged, $0 moves wallet.sell(item, offer); // credit rides the proven seam (mirrors wallet.js sell) return true; }, onClose: () => window.dispatchEvent(new CustomEvent('procity:sellClose')), }); if (opened) window.dispatchEvent(new CustomEvent('procity:sellOpen')); // shell releases pointer-lock (dig precedent) return opened; } if (digEnabled || wallet) { addEventListener('keydown', (e) => { if (e.code !== 'KeyE' || !current || (dig && dig.active) || (sellCard && sellCard.active)) return; if (digEnabled) { const bin = binUnderAim(true); if (bin) { openDig(bin); return; } } // AIMED bin → riffle (§9.4 first branch) if (trySell()) return; // [R30 §9.4] near the counter with ≥1 sellable → the sell card if (digEnabled) { const bin = binUnderAim(); if (bin) { openDig(bin); return; } } // forgiving nearest-bin (away from a sale) buyShelfUnderAim(); // else 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. // [R26] NO probe here. R24 preloaded this shop's pack on entry — which meant walking into a keyed // shop whose type has no atlas fired a fetch that could only 404. The shell now preloads every // (shop, type) the manifest actually lists, at boot, so by the time a door opens the pack is either // resolved or was never going to exist; asking again at the door can only ask for the absent one. // F's own gate caught this leftover the moment the 404 tolerance came out — which is the argument // for taking tolerances out. Lookup only: a shop with no pack gets null → parody, silently. const gBase = stockReal ? godverseBaseFor(shop) : undefined; currentAdapter = stockReal ? makeStockAdapter(getStockPack(shop.type, gBase)) : null; // [R27 §2] Tier 2, fire-and-forget. ONLY for a real-sourced shop: G's fence 404s a mint shop, and // asking anyway would be R26's blind probe wearing a server. The manifest already told us which is // which, so we ask only where an answer exists. No await — the room is already built and complete. if (stockReal && gBase && liveBase && sourcingOf && sourcingOf(shop.godverseShopId) === 'real') { const pack = getStockPack(shop.type, gBase); if (pack) readLiveStock(liveBase, shop, pack); } // [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; // [R30 §30.3 — SLEEP=TOMORROW] the day salt for this room's stock pick streams. RUNTIME ONLY (the // plan never sees day — A verifies the boundary). Null (= no rotation, byte-identical to pre-v7) // when: no game layer · day 1 (the day-1 convention) · a REAL-sourced crate (the godverse manifest // says sourcing:'real' — real stock never rotates, it's real). Mint crates and parody stock rotate. const realSourced = !!(shop.godverseShopId && sourcingOf && sourcingOf(shop.godverseShopId) === 'real'); currentStockDay = (game && game.day > 1 && !realSourced) ? game.day : null; current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter, stockDay: currentStockDay, 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- — 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; }); // [R30 §9.4] the sell hint shows only when the player actually HOLDS something this shop trades in // (type = type, C's fail-closed matcher) — a hint over nothing sellable would advertise a dead E. const canSell = !!(game && game.collection && game.collection.some((it) => it && it.type === shop.type)); 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' : '') + (canSell ? ' · E at the counter: sell' : ''); 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) if (sellCard && sellCard.active) sellCard.close(); // [R30] the sell card never outlives the room 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; currentStockDay = 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 get sellActive() { return !!(sellCard && sellCard.active); }, // [R30 §9.4] same contract for the sell card // [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], }; }, }; }