diff --git a/C-progress.md b/C-progress.md
index fb64ef8..9ebc4a6 100644
--- a/C-progress.md
+++ b/C-progress.md
@@ -3,7 +3,49 @@
*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 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 ~$60–200 (`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`.
---
diff --git a/docs/LANES/LANE_C_NOTES.md b/docs/LANES/LANE_C_NOTES.md
index 08ff003..88ea9cb 100644
--- a/docs/LANES/LANE_C_NOTES.md
+++ b/docs/LANES/LANE_C_NOTES.md
@@ -1,5 +1,31 @@
# 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 ~$60–200 (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 4–6 atlases).
+
## → 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:
diff --git a/docs/shots/laneC/buyloop_r8.jpg b/docs/shots/laneC/buyloop_r8.jpg
new file mode 100644
index 0000000..d4fb2ca
Binary files /dev/null and b/docs/shots/laneC/buyloop_r8.jpg differ
diff --git a/web/interior_test.html b/web/interior_test.html
index 654e169..a30296a 100644
--- a/web/interior_test.html
+++ b/web/interior_test.html
@@ -71,12 +71,35 @@ import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { buildInterior, SHOP_TYPES, ARCHETYPE_KEYS, preloadStockPack, getStockPack, makeStockAdapter } from './js/interiors/interiors.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.
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);
+// ── 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 = `
INVENTORY (${inv.length})
` + (inv.length
+ ? inv.map(it => `${it.artist ? it.artist + ' — ' : ''}${it.title}$${it.price}
`).join('')
+ : 'empty — walk to a bin and press E, then BUY
');
+}
+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 ──────────────────────────────────────────────
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
@@ -347,21 +370,42 @@ 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, shop: { type: current.dims.type },
- stockAdapter: realAdapterFor(current.dims.type), getCash: () => 50, onBuy: () => true, onClose: () => {} });
+ const sold = boughtByBin[key] || 0; // the bin depletes as you buy from it
+ 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) {
addEventListener('keydown', e => { if (e.code === 'KeyE' && current && (!dig || !dig.active)) { const b = binUnderAim(); if (b) openDigOn(b); } });
- document.getElementById('hint').innerHTML += ' · E riffle a record bin';
+ document.getElementById('hint').innerHTML += ' · E riffle a record bin · I 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) {
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;
- 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);
- 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 ───────────────────────────────────────────────────────────────────────
@@ -375,7 +419,7 @@ function frame() {
// 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,
- 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();
frame();
diff --git a/web/js/interiors/stock.js b/web/js/interiors/stock.js
index b35b779..1b40a69 100644
--- a/web/js/interiors/stock.js
+++ b/web/js/interiors/stock.js
@@ -145,6 +145,9 @@ function shelfLine(ctx, parent, slot, opts, r) {
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++) {
@@ -163,11 +166,19 @@ function shelfLine(ctx, parent, slot, opts, r) {
if (kind === 'spine') { // books / VHS: thin spines edge-out
const step = (run - 0.04) / 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 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
- b.userData.isStock = true;
}
return;
}
@@ -191,11 +202,17 @@ function shelfLine(ctx, parent, slot, opts, r) {
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 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,
- slot.x - run / 2 + (i + 0.5) * step, slot.y + bh / 2, slot.z + depth / 2 - bd / 2 - 0.01, parent);
- mesh.userData.isStock = true;
+ 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;
+ }
}
}
diff --git a/web/js/interiors/stockpack.js b/web/js/interiors/stockpack.js
index c92afdf..e2c6563 100644
--- a/web/js/interiors/stockpack.js
+++ b/web/js/interiors/stockpack.js
@@ -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 /
-// dig.js) pick a seeded subset per bin. Non-sleeve kinds → null (fall through to procedural).
+// Each pack type feeds exactly one stock slot kind so a record shop's pack never lands on a bric-a-brac
+// 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) {
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
diff --git a/web/js/interiors/wallet.js b/web/js/interiors/wallet.js
new file mode 100644
index 0000000..8ea9cc7
--- /dev/null
+++ b/web/js/interiors/wallet.js
@@ -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 ~$60–200
+ 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); },
+ };
+}