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>
213 lines
15 KiB
Markdown
213 lines
15 KiB
Markdown
# 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 ~$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:
|
||
- 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
|