PROCITY/web/js/interiors/stockpack.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

198 lines
12 KiB
JavaScript

// PROCITY Lane C — real record sleeves for `?stock=real` (round 7). Feeds Lane E's GODVERSE stock pack
// (parody-transformed Discogs metadata + cover atlases) into the interior stock via the stockAdapter
// seam built in v1. Read-only, no economy (buying stays a stub — that's a later round).
//
// Pack contract (agreed in LANE_C_NOTES): stock_<type>_index.json →
// { version, atlas_px, cell, atlases:["…webp"], items:[{ id,title,artist,price,price_band,atlas,uv }] }
// uv = [u0,v0,u1,v1] cover rect, ORIGIN TOP-LEFT (image-natural) — this module flips V for WebGL.
//
// Batching: every sleeve samples ONE shared atlas material with a per-item UV baked into its geometry,
// so all real sleeves in a room merge (batch.js) to one draw per atlas — the ≤350 law holds.
// Leak-free: the atlas texture + material are shared/cached in the pack (NOT per-room), like depot GLBs;
// per-sleeve geometries are ctx-tracked and disposed with the room.
// Fail-soft: index/atlas missing → getStockPack returns null → callers fall back to parody canvas.
import * as THREE_NS from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
const _texLoader = new THREE_NS.TextureLoader();
// Where the TOWN-WIDE packs live. A per-shop (godverse) pack lives under its own base instead —
// `assets/stock_godverse/<godverseShopId>/` — and carries the SAME `stock_<type>_index.json` name,
// because `type` is what picks the stock slot (SLOT_FOR: record→sleeve, book→spine, toy→box).
export const DEFAULT_STOCK_BASE = 'assets/models/';
// [R42 §42.2b — one spelling, from Lane F] The R41 op-shop wardrobe pack lives under its OWN base
// because it is not a GODVERSE crate (52 named 1990s Australian garment layers on one 240 KB atlas).
// The literal was written out in TWO files — `web/interior_test.html` and `web/js/world/interior_mode.js`
// — and a string that has to match a directory on disk in two places is a rename waiting to break the
// op shop silently. It is declared here, next to the loader that resolves it, and re-exported from
// `interiors.js`; the test page now imports it. → LANE B/F: `interior_mode.js` can drop its own copy
// for `import { WARDROBE_BASE } from '../interiors/interiors.js'` in one line (LANE_C_NOTES §42).
export const WARDROBE_BASE = 'assets/wardrobe/';
// CACHE IDENTITY IS (type, base) — never `type` alone. A per-shop atlas and the town-wide pack are
// different packs that share a type; keying by type alone made `base` a no-op on a cache hit, so the
// 2nd caller silently received the 1st caller's pack (R23, measured). That wasn't only a mix-up: it
// defeated the fail-soft law — an UNSTOCKED shop of an already-cached type inherited that crate
// instead of falling back to parody. (R24 ledger #1; the runtime is the contract's authority.)
const packKey = (type, base) => `${base}|${type}`;
const _packs = new Map(); // packKey → Promise<pack|null>
const _resolved = new Map(); // packKey → pack|null (sync accessor for buildInterior)
// Preload a pack (fetch index + atlas textures). Cached per (type, base). Call once before building
// with ?stock=real — for a per-shop pack, pass that shop's base.
export function preloadStockPack(type, { base = DEFAULT_STOCK_BASE, THREE = THREE_NS } = {}) {
const key = packKey(type, base);
if (_packs.has(key)) return _packs.get(key);
const p = fetch(`${base}stock_${type}_index.json`)
.then(r => (r.ok ? r.json() : null))
.then(idx => (idx && idx.items && idx.items.length ? buildPack(type, idx, base, THREE) : null))
.catch(() => null)
.then(pack => { _resolved.set(key, pack); return pack; });
_packs.set(key, p);
return p;
}
// Synchronous accessor — the resolved pack for (type, base), else null (buildInterior uses this).
// `base` defaults to the town-wide packs, so every pre-v5 caller keeps its exact behaviour.
export function getStockPack(type, base = DEFAULT_STOCK_BASE) { return _resolved.get(packKey(type, base)) || null; }
async function buildPack(type, idx, base, THREE) {
const names = idx.atlases || [];
const materials = {};
// [R41 §41.4] `idx.material` — an OPTIONAL per-pack material block. The record/book/toy atlases are
// opaque cover art and carry none, so they build exactly the material they always did. The wardrobe
// atlas is 52 CUT-OUT garments on transparent ground: without alphaTest every tee is a rectangle, and
// without DoubleSide the back of a garment hanging on a rail is a hole. One field, no fork.
const mk = idx.material || null;
await Promise.all(names.map(async (name) => {
const url = /^depot:|^https?:/.test(name) ? name : base + name;
let tex;
try { tex = await _texLoader.loadAsync(url); } catch (e) { tex = null; }
if (tex) { tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; }
materials[name] = new THREE.MeshStandardMaterial({
color: 0xffffff, roughness: (mk && mk.roughness) || 0.72, map: tex || null,
...(mk && mk.alphaTest ? { transparent: true, alphaTest: mk.alphaTest } : {}),
...(mk && mk.side === 'double' ? { side: THREE.DoubleSide } : {}),
});
}));
const first = names[0];
// Items grouped by atlas — so a buyable shelf can draw all its covers from ONE atlas and merge to a
// single mesh (one draw), instead of fragmenting into one mesh per atlas the random picks happened to hit.
const byAtlas = {};
for (const name of names) byAtlas[name] = [];
for (const it of idx.items) (byAtlas[it.atlas] || byAtlas[first] || (byAtlas[first] = [])).push(it);
const nonEmpty = names.filter(n => byAtlas[n] && byAtlas[n].length);
// [R41] Items may declare which STOCK SLOT they serve (`it.slot`). The wardrobe pack holds two kinds in
// one atlas — garments that hang on a rail and small goods that sit on a shelf — because one atlas is
// one material is ONE DRAW for every piece of clothing in the room. Packs whose items carry no `slot`
// get a single bucket keyed by the pack's own canonical slot, i.e. exactly their old behaviour.
const bySlot = {};
for (const it of idx.items) (bySlot[it.slot || SLOT_FOR[type] || 'sleeve'] ||= []).push(it);
return {
type,
items: idx.items,
atlases: nonEmpty.length ? nonEmpty : names,
itemsByAtlas: byAtlas,
bySlot,
// items serving one stock-slot kind (falls back to the whole pack, so a caller never gets nothing)
slotPool(slotKind) { const p = bySlot[slotKind]; return p && p.length ? p : idx.items; },
// shared material for an item's atlas (batching groups sleeves by this)
material(item) { return materials[item.atlas] || materials[first]; },
// items from a seeded single atlas (buyable shelves use this to stay one-mesh/one-draw)
atlasPool(r) { const a = (this.atlases.length ? this.atlases : names); const name = a[(r() * a.length) | 0]; return byAtlas[name] && byAtlas[name].length ? byAtlas[name] : idx.items; },
// WebGL-flipped cover rect [u0, v0, u1, v1] (origin bottom-left) for a plane's UV
uvRect(item) { const u = item.uv; return [u[0], 1 - u[3], u[2], 1 - u[1]]; },
};
}
// Each pack type feeds the stock slot kinds it belongs in, so a record shop's pack never lands on a
// bric-a-brac shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves).
// [R41 §41.4] `opshop` serves TWO: garment (the clothes rails) and box (the shelves and bargain tubs) —
// the 441-layer wardrobe library is tops/bottoms AND hats/shoes/bags, and an op shop sells both off the
// same floor. Declared as an array; the three pre-R41 packs keep their single string.
const SLOT_FOR = { record: 'sleeve', book: 'spine', toy: 'box', opshop: ['garment', 'box'] };
// The stockAdapter for ?stock=real. Returns the pack POOL for its matching slot kind; consumers
// (stock.binFan / stock.shelfLine / stock.hang / dig.js) pick a seeded subset. Other kinds → null →
// procedural. `pool` is the slot-filtered item list; a single-slot pack returns its whole item list, so
// the three pre-R41 packs are handed byte-identical data.
export function makeStockAdapter(pack) {
if (!pack || !pack.items || !pack.items.length) return null;
const want = SLOT_FOR[pack.type] || 'sleeve';
const wants = Array.isArray(want) ? want : [want];
return (shop, slotKind) => (wants.includes(slotKind)
? { real: true, pack, pool: wants.length > 1 ? pack.slotPool(slotKind) : pack.items }
: null);
}
// Build a leaning cover PLANE for one pack item, UV-remapped to its atlas rect, tagged isStock so
// batch.js merges it. Shared material (per atlas) → the whole bin batches to one draw.
export function realSleeveMesh(ctx, pack, item, w, h) {
const THREE = ctx.THREE;
const g = ctx.geom(new THREE.PlaneGeometry(w, h));
const [u0, v0, u1, v1] = pack.uvRect(item); // already WebGL-flipped
const a = g.attributes.uv;
a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0); // TL,TR,BL,BR
a.needsUpdate = true;
const mesh = new THREE.Mesh(g, pack.material(item));
mesh.userData.isStock = true;
return mesh;
}
// One quad geometry for a placement, UV-baked to its atlas rect and transformed into fitting-local
// space (so the merged mesh sits at identity). Shared shape with realSleeveMesh, minus the Mesh.
function itemQuad(THREE, pack, pl) {
const g = new THREE.PlaneGeometry(pl.w, pl.h);
const [u0, v0, u1, v1] = pack.uvRect(pl.item);
const a = g.attributes.uv;
a.setXY(0, u0, v1); a.setXY(1, u1, v1); a.setXY(2, u0, v0); a.setXY(3, u1, v0);
if (pl.rz) g.rotateZ(pl.rz);
if (pl.rx) g.rotateX(pl.rx);
if (pl.ry) g.rotateY(pl.ry); // [R41] hanging garments swing a few degrees on the rail
g.translate(pl.x, pl.y, pl.z);
return g;
}
// BUY-ANYWHERE (round 9): build a shelf of real covers as merged, per-item-ADDRESSABLE meshes — one
// mesh per atlas bucket (so all share a material and draw once), tagged `noBatch` so batch.js leaves
// them intact (they're already one draw each). Each mesh carries `buyItems:[{item,center,vStart}]` and
// a `sold` list; collapseBuyItem() zero-areas a bought item's quad in place. Records stay the dig; this
// is book spines + toy boxes. Returns [] on merge failure → caller falls back to plain realSleeveMesh.
// The merged geometries are ctx-tracked → freed with the room (leak-free).
export function buildBuyableShelf(ctx, pack, placements) {
const THREE = ctx.THREE;
const byAtlas = new Map(); // material.uuid → { material, pls:[] }
for (const pl of placements) {
const mat = pack.material(pl.item); if (!mat) continue;
let b = byAtlas.get(mat.uuid); if (!b) { b = { material: mat, pls: [] }; byAtlas.set(mat.uuid, b); }
b.pls.push(pl);
}
const out = [];
for (const b of byAtlas.values()) {
const geos = b.pls.map(pl => itemQuad(THREE, pack, pl));
let merged; try { merged = mergeGeometries(geos, false); } catch (e) { merged = null; }
geos.forEach(g => g.dispose());
if (!merged) continue;
ctx._geometries.add(merged); // freed at room dispose
const mesh = new THREE.Mesh(merged, b.material);
mesh.userData.noBatch = true; // already one draw — batch.js skips it
mesh.userData.buyMesh = true;
mesh.userData.buyItems = b.pls.map((pl, i) => ({ item: pl.item, center: { x: pl.x, y: pl.y, z: pl.z }, vStart: i * 4 }));
mesh.userData.sold = [];
out.push(mesh);
}
return out;
}
// Zero-area a bought item's quad (collapse its 4 verts to the item centre → invisible), in place, in
// the merged buyable mesh. Leak-free (no geometry churn). `it` is an entry from mesh.userData.buyItems.
export function collapseBuyItem(mesh, it) {
const pos = mesh.geometry.attributes.position;
for (let k = 0; k < 4; k++) pos.setXYZ(it.vStart + k, it.center.x, it.center.y, it.center.z);
pos.needsUpdate = true;
mesh.geometry.computeBoundingSphere();
if (mesh.userData.sold) mesh.userData.sold.push(it.vStart);
}