PROCITY/docs/LANES/LANE_C_NOTES.md
m3ultra 9c7c65f57c Lane C round-9: draw-headroom note for D's browser rigs (F1 handoff)
Measured worst interior with 3 two-draw rig proxies at browse points:
68 draws (opshop/hall) in the ?stock=real path — browsers add ≤6, ample
headroom. Flags the one tight spot for D/F: pre-existing GLB-on opshop/
hall is 344/350, so keep rigs impostor-cheap if ever stacked on GLB
hero-props there; F's sweep-with-browsers confirms ≤350.

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

213 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
**Draw headroom for D's rigs** (I measured with 3 two-draw rig proxies standing at the points): worst
interior in the `?stock=real` path is **68 draws** (opshop/hall) — browsers add ≤6, tons of room. The one
tight spot is the *pre-existing* GLB-on worst (opshop/hall **344/350**, unrelated to browsers): if D's rigs
are ever on TOP of GLB hero-props in that room, keep them impostor-cheap (≤2 draws each, the keeper pattern)
and F's sweep-with-browsers should confirm ≤350. Everywhere else there's ample headroom.
### What C exposes on the room API (live once C1 commits)
```jsonc
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):
```js
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:
```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)
`?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:
```jsonc
// 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`](../shots/laneC/glb_v1_1_r5.jpg),
report in [`LANE_C_GLB_VALIDATION.md`](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