Lane C R7 C1: ?stock=real — real GODVERSE sleeves in the crates

The stockAdapter seam built in v1 finally eats. Lane E's stock pack (parody-
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig
riffle behind ?stock=real. Economy stays a stub (buying = toast).

- Contract handshake (task 1, unblocked E): confirmed E's index schema as-is
  in LANE_C_NOTES (items[{id,title,artist,price,price_band,atlas,uv}]; uv
  origin top-left, C flips V for WebGL; fail-soft; per-type packs).
- stockpack.js (new): preloadStockPack/getStockPack (cached) + makeStockAdapter.
  Atlas texture + material are shared/cached (like depot GLBs); each sleeve is
  a cover PLANE sampling the atlas at its baked UV, so all real sleeves in a
  room batch to ONE draw (record: 59 draws).
- stock.js binFan: real cover planes (else parody canvas). dig.js: riffles real
  covers, pull card shows title/artist/$price/band. interiors.js: opts.stock=
  'real' wires the adapter (warn-once + parody fallback if pack absent). Test
  page reads ?stock=real, preloads record/book/toy, passes the adapter to the dig.

Validated (E's staged 24-sample; real covers drop in unchanged when E gets the
DB): determinism 0/30, leak-free rooms + real dig (0/0 over 25 cycles), draws
<=139 all rooms (record 59), fail-soft on no-pack types, ?dig=1&stock=real
leak-free. Flag-off + ?noassets untouched (procedural path byte-identical).
qa.sh --strict GREEN. F smoke hook + E contract in LANE_C_NOTES. Shot
docs/shots/laneC/stock_real_r7.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-15 01:23:23 +10:00
parent 43edabfe56
commit 3724a65505
8 changed files with 226 additions and 18 deletions

View File

@ -3,7 +3,32 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-14 (round 6) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-14 (round 7) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-14 (round 7) — `?stock=real`: real GODVERSE sleeves in the crates
Round-7 §Lane C: the `stockAdapter` seam built in v1 finally eats. Lane E's GODVERSE stock pack (parody-
transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig riffle behind `?stock=real`.
- **Contract handshake** (task 1, unblocked E): confirmed E's index schema as-is in
[LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md) — `{ items:[{id,title,artist,price,price_band,atlas,uv}] }`,
UV origin top-left (C flips V for WebGL), fail-soft, per-type packs.
- **New `stockpack.js`**: `preloadStockPack`/`getStockPack` (cached) + `makeStockAdapter`. Atlas texture +
material are shared/cached (like depot GLBs); sleeves are cover PLANEs sampling the atlas at each item's
baked UV → all real sleeves in a room **batch to one draw** (record: 59 draws).
- **Consumers**: `stock.js binFan` builds real cover planes (else parody canvas); `dig.js` riffles real
covers and the pull card shows **title / artist / $price / band**; `interiors.js` wires `opts.stock='real'`
through the seam (warn-once + parody fallback if the pack is absent); test page reads `?stock=real`.
- **Validated (sample 24-pack)**: determinism 0 fails/30, leak-free rooms AND real dig (0/0 over 25 cycles —
atlas shared, sleeve geo disposed), draws ≤139 all rooms (record 59), fail-soft on no-pack types (opshop
ok → parody), `?dig=1&stock=real` leak-free. Flag-off + `?noassets` untouched (procedural path byte-identical).
- Built against E's **staged sample** (real index format, synthetic covers); real Discogs covers drop in
unchanged when E gets the DB (external — John/Fable). Shot: [stock_real_r7.jpg](docs/shots/laneC/stock_real_r7.jpg).
**→ F**: new smoke for your strict harness — `?stock=real` enter record shop + open dig → assert real
(non-parody) sleeves; the record bin's real sleeves are cover planes with `userData.isStock` on a shared
atlas material (drawSweep stays ≤350). Hook + adapter in LANE_C_NOTES.
---

View File

@ -1,5 +1,64 @@
# LANE C — cross-lane notes (PROCITY-C)
## → Lane F: `?stock=real` smoke for the strict harness (round-7 C1 landed)
`?stock=real` is wired (record shops; book/toy fail-soft if E ships those packs). Smoke recipe:
- Preload once: `import { preloadStockPack } from interiors.js; await preloadStockPack('record')`.
- Boot `?stock=real`, enter a record shop, **assert real sleeves present**: a real sleeve is a `Mesh`
with `userData.isStock`, geometry `PlaneGeometry`/`BufferGeometry` (batched), on a shared **atlas**
material (`material.map` = the pack atlas, not a per-item canvas). Simplest assert: after build,
`let n=0; room.group.traverse(o=>{ if(o.userData?.isStock && o.material?.map) n++ }); assert n>0` — with
the pack loaded the record bins' sleeves batch to **one** atlas mesh (parody canvas would be ~6 card
meshes). Or open the dig (`?dig=1&stock=real`) and assert a pulled sleeve's panel title is a pack title.
- **Fail-soft**: pack missing → parody canvas + one `console.warn` (no throw). `?noassets` unaffected.
- **drawSweep still ≤350** with `?stock=real` (record 59 draws — the atlas batches all sleeves).
## → Lane E: stock-pack contract CONFIRMED (round-7 C1) — build the real pack to this, unchanged
Your proposed index schema is good — **confirmed as-is, no field changes**. Building `?stock=real`
against your staged 24-sleeve sample now (`web/assets/models/stock_record_{index.json,atlas_00.webp}`);
the real covers drop in unchanged. The contract C consumes:
```jsonc
// stock_<type>_index.json (type ∈ record | book | toy ; record is the acceptance bar)
{
"version": 1,
"atlas_px": 2048,
"cell": 256, // informational; C reads uv, not cell
"atlases": ["stock_record_atlas_00.webp", ...], // 1+; may be depot: or assets/models/ resolved
"items": [
{ "id": "rec_0000",
"title": "Neon Suburbs", // parody-transformed (no-real-trademarks law applies to metadata)
"artist": "Trev Wollemi",
"price": 39, // integer dollars — C shows "$39"
"price_band": "collector", // bargain|standard|collector|grail — C may tint the sticker
"atlas": "stock_record_atlas_00.webp", // which entry in `atlases`
"uv": [0.0, 0.0, 0.125, 0.125] } // [u0,v0,u1,v1] cover rect, ORIGIN TOP-LEFT
]
}
```
Agreements / who-does-what:
- **UV origin is top-left (image-natural) — C flips V for WebGL** (`v_gl = 1 - v`). Keep your index
image-natural; the consumer handles the flip. (Sample verified against this.)
- **Resolution**: C loads `stock_<type>_index.json` from a base (default `assets/models/`, i.e. your
staged path — same-origin, works offline). Atlas filenames in the index resolve against the same base;
if you publish atlases to the depot, give me a `"depot": true` flag or depot-prefixed names and I'll
route through the `depot:` loader. For now the sample loads local.
- **Seeded pick per bin is mine** (same `shop.seed` → same crate contents). I only need *enough* items —
24 repeats across bins (fine for testing); the 300 pack fixes variety.
- **Batching**: because every sleeve samples one shared atlas material (per-item UV baked into geometry),
all real sleeves in a room merge to **one draw** — the ≤350 law holds. Please keep it **one atlas per
pack** where you can (or few); each extra atlas = one more draw.
- **Fail-soft**: index/atlas missing or `?stock` off → parody canvas (warn once). `?noassets` untouched.
- **Per-type packs**: name them `stock_book_*`, `stock_toy_*` if you build them; I key by shop type and
fail-soft per type, so record-only is fine — book/toy are a bonus.
You flagged needing a Postgres DSN (from John/Fable) to swap synthetic covers for real Discogs images —
that's external and **does not block my consumer**; ping me only if the schema needs to change.
## → Lane F: interior ≤350-draw assertion for the harness (round-6 C1)
C1 batching landed — worst room is now **313 draws GLB-on** / 152 GLB-off (was 1,245). The test page

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -69,9 +69,14 @@
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS } from './js/interiors/interiors.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } from './js/interiors/interiors.js';
import { createDig, binSeed } from './js/interiors/dig.js';
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real';
if (STOCK_REAL) for (const t of ['record', 'book', 'toy']) preloadStockPack(t); // fail-soft per type
const realAdapterFor = (type) => (STOCK_REAL ? makeStockAdapter(getStockPack(type)) : null);
// ── renderer / scene / camera ──────────────────────────────────────────────
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
@ -121,7 +126,7 @@ function rebuild() {
const arch = archSel.value === 'auto' ? undefined : archSel.value;
const lot = parseLot($('lot').value);
const shop = { id: 's' + seed, type, name: type.toUpperCase() + ' ' + seed, seed, storeys: 1, lot };
current = buildInterior(shop, THREE, { archetype: arch, useGLB, manifest: useGLB ? glbManifest : undefined });
current = buildInterior(shop, THREE, { archetype: arch, useGLB, manifest: useGLB ? glbManifest : undefined, stock: STOCK_REAL ? 'real' : undefined });
scene.add(current.group);
// place the player at the spawn
camera.position.set(current.spawn.x, 1.6, current.spawn.z);
@ -342,8 +347,8 @@ function openDigOn(bin) {
if (controls.isLocked) controls.unlock();
const p = new THREE.Vector3(); bin.getWorldPosition(p);
const key = Math.round(p.x * 100) + '_' + Math.round(p.z * 100); // stable per-bin key (deterministic position)
dig.open({ seed: binSeed(curSeed, key), count: 16, shopName: current.recipe.label,
getCash: () => 50, onBuy: () => true, onClose: () => {} });
dig.open({ seed: binSeed(curSeed, key), count: 16, shopName: current.recipe.label, shop: { type: current.dims.type },
stockAdapter: realAdapterFor(current.dims.type), getCash: () => 50, onBuy: () => true, onClose: () => {} });
}
if (DIG_ON) {
addEventListener('keydown', e => { if (e.code === 'KeyE' && current && (!dig || !dig.active)) { const b = binUnderAim(); if (b) openDigOn(b); } });

View File

@ -172,8 +172,16 @@ export function createDig(THREE, renderer) {
let z = -0.05;
list.forEach((o, i) => {
const grp = new THREE.Group(); grp.position.set(0, 0, z);
const face = new THREE.MeshStandardMaterial({ map: sleeveTexture(o), roughness: 0.75 }); oMat.add(face);
const mesh = new THREE.Mesh(sleeveGeo, [sideMat, sideMat, sideMat, sideMat, face, sideMat]); // shared sleeve geo
let mesh;
if (o.real) { // ?stock=real: a cover PLANE sampling the pack atlas
const g = new THREE.PlaneGeometry(w, h); oGeo.add(g);
const [u0, v0, u1, v1] = o.pack.uvRect(o.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); a.needsUpdate = true;
mesh = new THREE.Mesh(g, o.pack.material(o.item)); // shared atlas material (not disposed here)
} else {
const face = new THREE.MeshStandardMaterial({ map: sleeveTexture(o), roughness: 0.75 }); oMat.add(face);
mesh = new THREE.Mesh(sleeveGeo, [sideMat, sideMat, sideMat, sideMat, face, sideMat]); // shared sleeve geo
}
mesh.position.y = h / 2;
grp.add(mesh); grp.rotation.x = BACK;
grp.rotation.z = (((i * 2654435761) >>> 0) % 100 - 50) / 1800; // each sleeve leans its own way (seeded by index)
@ -258,13 +266,25 @@ export function createDig(THREE, renderer) {
renderer.domElement.addEventListener('pointerup', onUp);
document.addEventListener('keydown', onKey, true);
// ?stock=real: seeded pick of real pack items (same bin seed → same crate contents).
function pickRealOffers(pack, seed, n) {
const rr = mulberry32(seed >>> 0), out = [];
for (let i = 0; i < n; i++) {
const it = pack.items[(rr() * pack.items.length) | 0];
out.push({ real: true, pack, item: it, a: it.artist, t: it.title, price: it.price,
s: (it.price_band || '').toUpperCase(), y: '', cond: '', rare: it.price_band === 'grail' });
}
return out;
}
// open({ seed, count, shopName, shop, stockAdapter, onClose, onBuy, getCash })
function open(opts = {}) {
active = true;
onCloseCb = opts.onClose || null; onBuyCb = opts.onBuy || null; getCashCb = opts.getCash || null;
const count = opts.count || 16;
// riffle items: the stockAdapter seam (real records later) or the seeded procedural sleeves now.
offers = (opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve')) || proceduralSleeves(opts.seed ?? 1, count);
// riffle items: real pack (?stock=real) via the stockAdapter seam, else seeded procedural sleeves.
const src = opts.stockAdapter && opts.stockAdapter(opts.shop, 'sleeve');
offers = (src && src.real) ? pickRealOffers(src.pack, opts.seed ?? 1, count) : proceduralSleeves(opts.seed ?? 1, count);
curLbl.textContent = (opts.shopName ? opts.shopName + ' — ' : '');
[hint, curLbl, outBtn].forEach(e => e.style.display = 'block');
buildStack(offers);

View File

@ -40,12 +40,16 @@ import { ARCHETYPE_KEYS, chooseArchetype, computeDims, buildShell } from './shel
import { layout } from './layout.js';
import { batchRoom } from './batch.js';
import { preloadManifest, upgradeRoom } from './glb.js';
import { getStockPack, makeStockAdapter } from './stockpack.js';
import { xmur3 } from '../core/prng.js';
export { SHOP_TYPES, ARCHETYPE_KEYS, mergeRegistry };
export { preloadManifest } from './glb.js'; // Lane F: preload once so GLB upgrades apply synchronously
export { preloadStockPack, getStockPack, makeStockAdapter } from './stockpack.js'; // ?stock=real
let glbManifest = null; // resolved manifest cache (once the depot answers)
const _warned = new Set();
function warnOnce(msg) { if (!_warned.has(msg)) { _warned.add(msg); console.warn('[interiors] ' + msg); } }
function normalizeShop(shop) {
const s = shop || {};
@ -77,8 +81,15 @@ export function buildInterior(shop, THREE, opts) {
const dims = computeDims(ctx, recipe, archetype, norm.lot, norm.storeys);
const shell = buildShell(ctx, { recipe, archetype, dims });
// fittings + wall decor + stock + walkable-path guarantee
const lay = layout(ctx, { recipe, dims, shell, adapter: opts.stockAdapter || null, shop: norm });
// fittings + wall decor + stock + walkable-path guarantee. ?stock=real feeds Lane E's GODVERSE pack
// through the stockAdapter seam (preload it first); missing pack → parody canvas (warn once).
let adapter = opts.stockAdapter || null;
if (!adapter && opts.stock === 'real') {
const pack = getStockPack(recipe.key);
if (pack) adapter = makeStockAdapter(pack);
else warnOnce(`stock=real: no pack for "${recipe.key}" (preload it) — using parody canvas`);
}
const lay = layout(ctx, { recipe, dims, shell, adapter, shop: norm });
// DRAW BATCHING (round 6, ≤350 draws/room law): merge the many same-material stock/fixture meshes
// inside each fitting group into a few merged geometries. Fitting groups (and their userData) are

View File

@ -15,6 +15,7 @@
// 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'];
@ -108,21 +109,32 @@ function faceTex(ctx, kind, opts, r) {
return pick(r, facePool(ctx, kind === 'magazine' ? 'card' : kind, r));
}
// Packed leaning sleeves in a bin/crate; front few wear covers (dig.js look).
// 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 pool = facePool(ctx, 'card', r);
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;
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; }
const sl = ctx.box(fw, h, 0.012, faceMat, slot.x + (r() - 0.5) * 0.02, slot.y + h / 2, slot.z + z, parent);
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;
sl.userData.isStock = true;
}
}

View File

@ -0,0 +1,76 @@
// 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';
const _texLoader = new THREE_NS.TextureLoader();
const _packs = new Map(); // type → Promise<pack|null>
const _resolved = new Map(); // type → pack|null (sync accessor for buildInterior)
// Preload a pack (fetch index + atlas textures). Cached. Call once before building with ?stock=real.
export function preloadStockPack(type, { base = 'assets/models/', THREE = THREE_NS } = {}) {
if (_packs.has(type)) return _packs.get(type);
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(type, pack); return pack; });
_packs.set(type, p);
return p;
}
// Synchronous accessor — the resolved pack if preloaded, else null (buildInterior uses this).
export function getStockPack(type) { return _resolved.get(type) || null; }
async function buildPack(type, idx, base, THREE) {
const names = idx.atlases || [];
const materials = {};
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: 0.72, map: tex || null });
}));
const first = names[0];
return {
type,
items: idx.items,
// shared material for an item's atlas (batching groups sleeves by this)
material(item) { return materials[item.atlas] || materials[first]; },
// 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]]; },
};
}
// The stockAdapter for ?stock=real. Returns the pack POOL for a sleeve slot; consumers (stock.binFan /
// dig.js) pick a seeded subset per bin. Non-sleeve kinds → null (fall through to procedural).
export function makeStockAdapter(pack) {
if (!pack || !pack.items || !pack.items.length) return null;
return (shop, slotKind) => (slotKind === 'sleeve' ? { real: true, pack } : 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;
}