PROCITY/web/js/world/interior_mode.js
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
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>
2026-08-07 23:49:49 +10:00

634 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, WARDROBE_BASE } from '../interiors/interiors.js'; // [Lane F R42] WARDROBE_BASE is C's constant now — see §42.6 note below
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 { mulberry32, xmur3 } from '../core/prng.js'; // [R33] the rumor roll (seeded, runtime-only)
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-<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'
// [R33 — THE RUMOR] where a keeper's mutter can point: the repo's real cached towns (display names).
// Flavor + a travel nudge, not a promise — the 90s had no stock lookups, and the town-wide packs mean
// every town CAN surface any hunted record eventually. Full persona lines are D's when the clip work lands.
const RUMOR_TOWNS = ['Katoomba', 'Newtown', 'Fremantle', 'Bendigo', 'Castlemaine'];
// [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 R41 §41.6 — THE WARDROBE, turned on] Lane C's §41.4 route-R5 pack: 52 named 1990s Australian
// garment layers in ONE 246 KB atlas, serving the op shop's `garment` (rails) and `box` (shelves/tubs)
// slots at zero geometry cost. It is a stock pack under its OWN base, so it needs the same two-sided
// wiring the godverse packs needed — a boot preload (getStockPack is SYNC and the room is built the
// instant the door opens) and the identical base string at build time. Same reason as R24: if the two
// sides spelled the base even slightly differently the (type, base) cache key would miss and the op shop
// would fall silently to the procedural canvas — which looks exactly like "no wardrobe" and would sail
// past an eyeball. One constant, one resolver, both sides.
//
// PRECEDENCE, and it is deliberate: a shop carrying A's `godverseShopId` keeps its OWN pack (a real
// shop's real stock outranks the town-wide art). No `opshop` entry exists in G's manifest today (15
// shops: record/book/toy only, measured R41), so this arm is defensive, not live.
//
// [Lane F R42 §42.6 — Lane C's §42 ask, one line] THE LITERAL IS GONE FROM THIS FILE. C moved the
// declaration into `interiors/stockpack.js` and re-exported it from `interiors.js`, so the string now
// exists ONCE in the repo and this module re-publishes C's constant rather than keeping a second copy
// that agrees with it today. Every importer of `interior_mode.js` (the shell's `index.html:58`) is
// unchanged — this is a re-export, not a rename. That is the whole point: the comment above says the
// two sides must spell the base identically, and the only way to guarantee that is to have one side.
export { WARDROBE_BASE };
export function stockBaseFor(shop) {
return godverseBaseFor(shop) || (shop && shop.type === 'opshop' ? WARDROBE_BASE : 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 currentRealSourced = false; // [R32] this room's crate is sourcing:'real' — its pulls never expire
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 : '';
// [R32 scarcity] the PULL key's day tag is broader than the SEED's: a rotating bin is pulled-from
// per (bin, day) even on day 1 (where the seed stays untagged by the day-1 convention), while a
// REAL-sourced crate stays untagged forever — sold means gone, so its pulls never expire. The seed
// input above is UNTOUCHED (A's rotation-boundary law); this tag exists only in the save's ledger.
const pullTag = (game && !currentRealSourced) ? '@d' + game.day : '';
const pullPrefix = `${(currentShop && currentShop.id) ?? '?'}|${key}${pullTag}#`;
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)
gone: game ? game.pullsFor(pullPrefix) : undefined, // [R32] this bin's bought slots stay gone today
// [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.
// [R32] the find carries its BAND (the sell counter's §9.1 basis) and the pull enters the save's
// ledger — the slot is gone for the rest of the day even if the item is later sold on.
getCash: wallet ? () => wallet.cash() : undefined,
// [R33] the wantlist seams — game-only (undefined ⇒ dig builds zero WANT DOM, classic-pure)
onWant: game ? (o) => game.recordWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
sku: o && o.item && o.item.id != null ? String(o.item.id) : undefined }) : undefined,
isWanted: game ? (o) => !!game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title),
sku: o && o.item && o.item.id != null ? String(o.item.id) : '' }) : undefined,
onBuy: wallet ? (o) => {
const ok = wallet.buy(o);
if (ok && game) {
const sku = o && o.item && o.item.id != null ? String(o.item.id) : null;
game.recordFind(currentShop, {
type: 'record',
sku: sku ?? undefined,
slotId: sku != 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,
band: (o && o.band) || undefined,
});
game.recordPull(pullPrefix + (sku != null ? 'sku:' + sku : String((o && o.i) ?? 0)));
// [R33] the hunt ends when you're holding it: buying a wanted item clears its want
const w = game.findWant({ artist: o && (o.a || o.artist), title: o && (o.t || o.title), sku: sku || '' });
if (w) { game.removeWant(w); flashToast('✓ off the wantlist'); }
}
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,
band: item.price_band || undefined, // [R32] the shelf find carries its band too (§9.1 basis)
});
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.
// [Lane F R41 §41.6] `stockBaseFor`, not `godverseBaseFor`: same string for a godverse shop, plus the
// wardrobe base for an op shop. Everything else still resolves `undefined` → the town-wide packs,
// byte-identically. Gated on stockReal exactly as before, so ?noassets fetches nothing.
const gBase = stockReal ? stockBaseFor(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
// 24 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');
currentRealSourced = realSourced; // [R32] pull-ledger tagging reads this at dig-open time
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-<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; });
// [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));
// [R33 — THE RUMOR, charter #6, the keeper finally gossips] Seeded per (citySeed, shop, day): a
// ~45% mutter naming a hunted want + a real town — the reason to ride somewhere tomorrow. Runtime
// stream, freshly keyed: no plan/golden moves (A's boundary). No game / empty wantlist ⇒ silence,
// and the banner line simply doesn't exist (classic-pure by construction).
let rumor = '';
if (game && game.wants && game.wants.length) {
const rr = mulberry32(xmur3(`rumor:${plan.citySeed}:${shop.id}:${game.day}`)() >>> 0);
if (rr() < 0.45) {
const w = game.wants[(rr() * game.wants.length) | 0];
const towns = RUMOR_TOWNS.filter((t) => !(plan.name || '').toLowerCase().includes(t.toLowerCase()));
const town = towns[(rr() * towns.length) | 0] || RUMOR_TOWNS[0];
rumor = ` · 🗣 “${String(w.artist || w.title || w.sku).toUpperCase()}? saw one in ${town} just last week”`;
}
}
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' : '')
+ rumor;
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;
currentRealSourced = false;
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 R40 §40.6] the playtest note's "where am I" — the active shop's identity, or null on the street
get shop() { return currentShop ? { id: currentShop.id, name: currentShop.name, type: currentShop.type } : null; },
// [Lane F R40 §40.6] render one frame of whatever the interior is currently showing (open crate, else the
// room) — the F8 screenshot's interior arm: the drawing buffer isn't preserved, so a capture must render
// first, and only this module knows whether the dig owns the frame. Mirrors update()'s own render calls.
renderFrame() {
if (!current) return false;
renderer.autoClear = true;
renderer.info.reset();
if (dig && dig.active) renderer.render(dig.scene, dig.camera);
else renderer.render(scene, camera);
return true;
},
// [R32 — honest-gate surface] the open bin's offers by identity (index/ask/band/sku); null when no
// dig is open. The bible/scarcity gate asserts on THESE, not on how the crate looked.
get digOffers() { return dig && dig.active ? dig.currentOffers() : null; },
// [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 ? stockBaseFor(currentShop) : undefined; // [Lane F R41 §41.6] wardrobe-aware
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],
};
},
};
}