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>
This commit is contained in:
m3ultra 2026-07-15 04:33:00 +10:00
parent f952f513d0
commit fbc8ffa1c8
7 changed files with 187 additions and 20 deletions

View File

@ -3,7 +3,49 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens *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.* into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-14 (round 7) · owner: PROCITY-C · reviewer: Fable Last updated: 2026-07-15 (round 8) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 8) — the town comes alive: buy loop v0 + book/toy real stock
Round-8 §Lane C, theme "the town comes alive". Two deliverables + a verify pass.
**C1 — buy loop v0** (new `web/js/interiors/wallet.js`). A shallow, **runtime-only** economy: seeded
starting cash, purchases, an in-memory bag. Reload resets; world generation is **untouched** (purchases
never enter the plan or the seeded room build — the wallet is a separate object the shell reads, never
writes back into gen).
- `createWallet(seed)``{ cash, start, canBuy, buy(offer), inventory, count, onChange }`. Start is
seeded ~$60200 (`mulberry32`). `buy` deducts + records on success, **returns false unchanged if broke**.
`onChange` lets F's street shell subscribe the same wallet to a cash chip.
- **Test page** (`interior_test.html`): a `#cashChip` HUD (`$163 · 🛍 N`, street + interior) + an
inventory panel toggled with **I**. The dig's pull card gets a **BUY IT** button (price + band); buying
pulls the sleeve into the bag, decrements cash, updates the chip, and **depletes that bin** (bought bins
offer fewer sleeves on re-riffle). Broke → the button no-ops (can't buy). `digSoak` now buys up to 2/visit.
- Offer shape is the dig offer (`{a,t,price,s}`) OR a pack item (`{title,artist,price,price_band}`) — one
`buy()` eats both.
**C2 — book spines + toy boxes wired to `?stock=real`.** E shipped the generalized packs
(`stock_book_*` 311 items / 4 atlases, `stock_toy_*` 273 items / 5 atlases, alongside `stock_record_*`
350/6). `stockpack.js` now keys each pack to its slot via `SLOT_FOR = {record:'sleeve', book:'spine',
toy:'box'}`; `stock.js shelfLine` consults the adapter and builds real cover planes for book spines
(bookshelves) and toy boxes (cube shelves) — **static shelf stock, no dig** for those, fail-soft per pack.
Every real mesh samples its atlas material with a baked per-item UV, so a whole shelf still batches to one
draw per atlas.
**C3 — validated** (all three packs live, `?stock=real`):
- **Draws ≤350** (the law): record **41**, book **42**, toy **51** worst over seeds {1990,7,42(,101)}.
- **Determinism**: same seed → identical draw calls + mesh count, all three types (0 fails).
- **Leak-free**: 0 geometry/texture delta over 20 build/dispose cycles; **buy-soak** 12 record-dig visits,
5 bought (wallet depletes to broke → exercises the can't-buy path), `leakGeo 0 / leakTex 0`; resting geo
back to baseline after close. Atlas textures shared/cached (not per-room); per-item geo ctx-tracked & disposed.
- **Reload resets** the wallet (fresh boot → seeded start again); **flag-off + `?noassets` untouched**.
- Shot: [buyloop_r8.jpg](docs/shots/laneC/buyloop_r8.jpg) — toy boxes, book spines, record dig → BUY card.
**→ F**: two new warn-level smokes (hooks in [LANE_C_NOTES.md](docs/LANES/LANE_C_NOTES.md)): (1) **buy loop**
`createWallet` deduct/broke/inventory is pure and headless-testable; (2) **book/toy packs** — enter book or
toy shop with `?stock=real`, assert `isStock` meshes on a shared atlas material (drawSweep ≤350). Buy UI is
test-page-local; the reusable piece for the street shell is `wallet.js` + `onChange`.
--- ---

View File

@ -1,5 +1,31 @@
# LANE C — cross-lane notes (PROCITY-C) # LANE C — cross-lane notes (PROCITY-C)
## → Lane F: buy loop v0 + book/toy packs — two warn-level smokes (round-8 C1/C2 landed)
**Buy loop** is a runtime-only wallet in `web/js/interiors/wallet.js` — pure, headless-testable, no DOM:
```js
import { createWallet } from './web/js/interiors/wallet.js';
const w = createWallet(1990);
const start = w.start(); // seeded ~$60200 (deterministic per seed)
w.buy({ title:'X', artist:'Y', price: 25 }); // true; cash -= 25; bag +1
w.buy({ price: start }); // false when it exceeds remaining cash (broke) — no state change
// assert: w.count() === 1 && w.cash() === start - 25 && w.canBuy(w.cash()+1) === false
```
- Deterministic start per seed; `buy` returns `false` unchanged when broke; `onChange(fn)` fires on every
buy (so F's street shell can bind ONE wallet to a cash chip across street + interiors).
- The buy **UI** (cash chip, inventory panel, dig BUY button) is **test-page-local** (`interior_test.html`).
The reusable economy is `wallet.js`; render it wherever. World gen is untouched — the wallet never writes
back into the plan or the seeded room build, so goldens/draw-counts are unaffected by purchases.
**Book/toy packs** now feed `?stock=real` too (E shipped `stock_book_*` / `stock_toy_*`). Smoke:
- Boot `?stock=real`, enter a **book** or **toy** shop, assert real stock present: same test as records —
`let n=0; room.group.traverse(o=>{ if(o.userData?.isStock && o.material?.map) n++ }); assert n>0`. Book
spines land on bookshelves, toy boxes on cube shelves (**static shelf stock, no dig** for those).
- **Fail-soft per pack**: if a pack's index/atlas is missing, that type falls back to parody canvas + one
`console.warn`; records/other types unaffected. `?noassets` untouched.
- **drawSweep still ≤350**: record 41, book 42, toy 51 (each atlas = one batched draw; packs are 46 atlases).
## → Lane F: `?stock=real` smoke for the strict harness (round-7 C1 landed) ## → 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: `?stock=real` is wired (record shops; book/toy fail-soft if E ships those packs). Smoke recipe:

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@ -71,12 +71,35 @@ import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } 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'; import { createDig, binSeed } from './js/interiors/dig.js';
import { createWallet } from './js/interiors/wallet.js';
// ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam. // ?stock=real — feed Lane E's GODVERSE record-sleeve pack through the stockAdapter seam.
const STOCK_REAL = new URLSearchParams(location.search).get('stock') === 'real'; 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 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); const realAdapterFor = (type) => (STOCK_REAL ? makeStockAdapter(getStockPack(type)) : null);
// ── buy loop v0: in-memory wallet + cash HUD + inventory (I). F wires the same wallet on the street. ──
const wallet = createWallet(1990);
const boughtByBin = {}; // bin key → count sold, so a re-dug bin depletes
const cashChip = document.createElement('div'); cashChip.id = 'cashChip';
const invPanel = document.createElement('div'); invPanel.id = 'invPanel';
const walletCss = document.createElement('style'); walletCss.textContent = `
#cashChip{position:fixed;top:12px;right:14px;z-index:70;background:rgba(24,20,14,.92);border:1px solid #6a5a38;border-radius:8px;padding:8px 13px;font:700 15px "Courier New",monospace;color:#3dff8b}
#invPanel{position:fixed;top:52px;right:14px;z-index:70;width:238px;max-height:62vh;overflow:auto;background:rgba(24,20,14,.95);border:1px solid #4a4030;border-radius:8px;padding:10px 12px;font:12px "Courier New",monospace;color:#d8d8e0;display:none}
#invPanel h4{margin:0 0 6px;color:#ffd75e}
#invPanel .row{display:flex;justify-content:space-between;gap:8px;padding:2px 0;border-bottom:1px solid #2a2418}`;
document.head.appendChild(walletCss); document.body.append(cashChip, invPanel);
function updateCash() { cashChip.textContent = `$${wallet.cash()} · 🛍 ${wallet.count()}`; }
function renderInv() {
const inv = wallet.inventory();
invPanel.innerHTML = `<h4>INVENTORY (${inv.length})</h4>` + (inv.length
? inv.map(it => `<div class="row"><span>${it.artist ? it.artist + ' — ' : ''}${it.title}</span><span>$${it.price}</span></div>`).join('')
: '<div style="color:#8a8a9a">empty — walk to a bin and press E, then BUY</div>');
}
wallet.onChange(() => { updateCash(); if (invPanel.style.display === 'block') renderInv(); });
updateCash();
addEventListener('keydown', e => { if (e.code === 'KeyI' && !e.target.matches('input,select')) { const on = invPanel.style.display === 'block'; invPanel.style.display = on ? 'none' : 'block'; if (!on) renderInv(); } });
// ── renderer / scene / camera ────────────────────────────────────────────── // ── renderer / scene / camera ──────────────────────────────────────────────
const app = document.getElementById('app'); const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true }); const renderer = new THREE.WebGLRenderer({ antialias: true });
@ -347,21 +370,42 @@ function openDigOn(bin) {
if (controls.isLocked) controls.unlock(); if (controls.isLocked) controls.unlock();
const p = new THREE.Vector3(); bin.getWorldPosition(p); 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) 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, shop: { type: current.dims.type }, const sold = boughtByBin[key] || 0; // the bin depletes as you buy from it
stockAdapter: realAdapterFor(current.dims.type), getCash: () => 50, onBuy: () => true, onClose: () => {} }); dig.open({ seed: binSeed(curSeed, key), count: Math.max(3, 16 - sold), shopName: current.recipe.label, shop: { type: current.dims.type },
stockAdapter: realAdapterFor(current.dims.type),
getCash: wallet.cash,
onBuy: (o) => { const ok = wallet.buy(o); if (ok) boughtByBin[key] = (boughtByBin[key] || 0) + 1; return ok; },
onClose: () => {} });
} }
if (DIG_ON) { if (DIG_ON) {
addEventListener('keydown', e => { if (e.code === 'KeyE' && current && (!dig || !dig.active)) { const b = binUnderAim(); if (b) openDigOn(b); } }); addEventListener('keydown', e => { if (e.code === 'KeyE' && current && (!dig || !dig.active)) { const b = binUnderAim(); if (b) openDigOn(b); } });
document.getElementById('hint').innerHTML += ' · <b>E</b> riffle a record bin'; document.getElementById('hint').innerHTML += ' · <b>E</b> riffle a record bin · <b>I</b> bag';
} }
// enter+leave a dig per record room, assert leak-free (round-5 acceptance) // enter+leave a dig per record room, BUYING 2 items per visit, assert leak-free (round-8 buy loop)
async function digSoak(N = 15) { async function digSoak(N = 15) {
if (!dig) dig = createDig(THREE, renderer); if (!dig) dig = createDig(THREE, renderer);
dig.open({ seed: 1, count: 16 }); renderer.render(dig.scene, dig.camera); dig.close(); renderer.render(scene, camera); const richWallet = createWallet(7); // fresh rich-ish wallet so buys land during the soak
let topUps = 0;
const buyer = (o) => { if (o.price > richWallet.cash()) { topUps++; return false; } return richWallet.buy(o); };
dig.open({ seed: 1, count: 16, getCash: richWallet.cash, onBuy: buyer }); renderer.render(dig.scene, dig.camera); dig.close(); renderer.render(scene, camera);
const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures; const bG = renderer.info.memory.geometries, bT = renderer.info.memory.textures;
for (let i = 0; i < N; i++) { dig.open({ seed: (i * 7919 + 1) >>> 0, count: 10 + (i % 9) }); renderer.render(dig.scene, dig.camera); dig.close(); } const cv = renderer.domElement;
let bought = 0;
for (let i = 0; i < N; i++) {
dig.open({ seed: (i * 7919 + 1) >>> 0, count: 10 + (i % 9), getCash: richWallet.cash, onBuy: buyer });
for (let k = 0; k < 8; k++) dig.update(0.03);
for (let b = 0; b < 2; b++) { // buy 2 per visit via the real pull+BUY path
cv.dispatchEvent(new PointerEvent('pointerdown', { clientX: innerWidth / 2, clientY: innerHeight / 2, bubbles: true }));
cv.dispatchEvent(new PointerEvent('pointerup', { clientX: innerWidth / 2, clientY: innerHeight / 2, bubbles: true }));
for (let k = 0; k < 4; k++) dig.update(0.03);
const btn = document.querySelector('.pcdg-panel button');
if (btn && !btn.disabled) { const before = richWallet.count(); btn.click(); if (richWallet.count() > before) bought++; }
for (let k = 0; k < 4; k++) dig.update(0.03);
}
renderer.render(dig.scene, dig.camera); dig.close();
}
renderer.render(scene, camera); renderer.render(scene, camera);
return { rooms: N, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT }; return { rooms: N, bought, leakGeo: renderer.info.memory.geometries - bG, leakTex: renderer.info.memory.textures - bT };
} }
// ── loop ─────────────────────────────────────────────────────────────────────── // ── loop ───────────────────────────────────────────────────────────────────────
@ -375,7 +419,7 @@ function frame() {
// expose for headless verification (screenshot harness, workflow checks) // expose for headless verification (screenshot harness, workflow checks)
window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer, window.PROCITY_C = { buildInterior, THREE, SHOP_TYPES, ARCHETYPE_KEYS, soak, drawSweep, DRAW_LAW, rebuild, get current() { return current; }, scene, camera, renderer,
DIG_ON, digSoak, openDigOn, binUnderAim, get dig() { return dig; } }; DIG_ON, STOCK_REAL, digSoak, openDigOn, binUnderAim, get dig() { return dig; }, wallet, preloadStockPack, getStockPack, makeStockAdapter };
rebuild(); rebuild();
frame(); frame();

View File

@ -145,6 +145,9 @@ function shelfLine(ctx, parent, slot, opts, r) {
const scatter = slot.scatter; const scatter = slot.scatter;
const n = Math.max(1, slot.count || 4); const n = Math.max(1, slot.count || 4);
const pool = kind === 'spine' ? facePool(ctx, 'spine', r) : facePool(ctx, kind === 'snack' ? 'snack' : 'card', r); 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 if (scatter) { // tables / pegboards: random small goods
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@ -163,11 +166,19 @@ function shelfLine(ctx, parent, slot, opts, r) {
if (kind === 'spine') { // books / VHS: thin spines edge-out if (kind === 'spine') { // books / VHS: thin spines edge-out
const step = (run - 0.04) / n; const step = (run - 0.04) / n;
for (let i = 0; i < n; i++) { 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 bh = clear * (0.8 + r() * 0.2); const bh = clear * (0.8 + r() * 0.2);
const b = ctx.box(step * 0.86, bh, depth, m, slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z, parent); 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 if (slot.lean && i > n - 3) b.rotation.z = 0.18; // a couple flopped over
b.userData.isStock = true;
} }
return; return;
} }
@ -191,11 +202,17 @@ function shelfLine(ctx, parent, slot, opts, r) {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28); const bh = clear * (kind === 'snack' ? 0.6 : 0.62 + r() * 0.28);
const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26); const bd = Math.min(depth, kind === 'snack' ? 0.1 : 0.26);
const m = ctx.mat('#ffffff', 0.8); const x = slot.x - run / 2 + (i + 0.5) * step;
m.map = faceTex(ctx, kind, opts, r); m.color.set('#ffffff'); m.needsUpdate = true; if (pack) { // real toy box: cover plane at the carton front
const mesh = ctx.box(bw * 0.88, bh, bd, m, const b = realSleeveMesh(ctx, pack, pick(r, pack.items), bw * 0.88, bh);
slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent); b.position.set(x, slot.y + bh / 2, slot.z + depth / 2 - 0.01);
mesh.userData.isStock = true; 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;
}
} }
} }

View File

@ -54,11 +54,16 @@ async function buildPack(type, idx, base, THREE) {
}; };
} }
// The stockAdapter for ?stock=real. Returns the pack POOL for a sleeve slot; consumers (stock.binFan / // Each pack type feeds exactly one stock slot kind so a record shop's pack never lands on a bric-a-brac
// dig.js) pick a seeded subset per bin. Non-sleeve kinds → null (fall through to procedural). // shelf: record→sleeve (bins), book→spine (bookshelves), toy→box (cube shelves).
const SLOT_FOR = { record: 'sleeve', book: 'spine', toy: 'box' };
// The stockAdapter for ?stock=real. Returns the pack POOL for its matching slot kind; consumers
// (stock.binFan / stock.shelfLine / dig.js) pick a seeded subset. Other kinds → null → procedural.
export function makeStockAdapter(pack) { export function makeStockAdapter(pack) {
if (!pack || !pack.items || !pack.items.length) return null; if (!pack || !pack.items || !pack.items.length) return null;
return (shop, slotKind) => (slotKind === 'sleeve' ? { real: true, pack } : null); const want = SLOT_FOR[pack.type] || 'sleeve';
return (shop, slotKind) => (slotKind === want ? { real: true, pack } : null);
} }
// Build a leaning cover PLANE for one pack item, UV-remapped to its atlas rect, tagged isStock so // Build a leaning cover PLANE for one pack item, UV-remapped to its atlas rect, tagged isStock so

View File

@ -0,0 +1,33 @@
// PROCITY Lane C — buy loop v0 (round 8). In-memory wallet: seeded starting cash, purchases, inventory.
// RUNTIME state only — reload resets, world generation is untouched (purchases never enter the plan or
// the seeded room build). One wallet is shared across dig sessions + shops so F's shell can render the
// same cash chip on the street. Economy is deliberately shallow (no persistence, no backend) — v0.
import { mulberry32 } from '../core/prng.js';
export function createWallet(seed = 1) {
const r = mulberry32(seed >>> 0);
const start = 60 + ((r() * 140) | 0); // seeded starting cash ~$60200
let cash = start;
const inv = [];
const listeners = new Set();
const notify = () => listeners.forEach(fn => { try { fn(); } catch (e) {} });
return {
cash: () => cash,
start: () => start,
canBuy: (price) => (price || 0) <= cash,
// buy(offer) — offer is a dig offer ({a:artist, t:title, price, ...}) or a pack item ({title,artist,price}).
// Returns false (no state change) if broke. Deducts + records on success.
buy(o) {
const price = (o && o.price) || 0;
if (price > cash) return false;
cash -= price;
inv.push({ title: o.t || o.title || '?', artist: o.a || o.artist || '', price, band: (o.s || o.price_band || '') });
notify();
return true;
},
inventory: () => inv.slice(),
count: () => inv.length,
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
};
}