PROCITY/docs/LANES/LANE_C_NOTES.md
m3ultra c68bba8f32 Lane C round-9 C2: buy-anywhere — pull-and-buy on book spines + toy boxes
Book/toy shelves get pull-and-buy over the SAME wallet; records keep the
deep dig. New stockpack.buildBuyableShelf(): a real shelf is merged,
per-item-ADDRESSABLE meshes — one per atlas (all covers drawn from one
seeded atlas → one draw/shelf), tagged noBatch so batch.js leaves them
intact. Aim a spine/box → pull card (title/author-or-maker/$price/band
from the pack index) → BUY debits the wallet, adds to inventory, and
collapseBuyItem() zero-areas the item's quad in place (removed from the
shelf, no geometry churn). Parody stock → no buy mesh → no card. Fail-
soft per pack; atlasPool() keeps each shelf single-atlas (one draw).

Test page: E interacts (bin→dig, shelf→card) + a #shelfBuy pull card;
shelfBuySoak() buys one item per book/toy seed. Validated: raycast→card
→BUY end-to-end (quad collapses to centre, cash/inventory update),
buy-soak 6/6 collapseOk leak geo0/tex0, ?stock=real draws record 41/
book 58/toy 57 ≤350, drawSweep GLB-off 168/on 344 unchanged, placement
determinism 0 fails, qa --strict GREEN 5/5. Shot: browse_buy_r9.jpg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:56:21 +10:00

14 KiB
Raw Blame History

LANE C — cross-lane notes (PROCITY-C)

→ Lane D + Lane F: BROWSE-POINTS interface (round-9 C1 — LANDED, code live)

Status: shipped. room.browsePoints[] is live on the room API (commit 5ae635a, ref lane-c/round9-browse). The contract below is final — build against it. It is the seam the whole round hangs off. C owns the points + room fit; D owns occupancy truth; F owns the handoff.

F smoke (warn): enter a ?stock=real book/toy/record shop, assert room.browsePoints.length >= 1 and every point walkable+reachable (or just trust C's asserts and check .length + that D's rig count == min(occupancy,3)). Test page: the path overlay draws each point as a cyan post + facing nose.

What C exposes on the room API (live once C1 commits)

room.browsePoints: BrowsePoint[]     // 0..3 entries, DETERMINISTIC per shop.seed, stably ordered
BrowsePoint = {
  x, z,        // room-local floor position (metres). Origin = room centre, +Z = door/street side,
               //   Z = back — the SAME frame as room.spawn / room.counter.stand.
  ry,          // facing yaw, oriented to look AT the shelf. SAME convention as room.counter.stand.ry
               //   (rig-front = local Z, i.e. the value keepers.js already consumes). Drop your
               //   browser rig in with rotation.y = ry and it faces the goods.
  atKind,      // string — the fitting kind being browsed ('bin' | 'shelf' | 'rack' | 'table' | 'case' …).
               //   Purely informational (pick a browse clip by it if you like); not load-bearing.
  slotIndex,   // 0-based, == array position. Stable across rebuilds of the same seed.
}

Guarantees C makes about every point (drawSweep + a new browse-collision assert prove them):

  • Walkable: the cell is floor-free (occ==0) — never inside a fitting footprint, wall, door corridor reserve, or the counter-front service strip.
  • Reachable: connected to the spawn cell by the same 4-connected flood-fill that guards door→counter.
  • Spaced: points are ≥0.6 m apart from each other AND from the keeper stand, so rigs don't interpenetrate.
  • Deterministic: pure function of shop.seed; same seed → same points, same order.
  • Every archetype: cosy/gallery/wide/hall/pokey all yield ≥1 point when the room has ≥1 browsable floor fitting (a bare room — e.g. a tiny pokey with only a counter — may yield 0; treat 0 as "no browsers").

C adds no meshes and no draws for browse points — they are pure poses. The ≤350 law is unaffected by C1; the only draw cost is D's rigs, which ride the interior budget (see below).

The D → F → C handoff (who calls whom)

  1. D owns occupancy truth. Propose occupancyOf(shopId) → { count, occupants:[{ seed, enteredAt }] } (your shape — C doesn't consume it). Count is however many peds patronage says are inside, pre-cap.
  2. F wires it at interior build. On enter, F reads D's occupancy, then: const pts = room.browsePoints.slice(0, Math.min(occ.count, 3)); and asks D's fleet to stand one browser rig per pt at (pt.x, pt.z), rotation.y = pt.ry. Cap 3 is enforced by there being ≤3 points — F just clamps count. Identity: pair occupants[i].seed with pts[i] so the same ped is the same rig deterministically.
  3. Rig lifecycle is D's (KeeperManager-style fleet clone, the leak-proven keeper pattern): idle/browse pose, dispose on player exit, despawn on dwell-expiry. Browsers count against the interior ≤350 — C's soak will run WITH browsers injected at the points to prove keeper+stock+dig+browsers all compose.

Open question for A (via D): closing-time behaviour for occupants — A rules, D implements, C's points don't care (a closed shop simply isn't entered). No blocker on C.

If any field above doesn't fit D's rig spawner or F's wiring, flag it here before I finalize — but the shape mirrors counter.stand exactly, which both of you already consume, so it should slot straight in.

→ Lane F: buy-anywhere (round-9 C2 landed) — shelf pull-and-buy smoke (warn)

Book spines + toy boxes are now buyable over the same wallet (records keep the dig). A real shelf is a merged, per-item-addressable mesh: mesh.userData.buyMesh === true, mesh.userData.buyItems = [{item, center, vStart}], one draw per shelf (drawSweep with ?stock=real: record 41 · book 58 · toy 57, ≤350). Smoke recipe (headless — no raycast needed):

const room = buildInterior({ id:'book', type:'book', seed:1990 }, THREE, { stock:'real' });
let mesh=null; room.group.traverse(o=>{ if(!mesh && o.userData?.buyMesh && o.userData.buyItems.length) mesh=o; });
assert(mesh);                                       // real book/toy shop has ≥1 buy mesh
const it = mesh.userData.buyItems[0];               // {item:{title,artist,price,price_band}, center, vStart}
// buy path: wallet.buy({t:it.item.title, price:it.item.price||20}); collapseBuyItem(mesh, it);
// assert: geometry.position vert[it.vStart] collapsed to it.center (item removed) · wallet.count()++ · leak-free

Test page exposes shelfBuySoak() (buys one shelf item per book/toy seed, asserts collapse + leak 0/0) and shelfUnderAim()/showShelfCard()/buyShelfOffer() for the live raycast path (E key: bin→dig, shelf→card). Parody stock (no pack) has no buy mesh → aiming shows no card ("not for sale"). Fail-soft per pack.

→ 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 ~$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)

?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:

// 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 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 worst renderer.info.render.calls. It's already folded into the interior soak's PASS/FAIL. For your smoke/soak gate, call it with glb:true (the tighter case) and assert pass (worst <= 350). Headless-safe, ~1s.
  • The draw law is a Lane-C constant (DRAW_LAW = 350 in 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_register counter-top attach is dropped whenever counter_till is live (that GLB has its own till). milkshake_mixer still 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 only glb.js). qa.sh --strict green.

→ 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 expose places:[group] with userData = { kind:'bin', interactable:true }), call a hook the interior optionally provides: room.onInteract?.(hitObject) — where room is the object returned by buildInterior(...). If it returns truthy, the shell should suppress normal movement / pointer-lock until the interior calls back that the dig closed (I'll expose room.digActive + fire a procity:digClosed event, 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, onInteract is 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