PROCITY/docs/LANES/LANE_C_NOTES.md
m3ultra 422f17263e Lane C R41 §41.4: the shops get their stock — and the measuring instrument was wrong
110 fittings placed across 12 shop types via new kit41.js (96-row catalogue, 67 ids in
pools) and ONE generic kitProp builder; recipes name pools, never asset ids. glb.js now
honours seeded per-fitting variants, multi-GLB booths, and a MEASURED autoYaw instead of a
110-row facing table.

THE INSTRUMENT WAS WRONG. drawSweep cleared every interior EXCEPT current, so every
historical interior number carried a phantom second room — 'toy', untouched this round,
'gained' 4 draws. R39/R40's celebrated 'worst 188, margin 162' was inflated. Corrected, and
with 110 new fittings placed: worst room in the game is 122 @ dept/auto, MARGIN 228.
Per type: record 61->78, pub 45->59, band_room 39->50, stall 71->82, book 51->61,
pawn 60->70, video 54->63, rsl 52->60, dept 116->122, opshop 102->105, milkbar 68->69,
toy 86->86 (+0, the control).

The pub finally has a bar (plywood_bar), banquettes, pokies, a stage flank. Milk bars get a
bain-marie and pie warmer. The pawn shop gets an 808 and an SP-1200 on the bench. Record
shops get a rigged DJ booth. Market stalls get 2-4 diggable bins each, closing R39's measured
44%-no-crate hole to 0/40.

WARDROBE, route R5: 257.5 KB total (246 KB atlas + 17 KB index), ONE fetch, one material,
one draw per fitting — 52 layers picked BY NAME, alpha-cropped, shelf-packed, 3.7% of source
bytes, lazy behind ?stock=real. LICENCE CALL MADE EXPLICITLY: AUDIT.md says no real brands
ship and the source library is prompted FROM real labels — the adidas jacket renders a
legible trefoil. Screened on a contact sheet, then relabelled in parody voice (Paddy's
Bootleg, Kaymart, Coogee Knits, Mombo, Stoosh).

FIVE DEFECTS FOUND AND FIXED, one long-standing: buyMesh has been INVISIBLE on every GLB
fitting since R9 (hiding every buyable item under ?stock=real) · DJ booths were
nondeterministic (parts attached in network order) · booth gear floated at 0.92 m · batch.js
dropped userData sub-tags on merge, so any post-batch flag read was already a no-op · the
path guarantee could empty a room (pub/wide/4242 returned a bar, a stage and nothing else) —
new restore pass, 129 pulled -> 101 restored over 480 rooms, path re-proved on every restore.

Gates: 288-room sweep x 4 arms all pass; soak 60 rooms avg 3.83 ms worst 10.3 ms, leaks 0;
960-room corpus 0 path-fails 0 carves; determinism byte-equal across fresh contexts
(sha256 c26b4848); ?noassets=1 60 rooms with 0 GLB / 0 manifest / 0 wardrobe / 0 stock
fetches. DJ control nodes recorded on fitting.controlNodes for a future driver (glTF drops
the limit constraints — a driver must clamp travel itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:11:07 +10:00

286 lines
19 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)
## Round 41 §41.4 — the 110 fittings are placed. Three hand-offs, one warning.
Full tables in `C-progress.md` § Round 41 (§41.4). Shots: `docs/shots/laneC/r41_*.png`.
**Worst room in the game is now 122 draws @ `dept`/auto (GLB on), law ≤350 — margin 228.**
### → LANE F: one line turns the wardrobe on in the game, and one number needs restating
**1. The wardrobe pack.** 52 named 1990s Australian garment layers, one 240 KB atlas, feeding the op-shop
clothes rails and shelves at **zero geometry cost**. It is a stock pack under its own base, so `index.html`
needs the same one line the test page has, beside the existing three preloads:
```js
if (STOCK_REAL) preloadStockPack('opshop', { base: 'assets/wardrobe/' });
```
and, where a shop's interior is built, `stockBase: 'assets/wardrobe/'` when `shop.type === 'opshop'`
(`interior_mode.js` already passes `stockAdapter`; the adapter must be
`makeStockAdapter(getStockPack('opshop', 'assets/wardrobe/'))`). **Nothing breaks without it** — no pack
means the procedural garment canvas, byte-identically, which is also what `?noassets` and every default
boot get. Cache identity is `(type, base)`, so it coexists with the town-wide packs.
**2. `drawSweep` numbers moved, and it is the instrument, not the rooms.** The sweep cleared every interior
from the scene *except* `current` — the live room the page is showing — so every per-type number carried a
constant offset from an unrelated second room. R39/R40 prints of this function (worst 188, "margin 162")
include that phantom. The sweep now clears all interiors and rebuilds at the end. Re-baseline against the
corrected values before asserting anything tighter than ≤350.
**3. New gate surface, if you want it:** `PROCITY_C.kitAudit(manifest)` compares all 96 catalogue rows in
`kit41.js` against the live `manifest.json` and asserts Lane E's three traps (no pool ships both halves of
a rotated pair; no pool ships an oversize id; every pool/booth id resolves). Currently **96/96 checked, 0
dimension errors, 0 missing, 0 pair errors, 0 oversize**. It caught a real 5 mm drift on its first run. It
is cheap, it is non-vacuous (it has already fired), and it is exactly the check that goes stale silently.
**4. `opts.noKit`** on `buildInterior` drops every R41 spec and reproduces the pre-R41 room exactly — the
control arm for any before/after the round needs. `room.pulls = { pulled, restored, lost }` reports what
the door→counter guarantee cost that room; `lost > 0` means a genuine blocker.
### → LANE D: the DJ control nodes exist, nothing drives them, and that is deliberate
R41 **places** the rigged gear and **records** the contract. There is no driver and `djBooth` ships no
animation hook, so a placed node can never be mistaken for a live one. When you build the driver:
| id | control nodes | live where |
|---|---|---|
| `deck_1200_rigged` | `Platter_SPIN` · `Arm_YAW` · `Fader_PITCH` · `Btn_STARTSTOP` | record shop, `BOOTHS.record_hero`, left deck |
| `mixer_ttm54i_rigged` | `Fader_CROSS` | record shop, `BOOTHS.record_hero`, centre |
| `mixer_pmc05pro_rigged` · `mixer_shdj1200_rigged` | `Fader_CROSS` | catalogued, not placed |
Reach them from the room without guessing: any fitting group with `userData.djBooth` carries
`userData.controlNodes = { <id>: [node…] }`, and the GLB instance beneath it is tagged
`userData.glbUpgrade` with `userData.kind === <id>`. Two rules from E that a driver must honour: drive by
the **bare node name** (cap/stem/rib are parented under it), and **glTF drops the source's limit
constraints — clamp the travel yourself.** Venue and pawn booths are statics by design.
**Your poses are unchanged and still hold in a furnished room.** browsePoints stayed at 3.00/room across
all 12 types; watchPoints measured after furnishing: pub **7.5 of cap 8**, band_room **8.0 of 8**, RSL
**11.4 of 12**. `counter.stand` is untouched. The venues' bar is now `plywood_bar` instead of
`counter_till` — same fitting, same stand pose, same service strip, different model.
### ⚠ WARNING TO ANY LANE THAT HIDES A PRIMITIVE OR MERGES A MESH
Two latent bugs this round found the hard way, both of the same shape — **a tag that something depends on,
destroyed by a later pass**:
- `glb.js` hid every child of an upgraded fitting that was not `isStock`. R9's buy-anywhere shelves are
tagged `{ noBatch, buyMesh }` and *not* `isStock`, so **`?stock=real` + GLB made every buyable item in
the room invisible** — live since R9 on `bookshelf`/`cubeShelf`/`clothesRack`, never seen because R9
measured GLB-off.
- `batch.js` rebuilt `userData` from scratch when it merged a material bucket, dropping sub-tags such as
`tillPrimitive`. Any hide that reads a per-mesh flag after batching was already a no-op.
If you add a per-mesh flag that a later stage reads, check it survives `batchRoom` **and** the GLB swap.
## → 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