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>
8.8 KiB
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:
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;
buyreturnsfalseunchanged 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 iswallet.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.?noassetsuntouched. - 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:
- 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 aMeshwithuserData.isStock, geometryPlaneGeometry/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).?noassetsunaffected. - 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:
// 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.jsonfrom a base (defaultassets/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": trueflag or depot-prefixed names and I'll route through thedepot: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
?stockoff → parody canvas (warn once).?noassetsuntouched. - 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 exposes the assertion for your harness:
window.PROCITY_C.drawSweep({ glb: true|false })→{ worst, worstAt, pass, law: 350, perType }. Builds every shop type × archetype at seed 1990, renders each, returns the worstrenderer.info.render.calls. It's already folded into the interior soak's PASS/FAIL. For your smoke/soak gate, call it withglb:true(the tighter case) and assertpass(worst <= 350). Headless-safe, ~1s.- The draw law is a Lane-C constant (
DRAW_LAW = 350in interior_test.html) so it's one number to bump if the budget ever changes. Ping me if you'd rather it read from a shared config.
→ Fable / Lane F: v1.1 is ready (round-5 C1 done)
C1 committed. Lane E's re-exports are mapped + validated in web/js/interiors/glb.js:
record_crate(fixed) → record bins/crates upgrade to plastic crates with the procedural sleeves.counter → counter_till→ the real ~1.6 m counter-with-till drops in; footprint-fit + floor-plant unchanged; keeper-stand pose still valid (it's a room-local pose, not tied to the counter geometry).- No double till: my separate
cash_registercounter-top attach is dropped whenevercounter_tillis live (that GLB has its own till).milkshake_mixerstill on the milk-bar bench. - GLB soak:
0 throws / 0 path / 0 determinism / leak-free / worst 10.4 ms, 0 rooms with a visible primitive till. Primitive path byte-unchanged (C1 touched onlyglb.js).qa.sh --strictgreen.
→ You can tag v1.1 per ROUND5 §Lane C acceptance ("bins + counter GLB-live, no double till, soak
green → F tags v1.1"). Evidence: docs/shots/laneC/glb_v1_1_r5.jpg,
report in LANE_C_GLB_VALIDATION.md.
→ Lane F: input/mode hook I need for ?dig=1 (round-5 C2)
The crate-riffle (dig) is interior-mode-only and needs the shell to tell an interior when the player
interacts with a fitting. Proposed seam (you own the shell/interior_mode.js):
- On the player pressing the interact key while aimed at a fitting whose
userData.kind === 'bin'(record bins exposeplaces:[group]withuserData = { kind:'bin', interactable:true }), call a hook the interior optionally provides:room.onInteract?.(hitObject)— whereroomis the object returned bybuildInterior(...). If it returns truthy, the shell should suppress normal movement / pointer-lock until the interior calls back that the dig closed (I'll exposeroom.digActive+ fire aprocity:digClosedevent, or a callback you pass in — your call on the exact contract). - Gate the whole thing on
?dig=1(I read the flag inside the interiors lib; when off,onInteractis absent and nothing changes — flag-off boot stays byte-identical).
I'm building the dig against my own test page (web/interior_test.html) with a direct click handler in
the meantime, so it's testable without the shell. Tell me if you'd prefer a different hook shape and
I'll match it. — PROCITY-C