110 fittings placed across 12 shop types via new kit41.js (96-row catalogue, 67 ids in pools) and ONE generic kitProp builder; recipes name pools, never asset ids. glb.js now honours seeded per-fitting variants, multi-GLB booths, and a MEASURED autoYaw instead of a 110-row facing table. THE INSTRUMENT WAS WRONG. drawSweep cleared every interior EXCEPT current, so every historical interior number carried a phantom second room — 'toy', untouched this round, 'gained' 4 draws. R39/R40's celebrated 'worst 188, margin 162' was inflated. Corrected, and with 110 new fittings placed: worst room in the game is 122 @ dept/auto, MARGIN 228. Per type: record 61->78, pub 45->59, band_room 39->50, stall 71->82, book 51->61, pawn 60->70, video 54->63, rsl 52->60, dept 116->122, opshop 102->105, milkbar 68->69, toy 86->86 (+0, the control). The pub finally has a bar (plywood_bar), banquettes, pokies, a stage flank. Milk bars get a bain-marie and pie warmer. The pawn shop gets an 808 and an SP-1200 on the bench. Record shops get a rigged DJ booth. Market stalls get 2-4 diggable bins each, closing R39's measured 44%-no-crate hole to 0/40. WARDROBE, route R5: 257.5 KB total (246 KB atlas + 17 KB index), ONE fetch, one material, one draw per fitting — 52 layers picked BY NAME, alpha-cropped, shelf-packed, 3.7% of source bytes, lazy behind ?stock=real. LICENCE CALL MADE EXPLICITLY: AUDIT.md says no real brands ship and the source library is prompted FROM real labels — the adidas jacket renders a legible trefoil. Screened on a contact sheet, then relabelled in parody voice (Paddy's Bootleg, Kaymart, Coogee Knits, Mombo, Stoosh). FIVE DEFECTS FOUND AND FIXED, one long-standing: buyMesh has been INVISIBLE on every GLB fitting since R9 (hiding every buyable item under ?stock=real) · DJ booths were nondeterministic (parts attached in network order) · booth gear floated at 0.92 m · batch.js dropped userData sub-tags on merge, so any post-batch flag read was already a no-op · the path guarantee could empty a room (pub/wide/4242 returned a bar, a stage and nothing else) — new restore pass, 129 pulled -> 101 restored over 480 rooms, path re-proved on every restore. Gates: 288-room sweep x 4 arms all pass; soak 60 rooms avg 3.83 ms worst 10.3 ms, leaks 0; 960-room corpus 0 path-fails 0 carves; determinism byte-equal across fresh contexts (sha256 c26b4848); ?noassets=1 60 rooms with 0 GLB / 0 manifest / 0 wardrobe / 0 stock fetches. DJ control nodes recorded on fitting.controlNodes for a future driver (glTF drops the limit constraints — a driver must clamp travel itself). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
11 KiB
JavaScript
189 lines
11 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/';
|
|
|
|
// 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);
|
|
}
|