PROCITY/web/js/interiors/stock.js
m3ultra 422f17263e Lane C R41 §41.4: the shops get their stock — and the measuring instrument was wrong
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>
2026-08-07 18:11:07 +10:00

300 lines
16 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 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 01. 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;
}
}