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>
300 lines
16 KiB
JavaScript
300 lines
16 KiB
JavaScript
// PROCITY Lane C — visual stock (v1). Fills the fittings' `slots` with product silhouettes:
|
||
// canvas-texture cardboard sleeves / spines / boxes / snacks / magazines with price stickers
|
||
// (the dig.js sleeve trick — coloured cardboard + sticker). Real item data is the content phase;
|
||
// this stays purely cosmetic and byte-identical per seed.
|
||
//
|
||
// stock.fill(ctx, fitting, { shop, recipe, stockKind, adapter }, r)
|
||
// iterates fitting.slots and adds child meshes into fitting.group.
|
||
//
|
||
// stockAdapter hook (content phase): `adapter(shop, slotKind) => { texture } | { mesh } | null`.
|
||
// If it returns a texture, that texture skins the item face; a mesh replaces the item entirely;
|
||
// null (default) → procedural placeholder. BaseGod / GODVERSE data plugs in here without touching
|
||
// layout or fittings code.
|
||
//
|
||
// PERF + LEAK SAFETY: face textures are POOLED per room (a small seeded set reused across many
|
||
// items) → few CanvasTextures to build and dispose, fast builds, soak returns to baseline.
|
||
|
||
import { pick } from './context.js';
|
||
import { realSleeveMesh, buildBuyableShelf } from './stockpack.js';
|
||
|
||
// Muted 90s stock palette (cardboard, plastic, faded card).
|
||
const CARD = ['#b49b72', '#a6b472', '#72a6b4', '#b47298', '#8a72b4', '#b48a72', '#72b48a', '#b4a672'];
|
||
const SPINE = ['#7a2a2a', '#2a4a7a', '#2a6a4a', '#7a5a2a', '#5a2a6a', '#2a5a6a', '#6a6a2a', '#7a3a5a'];
|
||
const SNACK = ['#e04a3a', '#f0b428', '#3a9ae0', '#4ac04a', '#e04a9a', '#f07028', '#a04ae0'];
|
||
|
||
// ── per-room face-texture pool ──────────────────────────────────────────────────────
|
||
// A pooled canvas "face" for a stock kind: coloured card + a little price sticker + label bars.
|
||
function makeFace(ctx, kind, r) {
|
||
const cv = document.createElement('canvas');
|
||
cv.width = 64; cv.height = kind === 'spine' ? 128 : 64;
|
||
const x = cv.getContext('2d');
|
||
const pal = kind === 'spine' ? SPINE : kind === 'snack' ? SNACK : CARD;
|
||
const base = pick(r, pal);
|
||
x.fillStyle = base; x.fillRect(0, 0, cv.width, cv.height);
|
||
// faded border
|
||
x.strokeStyle = 'rgba(0,0,0,0.18)'; x.lineWidth = 3; x.strokeRect(2, 2, cv.width - 4, cv.height - 4);
|
||
if (kind === 'spine') {
|
||
// title bars down the spine
|
||
x.fillStyle = 'rgba(255,255,255,0.82)';
|
||
for (let i = 0; i < 3; i++) x.fillRect(10, 18 + i * 30 + (r() * 6 | 0), cv.width - 20, 8);
|
||
x.fillStyle = 'rgba(0,0,0,0.3)'; x.fillRect(8, cv.height - 20, cv.width - 16, 10);
|
||
} else {
|
||
// a cover blob + label lines
|
||
x.fillStyle = 'rgba(255,255,255,0.16)';
|
||
x.beginPath(); x.arc(32 + (r() - 0.5) * 16, 26, 12 + r() * 6, 0, 6.3); x.fill();
|
||
x.fillStyle = 'rgba(255,255,255,0.75)';
|
||
x.fillRect(8, 44, 34, 5); x.fillRect(8, 52, 22, 4);
|
||
}
|
||
// the op-shop price sticker — a fluoro dot with a $ scrawl
|
||
const sx = cv.width - 18, sy = 14;
|
||
x.fillStyle = '#f4e11a'; x.beginPath(); x.arc(sx, sy, 9, 0, 6.3); x.fill();
|
||
x.fillStyle = '#c1121f'; x.font = 'bold 11px Arial'; x.textAlign = 'center'; x.textBaseline = 'middle';
|
||
x.fillText('$' + (1 + (r() * 9 | 0)), sx, sy + 1);
|
||
return ctx.canvasTexture(cv);
|
||
}
|
||
|
||
function facePool(ctx, kind, r, n = 6) {
|
||
const key = `_facePool_${kind}`;
|
||
if (!ctx[key]) { ctx[key] = []; for (let i = 0; i < n; i++) ctx[key].push(makeFace(ctx, kind, r)); }
|
||
return ctx[key];
|
||
}
|
||
|
||
// A garment silhouette (ported from 90sDJsim garmentCanvas), pooled per room.
|
||
const GARMENTS = ['tee', 'hoodie', 'jacket', 'dress', 'pants', 'shirt'];
|
||
function makeGarment(ctx, r) {
|
||
const shape = GARMENTS[(r() * GARMENTS.length) | 0];
|
||
const color = new ctx.THREE.Color().setHSL(r(), 0.45, 0.4 + r() * 0.2);
|
||
const cv = document.createElement('canvas'); cv.width = 128; cv.height = 160;
|
||
const x = cv.getContext('2d');
|
||
const col = '#' + color.getHexString();
|
||
const dark = '#' + color.clone().offsetHSL(0, 0.05, -0.14).getHexString();
|
||
x.fillStyle = col; x.strokeStyle = dark; x.lineWidth = 4; x.lineJoin = 'round';
|
||
const P = pts => { x.beginPath(); x.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) x.lineTo(pts[i][0], pts[i][1]); x.closePath(); x.fill(); x.stroke(); };
|
||
if (shape === 'pants') P([[40, 8], [88, 8], [90, 152], [70, 152], [64, 62], [58, 152], [38, 152]]);
|
||
else if (shape === 'dress') { P([[48, 20], [80, 20], [104, 152], [24, 152]]); x.fillStyle = dark; x.beginPath(); x.ellipse(64, 22, 11, 6, 0, 0, 6.3); x.fill(); }
|
||
else {
|
||
P([[16, 46], [46, 26], [46, 60], [22, 78]]); P([[112, 46], [82, 26], [82, 60], [106, 78]]);
|
||
P([[42, 24], [86, 24], [86, 142], [42, 142]]);
|
||
x.fillStyle = dark; x.beginPath(); x.ellipse(64, 26, 12, 7, 0, 0, 6.3); x.fill();
|
||
if (shape === 'hoodie') { x.fillStyle = col; x.beginPath(); x.arc(64, 24, 15, Math.PI, 0); x.fill(); x.stroke(); }
|
||
}
|
||
x.fillStyle = 'rgba(255,255,255,0.22)'; x.fillRect(55, 62, 18, 20);
|
||
return ctx.canvasTexture(cv);
|
||
}
|
||
function garmentPool(ctx, r, n = 8) {
|
||
if (!ctx._garmentPool) { ctx._garmentPool = []; for (let i = 0; i < n; i++) ctx._garmentPool.push(makeGarment(ctx, r)); }
|
||
return ctx._garmentPool;
|
||
}
|
||
|
||
// Which pack items a slot may draw from. A multi-slot pack (R41's wardrobe) hands the caller an already
|
||
// filtered `pool`; a single-slot pack (record/book/toy) hands back `pack.items` by reference, and those
|
||
// keep the pre-R41 behaviour exactly — one seeded atlas bucket, one r() draw, one draw call.
|
||
function slotItems(pack, src, r) {
|
||
return (src && src.pool && src.pool !== pack.items) ? src.pool : pack.atlasPool(r);
|
||
}
|
||
|
||
// ── fill entry point ─────────────────────────────────────────────────────────────────
|
||
export function fill(ctx, fitting, opts, r) {
|
||
for (const slot of fitting.slots || []) fillSlot(ctx, fitting.group, slot, opts, r);
|
||
}
|
||
|
||
function fillSlot(ctx, parent, slot, opts, r) {
|
||
switch (slot.kind) {
|
||
case 'sleeve': return binFan(ctx, parent, slot, opts, r);
|
||
case 'garment': return hang(ctx, parent, slot, opts, r);
|
||
case 'treasure': return treasures(ctx, parent, slot, opts, r);
|
||
default: return shelfLine(ctx, parent, slot, opts, r); // box / spine / snack / magazine
|
||
}
|
||
}
|
||
|
||
// Ask the adapter for a face texture (content phase); null → pooled procedural face.
|
||
function faceTex(ctx, kind, opts, r) {
|
||
if (opts.adapter) {
|
||
const got = opts.adapter(opts.shop, kind);
|
||
if (got && got.texture) return got.texture;
|
||
}
|
||
return pick(r, facePool(ctx, kind === 'magazine' ? 'card' : kind, r));
|
||
}
|
||
|
||
// Packed leaning sleeves in a bin/crate; front few wear covers (dig.js look). With ?stock=real the
|
||
// adapter hands back a pack pool → every sleeve is a real cover PLANE sampling the shared atlas (batches
|
||
// to one draw); otherwise procedural cardboard.
|
||
function binFan(ctx, parent, slot, opts, r) {
|
||
const n = slot.count || 12;
|
||
const fw = slot.faceW || 0.3, h = slot.height || 0.3, run = slot.run || 0.5, tilt = slot.tilt ?? -0.4;
|
||
const step = run / Math.max(1, n);
|
||
const src = opts.adapter && opts.adapter(opts.shop, 'sleeve');
|
||
const pack = src && src.real ? src.pack : null;
|
||
const packItems = pack ? (src.pool || pack.items) : null; // [R41] slot-filtered when the pack has slots
|
||
const pool = pack ? null : facePool(ctx, 'card', r);
|
||
for (let i = 0; i < n; i++) {
|
||
const z = -run / 2 + (i + 0.5) * step;
|
||
let sl;
|
||
if (pack) {
|
||
sl = realSleeveMesh(ctx, pack, pick(r, packItems), fw, h); // isStock + shared atlas material
|
||
sl.position.set(slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z);
|
||
parent.add(sl);
|
||
} else {
|
||
const front = i >= n - 4; // front sleeves show a cover
|
||
const faceMat = front ? ctx.mat('#ffffff', 0.7) : ctx.mat(pick(r, CARD), 0.85);
|
||
if (front) { faceMat.map = pick(r, pool); faceMat.color.set('#ffffff'); faceMat.needsUpdate = true; }
|
||
sl = ctx.box(fw, h, 0.012, faceMat, slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z, parent);
|
||
sl.userData.isStock = true;
|
||
}
|
||
sl.rotation.x = tilt + (r() - 0.5) * 0.05;
|
||
sl.rotation.z = (r() - 0.5) * 0.04;
|
||
}
|
||
}
|
||
|
||
// Neat row along local X. Handles box (upright), spine (edge-out), snack (small), magazine (leaning).
|
||
function shelfLine(ctx, parent, slot, opts, r) {
|
||
const kind = slot.kind;
|
||
const run = slot.run || 0.8, depth = slot.depth || 0.3, clear = slot.height || 0.3;
|
||
const scatter = slot.scatter;
|
||
const n = Math.max(1, slot.count || 4);
|
||
const pool = kind === 'spine' ? facePool(ctx, 'spine', r) : facePool(ctx, kind === 'snack' ? 'snack' : 'card', r);
|
||
// ?stock=real: book spines / toy boxes pull real covers from the pack (shared atlas material → batched).
|
||
const src = !scatter && opts.adapter && opts.adapter(opts.shop, kind);
|
||
const pack = src && src.real ? src.pack : null;
|
||
|
||
if (scatter) { // tables / pegboards: random small goods
|
||
for (let i = 0; i < n; i++) {
|
||
const s = 0.09 + r() * 0.11;
|
||
// [R39] `slot.pool` — keep every face on a POOLED texture at white, exactly as the spine and
|
||
// shelf-box rows already do, so a scattered pile MERGES into the room's existing stock buckets
|
||
// instead of minting one bucket (= one draw) per item. Measured on the rummage bin: 9 tinted
|
||
// scatter items cost 9 draws in a room with no other scatter; pooled they cost 0–1. Absent ⇒
|
||
// the pre-R39 tinted mix, and the stream shape is unchanged either way (both branches draw
|
||
// tint, then the coin, then the face only when mapped) — trestle/pegboard are byte-identical.
|
||
const tint = pick(r, CARD);
|
||
const mapped = r() < 0.5 || !!slot.pool;
|
||
const m = ctx.mat(slot.pool ? '#ffffff' : tint, 0.85);
|
||
if (mapped) { m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true; }
|
||
const b = ctx.box(s, slot.wall ? s * 1.4 : s, slot.wall ? 0.03 : s, m,
|
||
slot.x + (r() - 0.5) * (run - s), slot.y + (slot.wall ? (r() - 0.5) * (clear - s) : s / 2),
|
||
slot.z + (slot.wall ? 0.03 : (r() - 0.5) * (depth - s)), parent);
|
||
b.rotation.y = (r() - 0.5) * 0.6;
|
||
b.userData.isStock = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (kind === 'spine') { // books / VHS: thin spines edge-out
|
||
const step = (run - 0.04) / n;
|
||
if (pack) { // real book spines → one buyable, addressable mesh
|
||
const poolItems = slotItems(pack, src, r); // single atlas → the whole slot merges to one draw
|
||
const pls = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const bh = clear * (0.8 + r() * 0.2);
|
||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||
const rz = (slot.lean && i > n - 3) ? 0.18 : 0;
|
||
pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: step * 0.86, h: bh, rz });
|
||
}
|
||
const meshes = buildBuyableShelf(ctx, pack, pls);
|
||
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
|
||
}
|
||
for (let i = 0; i < n; i++) { // procedural spines (no pack / merge failed)
|
||
const bh = clear * (0.8 + r() * 0.2);
|
||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const b = ctx.box(step * 0.86, bh, depth, m, x, slot.y + bh / 2, slot.z, parent);
|
||
b.userData.isStock = true;
|
||
if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (kind === 'magazine') { // leaning mag faces
|
||
const step = run / n;
|
||
for (let i = 0; i < n; i++) {
|
||
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const b = ctx.box(step * 0.9, clear * 0.9, 0.01, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + clear * 0.45, slot.z, parent);
|
||
b.rotation.x = slot.tilt ?? -0.35;
|
||
b.userData.isStock = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// default 'box' / 'snack': upright cartons in a row, faces forward (+Z).
|
||
// SINGLE material (cover on all faces) so the whole run merges to one draw in batch.js — a
|
||
// multi-material box cost one draw PER face (6×), the biggest draw-count offender pre-round-6.
|
||
const bw = Math.min((run - 0.02) / n, kind === 'snack' ? 0.12 : 0.28);
|
||
const step = run / n;
|
||
if (pack) { // real toy boxes → one buyable, addressable mesh
|
||
const poolItems = slotItems(pack, src, r); // single atlas → the whole slot merges to one draw
|
||
const pls = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
|
||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||
const it = pick(r, poolItems);
|
||
// [R41] a cut-out item (a bucket hat, a pair of thongs) takes its OWN width from its atlas cell;
|
||
// a cover-art item has no `aspect` and keeps the fixed carton width it always had.
|
||
const iw = it.aspect ? Math.min(bw * 0.98, bh * it.aspect) : bw * 0.88;
|
||
pls.push({ item: it, x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: iw, h: bh });
|
||
}
|
||
const meshes = buildBuyableShelf(ctx, pack, pls);
|
||
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
|
||
}
|
||
for (let i = 0; i < n; i++) { // procedural cartons (no pack / merge failed)
|
||
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
|
||
const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
|
||
const x = slot.x - run / 2 + (i + 0.5) * step;
|
||
const m = ctx.mat('#ffffff', 0.8);
|
||
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const mesh = ctx.box(bw * 0.88, bh, bd, m, x, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
|
||
mesh.userData.isStock = true;
|
||
}
|
||
}
|
||
|
||
// Garments hung along a rail.
|
||
//
|
||
// [R41 §41.4 — ROUTE R5, THE WARDROBE, ZERO GEOMETRY] With the op-shop pack loaded (`?stock=real`), every
|
||
// garment on this rail is a real 1990s Australian layer off ONE shared atlas — so a rail of twelve is one
|
||
// draw, exactly like the record bins, and the op shop's clothes finally have names the buy card can show.
|
||
// The garment's WIDTH comes from its own atlas cell aspect, not a constant: a pair of phat pants is wider
|
||
// than a ribbed singlet, and forcing both into a 0.34 m plane was the tell that gave the old placeholder
|
||
// away. No pack ⇒ the pooled procedural canvas, unchanged, byte-for-byte (this is the ?noassets path).
|
||
function hang(ctx, parent, slot, opts, r) {
|
||
const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52;
|
||
const src = opts.adapter && opts.adapter(opts.shop, 'garment');
|
||
const pack = src && src.real ? src.pack : null;
|
||
if (pack) {
|
||
const items = src.pool || pack.items;
|
||
const pls = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const it = pick(r, items);
|
||
const gh = h * (0.9 + r() * 0.2);
|
||
const gw = gh * (it.aspect || 0.66);
|
||
pls.push({ item: it, x: slot.x - run / 2 + run * (i / (n - 1 || 1)), y: slot.y - 0.05 - gh / 2,
|
||
z: slot.z + (r() - 0.5) * 0.03, w: gw, h: gh, ry: (r() - 0.5) * 0.15 });
|
||
}
|
||
const meshes = buildBuyableShelf(ctx, pack, pls); // addressable → the shelf buy card reads it
|
||
if (meshes.length) { for (const m of meshes) parent.add(m); return; } // else fall through, fail-soft
|
||
}
|
||
const pool = garmentPool(ctx, r);
|
||
for (let i = 0; i < n; i++) {
|
||
const gx = slot.x - run / 2 + (run) * (i / (n - 1 || 1));
|
||
const m = ctx.mat('#ffffff', 0.92, { transparent: true, alphaTest: 0.4, side: ctx.THREE.DoubleSide });
|
||
m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
|
||
const g = ctx.planeGeo(0.34, h);
|
||
const p = new ctx.THREE.Mesh(g, m);
|
||
p.position.set(gx, slot.y - 0.05 - h / 2, slot.z + (r() - 0.5) * 0.03);
|
||
p.rotation.y = (r() - 0.5) * 0.15;
|
||
p.userData.isStock = true;
|
||
parent.add(p);
|
||
}
|
||
}
|
||
|
||
// Small treasures on a glass-case shelf.
|
||
function treasures(ctx, parent, slot, opts, r) {
|
||
const n = slot.count || 4, run = slot.run || 1.0;
|
||
const cols = ['#c8a13a', '#b0b0b8', '#8a5a3a', '#3a6a8a', '#8a3a5a'];
|
||
for (let i = 0; i < n; i++) {
|
||
const s = 0.06 + r() * 0.06;
|
||
const b = ctx.box(s * (1 + r()), s, s, ctx.mat(pick(r, cols), 0.4, { metalness: 0.3 }),
|
||
slot.x - run / 2 + (run) * ((i + 0.5) / n), slot.y + s / 2, slot.z + (r() - 0.5) * (slot.depth || 0.2) * 0.5, parent);
|
||
b.rotation.y = r() * Math.PI;
|
||
b.userData.isStock = true;
|
||
}
|
||
}
|