PROCITY/web/js/interiors/stock.js
m3ultra eeba920d68 Lane C R39 §39.3: the op shop gets a bin — the verb table, and the margin was never six
THE TABLE (LANE_C_PUB §10, the deliverable): what a player can actually do in each of
the 12 shop types, counted in 48 rooms per type. `record` digs (4.6 bins/room) and is the
only room where anything can be sold. `stall` digs — in 27 of 48 rooms; the other 21 (44%)
have NO crate, a measured correction to the round's binding fact #5. The other ten types
offer looking. `?stock=real` is opt-in, so the default boot has zero `buyMesh` anywhere;
garments/snacks/magazines/treasures are unbuyable in any mode; `interactable:true` on
cases/fridges/returns/arcade is read by nothing.

THE MECHANISM: `rummageBin` — a castored galvanised dump tub, rim 0.76 m, heaped with
stickered bric-a-brac and a hand-lettered "EVERYTHING $2" card. 72 rooms: placed 72/72,
reachable 72/72 (0.77–1.15 m), 0 carves, pathOK 72/72, deterministic 72/72, and the rest
of the room byte-identical to pre-R39 72/72 (the spec is appended last). Armed end-to-end:
aim → dig → 16 offers → BUY → bin depletes. Default: 0 bins, dig never opens.

THE CONTENTS ARE NOT SHIPPED. `o.dig` defaults false, so the tub is furniture and the
economy is untouched; `opts.fittingOpts.rummageBin.dig` (or `?opshopdig=1`) arms it. Held
for John with the numbers: 44 record + 36 stall vs 147 op shops across 23 towns.

THE BUDGET: `C-progress.md:841`'s GLB-on 344 is R9-era and stale — today's tree measured
319 before I touched it, so the margin was 31, not six. The bin costs +5 GLB-off / +10
GLB-on at opshop/hall; every other type byte-identical. Margin now 21. Also: `slot.pool`
stops the scatter fill minting a material per item, so 12 goods cost less than 9 did.

FILED, NOT FIXED: Lane E's bookshelf GLB ships transmission:1, which triggers three.js's
transmission pre-pass and issues every draw in the room twice — opshop/hall 191→104,
book/hall 101→51. Roughly half the interior budget. E's asset, so E/F rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:59:42 +10:00

266 lines
14 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;
}
// ── 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 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, pack.items), 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 = pack.atlasPool(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 = pack.atlasPool(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;
pls.push({ item: pick(r, poolItems), x, y: slot.y + bh / 2, z: slot.z + depth / 2 - 0.01, w: bw * 0.88, 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.
function hang(ctx, parent, slot, opts, r) {
const n = slot.count || 10, run = slot.run || 1.4, h = slot.height || 0.52;
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;
}
}