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

19 KiB
Raw Blame History

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:

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)

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