PROCITY/web/js/interiors/stock.js
m3ultra fbc8ffa1c8 Lane C round-8: buy loop v0 + book/toy real stock (?stock=real)
C1 — buy loop v0 (new wallet.js): runtime-only economy — seeded cash
(~$60–200), buy()/broke-gate/in-memory bag, onChange for a cash chip.
World gen untouched (purchases never re-enter the plan/seeded build).
Test page: #cashChip HUD + I-toggle inventory + dig BUY IT button
(pull → bag, cash down, bin depletes); digSoak buys ≤2/visit.

C2 — book spines + toy boxes wired to ?stock=real. stockpack.js keys
each pack to a slot (record→sleeve, book→spine, toy→box); stock.js
shelfLine builds real atlas-UV cover planes for book/toy shelves
(static, no dig), fail-soft per pack. E's packs live: record 350/6,
book 311/4, toy 273/5 (items/atlases).

C3 — validated: draws ≤350 (record 41, book 42, toy 51), determinism
0 fails, leak-free (0 geo/tex delta ×20; buy-soak 12 visits/5 bought,
leakGeo0/tex0), reload resets wallet, flag-off + ?noassets untouched,
qa.sh --strict GREEN 5/5. Shot: docs/shots/laneC/buyloop_r8.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 04:33:00 +10:00

248 lines
12 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 } 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;
const m = ctx.mat(pick(r, CARD), 0.85);
if (r() < 0.5) { 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;
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;
let b;
if (pack) { // real book spine: cover plane at the shelf front
b = realSleeveMesh(ctx, pack, pick(r, pack.items), step * 0.86, bh);
b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01);
parent.add(b);
} else {
const m = ctx.mat('#ffffff', 0.7); m.map = pick(r, pool); m.color.set('#ffffff'); m.needsUpdate = true;
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;
for (let i = 0; i < n; i++) {
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;
if (pack) { // real toy box: cover plane at the carton front
const b = realSleeveMesh(ctx, pack, pick(r, pack.items), bw * 0.88, bh);
b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01);
parent.add(b);
} else {
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;
}
}