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); }, + }; +}