PROCITY/C-progress.md
m3ultra 05e020e3b8 Lane C: round-12 pub venue — archetype + watchPoints + audio.gigKey (?gigs=1)
v3.0-alpha band room. New pub recipe (venue:true): stage + PA + amps at the back wall (new stage/paSpeaker/ampStack fittings, GLB slots stage/pa_speaker/amp for E), bar counter (keeper), sticky carpet, blocked back door = green room.

buildInterior returns for venues: room.watchPoints[] (6-8 seeded audience poses {x,z,ry,dance} facing the stage, dance ~1/3, counter.stand ry convention); room.stage {x,z,w,d,deckY,frontZ,bandPoses[3]} for Lane D's band; room.audio.gigKey ('gig-'+genreKey) set when Lane F passes opts.gig.

Flags-off byte-identical: venue code runs only for recipe.venue on its own seed sub-streams; non-pub rooms have watchPoints:[], stage:null, unchanged placement/audio.

Verified: 40-seed pub sweep (0 throws/carves/path-fails, 8 watchPoints, dance 0.38) + 60-room mixed soak (0 det/path fails, leak geo0/tex0, worst 19ms). Non-pub unchanged. Interface for D/F: docs/LANES/LANE_C_PUB.md. scaffold + manifest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:59:48 +10:00

34 KiB
Raw Blame History

LANE C — INTERIORS · progress (PROCITY-C)

Status: v1 complete & verified. Standalone interiors library + test page. Every shop door opens into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.

Last updated: 2026-07-15 (round 12) · owner: PROCITY-C · reviewer: Fable


Update 2026-07-15 (round 12, v3.0-alpha) — the pub venue: archetype + watchPoints

R12 §Lane C (venues & gigs, behind ?gigs=1). Interface published in docs/LANES/LANE_C_PUB.md — the "publish interface first" deliverable D/F build on.

  • pub archetype (theme.js, venue:true): low stage + PA cabinets + amps at the back wall (new stage/paSpeaker/ampStack fittings, GLB slots stage/pa_speaker/amp for E), bar counter (corner — the barkeep is just a keeper), sticky-carpet floor, the blocked back doorway = the green room.
  • room.watchPoints[] — 68 seeded audience floor poses {x,z,ry,dance,slotIndex} facing the stage centre (counter.stand ry convention). dance seeded ~⅓ true (John: "a bit of both"). Walkable, reachable, clear of keeper/browse/stage. room.stage = {x,z,w,d,deckY,frontZ,bandPoses[3]} so D drops the band on the deck.
  • room.audio.gigKey — F passes opts.gig (gig state); a gig-on pub returns gigKey='gig-'+genreKey (gig-pubrock for the alpha), else a quiet-night interior. F prefers gigKey over musicKey. (→ E: name the live bed gig-pubrock.)
  • Flags-off byte-identical: venue code runs only for recipe.venue, on its own stage/watch sub- streams — non-pub rooms have watchPoints:[], stage:null, unchanged audio + placement.
  • Verified: 40-seed pub sweep — 0 throws · 0 carves (PA areaFree-guarded off the counter strip) · 0 path-fails · 8 watchPoints each · dance 0.38. 60-room mixed soak (incl. pub) — 0 throws/det/path fails, leak geo0/tex0, worst 19 ms. Non-pub rooms confirmed unchanged.

Update 2026-07-15 (round 11, audio) — room.audio contract

R11 §Lane C ("one contract answer"). Confirmed Fable's proposal: buildInterior now returns room.audio = { musicKey, toneKey }, seeded per shop — F's interior_mode plays it, B's audio.js resolves the keys. Full contract + F wiring recipe: docs/LANES/LANE_C_AUDIO.md.

  • toneKey → a manifest.audio.ambience interior room-tone (always set); musicKey → a manifest.audio.music bed, or null (no music). Keys, not files — buildInterior stays synchronous and asset-free; silence on missing/?mute=1/?noassets=1 (house audio law).
  • Maps mirror the manifest's per-type types arrays (one source of truth): record/milkbar/video/dept/ arcade always play their bed; opshop/book/toy/pawn/stall are room-tone-only, except a seeded ~1-in-3 keeps a general radio on (the general-tagged milk-bar bed).
  • Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches manifest types membership; deterministic per shop. qa.sh --strict green.
  • Optional visual-source prop (userData.audioEmitter) deferred to v2.2 (in the note) — out of round scope.

Update 2026-07-15 (round 9) — ship v2.0: browse points (C→D→F seam) + buy-anywhere

Round-9 §Lane C. The round hangs off C1 (two lanes build on it), so it shipped first as a handshake + working code; C2 widens buying beyond the dig.

C1 — browse points (the round's one cross-lane seam). layout.js placeBrowsePoints() exposes room.browsePoints[] — 13 deterministic floor poses in front of browsable fittings, facing the goods, where Lane D stands browser rigs (F wires occupancy → points). Same coord frame + ry convention as counter.stand, so D's fleet clones drop straight in. Pure data — no meshes, no draws; the ≤350 law is untouched by C1. Guarantees (all asserted headless): walkable (occ-free, not reserved), reachable from spawn (the door→counter flood-fill), ≥0.6 m apart + clear of the keeper stand, deterministic per shop.seed, ≥1 point in every archetype that has a browsable fitting. A rich candidate ring (4 sides × 2 gaps + diagonals) + a nearest-free-cell fallback covers tight/mismatched rooms. Interface published in LANE_C_NOTES.md before code (decision #1 half-day handshake) → committed first so D/F unblock. Validated: 180 type×archetype×seed combos → 0 empties (177×3 pts, 3×1 via fallback), determinism 0 fails, min-sep 0.7 m, 0 keeper clashes. Test page: cyan browse-post markers on the path overlay + a HUD count.

C2 — buy-anywhere (decision #2). Book spines + toy boxes get pull-and-buy over the same wallet; records keep the deep dig. New stockpack.buildBuyableShelf() builds each real shelf as merged, per-item-addressable meshes — one mesh per atlas (all covers on a shelf drawn from a single seeded atlas → one draw/shelf), tagged noBatch so batch.js leaves them intact. Aiming a spine/box raises a pull card (title / author-or-maker / $price / band from the pack index); BUY debits the wallet, adds to inventory, and collapseBuyItem() zero-areas that item's quad in place (removed from the shelf, one draw, no geometry churn). Parody stock has no buy mesh → no card ("not for sale"). Fail-soft per pack. Validated: raycast→card→BUY works end-to-end (item collapses to its centre, cash/inventory update, card closes); buy-soak 6/6 bought, collapseOk, leak geo 0 / tex 0; draws with ?stock=real record 41 · book 58 · toy 57 (≤350); full drawSweep unchanged (GLB-off 168 · GLB-on 344, both pass); placement determinism 0 fails. Shot: browse_buy_r9.jpg.

→ D: consume room.browsePoints (contract in LANE_C_NOTES) — stand rigs at the first min(occupancy, 3) points. → F: two new warn smokes (hooks in LANE_C_NOTES): browse-points present + valid, and shelf-buy (aim → card → wallet debit → collapse). C's soak covers keeper+stock+dig+buy together.


Update 2026-07-15 (round 8) — the town comes alive: buy loop v0 + book/toy real stock

Round-8 §Lane C, theme "the town comes alive". Two deliverables + a verify pass.

C1 — buy loop v0 (new web/js/interiors/wallet.js). A shallow, runtime-only economy: seeded starting cash, purchases, an in-memory bag. Reload resets; world generation is untouched (purchases never enter the plan or the seeded room build — the wallet is a separate object the shell reads, never writes back into gen).

  • createWallet(seed){ cash, start, canBuy, buy(offer), inventory, count, onChange }. Start is seeded ~$60200 (mulberry32). buy deducts + records on success, returns false unchanged if broke. onChange lets F's street shell subscribe the same wallet to a cash chip.
  • Test page (interior_test.html): a #cashChip HUD ($163 · 🛍 N, street + interior) + an inventory panel toggled with I. The dig's pull card gets a BUY IT button (price + band); buying pulls the sleeve into the bag, decrements cash, updates the chip, and depletes that bin (bought bins offer fewer sleeves on re-riffle). Broke → the button no-ops (can't buy). digSoak now buys up to 2/visit.
  • Offer shape is the dig offer ({a,t,price,s}) OR a pack item ({title,artist,price,price_band}) — one buy() eats both.

C2 — book spines + toy boxes wired to ?stock=real. E shipped the generalized packs (stock_book_* 311 items / 4 atlases, stock_toy_* 273 items / 5 atlases, alongside stock_record_* 350/6). stockpack.js now keys each pack to its slot via SLOT_FOR = {record:'sleeve', book:'spine', toy:'box'}; stock.js shelfLine consults the adapter and builds real cover planes for book spines (bookshelves) and toy boxes (cube shelves) — static shelf stock, no dig for those, fail-soft per pack. Every real mesh samples its atlas material with a baked per-item UV, so a whole shelf still batches to one draw per atlas.

C3 — validated (all three packs live, ?stock=real):

  • Draws ≤350 (the law): record 41, book 42, toy 51 worst over seeds {1990,7,42(,101)}.
  • Determinism: same seed → identical draw calls + mesh count, all three types (0 fails).
  • Leak-free: 0 geometry/texture delta over 20 build/dispose cycles; buy-soak 12 record-dig visits, 5 bought (wallet depletes to broke → exercises the can't-buy path), leakGeo 0 / leakTex 0; resting geo back to baseline after close. Atlas textures shared/cached (not per-room); per-item geo ctx-tracked & disposed.
  • Reload resets the wallet (fresh boot → seeded start again); flag-off + ?noassets untouched.
  • Shot: buyloop_r8.jpg — toy boxes, book spines, record dig → BUY card.

→ F: two new warn-level smokes (hooks in LANE_C_NOTES.md): (1) buy loopcreateWallet deduct/broke/inventory is pure and headless-testable; (2) book/toy packs — enter book or toy shop with ?stock=real, assert isStock meshes on a shared atlas material (drawSweep ≤350). Buy UI is test-page-local; the reusable piece for the street shell is wallet.js + onChange.


Update 2026-07-14 (round 7) — ?stock=real: real GODVERSE sleeves in the crates

Round-7 §Lane C: the stockAdapter seam built in v1 finally eats. Lane E's GODVERSE stock pack (parody- transformed Discogs metadata + cover atlas) feeds record-shop bins + the dig riffle behind ?stock=real.

  • Contract handshake (task 1, unblocked E): confirmed E's index schema as-is in LANE_C_NOTES.md{ items:[{id,title,artist,price,price_band,atlas,uv}] }, UV origin top-left (C flips V for WebGL), fail-soft, per-type packs.
  • New stockpack.js: preloadStockPack/getStockPack (cached) + makeStockAdapter. Atlas texture + material are shared/cached (like depot GLBs); sleeves are cover PLANEs sampling the atlas at each item's baked UV → all real sleeves in a room batch to one draw (record: 59 draws).
  • Consumers: stock.js binFan builds real cover planes (else parody canvas); dig.js riffles real covers and the pull card shows title / artist / $price / band; interiors.js wires opts.stock='real' through the seam (warn-once + parody fallback if the pack is absent); test page reads ?stock=real.
  • Validated (sample 24-pack): determinism 0 fails/30, leak-free rooms AND real dig (0/0 over 25 cycles — atlas shared, sleeve geo disposed), draws ≤139 all rooms (record 59), fail-soft on no-pack types (opshop ok → parody), ?dig=1&stock=real leak-free. Flag-off + ?noassets untouched (procedural path byte-identical).
  • Built against E's staged sample (real index format, synthetic covers); real Discogs covers drop in unchanged when E gets the DB (external — John/Fable). Shot: stock_real_r7.jpg.

→ F: new smoke for your strict harness — ?stock=real enter record shop + open dig → assert real (non-parody) sleeves; the record bin's real sleeves are cover planes with userData.isStock on a shared atlas material (drawSweep stays ≤350). Hook + adapter in LANE_C_NOTES.


Update 2026-07-14 (round 6) — C1 interior draw batching (≤350 draws/room is law)

Round-6 §Lane C task 1 (the review debt: book room was 1,245 draws GLB-on). Diagnosed the two drivers — (a) multi-material stock boxes cost one draw PER face (6×), (b) one mesh per stock item — then batched (new web/js/interiors/batch.js):

  • stock.js: boxes are now single-material; every stock mesh tagged userData.isStock.
  • batch.js (batchRoom, called from interiors.js before the GLB upgrade): two-tier merge — room-level merge of all isStock across every fitting (grouped by material, world transforms baked)
    • per-fitting merge of fixture frames (kept in-group so bins stay raycast targets and the GLB upgrade can hide the fixture). mergeGeometries from vendored BufferGeometryUtils.
  • glb.js: frame-hide is now by isStock tag (not frame-count), so batched fixtures hide and the room-level stock rides on the GLB.

Result (drawSweep, all 9 types × 6 archetypes, seed 1990):

  • GLB-off worst 152 (opshop/hall), GLB-on worst 313 (opshop/hall) — book 1,245 → 189. ≤350 ✓.
  • Determinism 0 fails / 810 builds · leak-free · dig still riffles batched bins (bins keep frame as hit target; dig regenerates its own sleeves) · worst warm build 38ms (<50) · visually identical.
  • Sweep now ASSERTS ≤350 (drawSweep() in the test page, folded into the soak; window.PROCITY_C. drawSweep for F's harness — see LANE_C_NOTES for the hook).

C2 — map the 3 orphaned fittings (published R5, unconsumed). In glb.js: glassCase→glass_case, magazineRack→magazine_rack, and a NEW crateStack primitive (stack of crates) → crate_stack. Recipe placements: crateStack in record + opshop (back-room dressing), glassCase added to milkbar, magazineRack added to pawn (+ moved milkbar's magazineRack window→wall to dodge the door-counter). Also fixed placeNearCounter — it picked one random side and gave up, so counter-zone fittings (the glass case) rarely landed; now it tries both sides + offsets, then falls back to a free aisle spot. All 3 load + render planted/scaled/facing right (yaw 0). Appearance: glassCase 12/12 (toy/pawn/milkbar), magazineRack 12/12 (milkbar/pawn), crateStack ~11/12 (record/opshop). Contact sheet: glb_orphans_r6.jpg. drawSweep still ≤350 with them on (worst 177 GLB-on). C3: dig soak re-run after batching + new fittings — riffle works on batched bins, leak-free.

Round 6 complete for Lane C: C1 (batching, 35981ad) + C2/C3 (orphans + dig re-verify) committed. Draw law satisfied (≤313 was the tightest; ≤177 after C2), determinism/leak/dig all green, qa strict green.


Update 2026-07-14 (round 5) — C1 v1.1 re-map (record_crate + counter_till) · F can tag v1.1

Round-5 §Lane C task 1 (the v1.1 gate). Lane E's re-exports mapped + validated in 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). Keeper-stand pose unaffected.
  • Double-till resolved: cash_register attach dropped when counter_till is live (it has its own till); falls back on the primitive; milkshake_mixer stays on the milk-bar bench (lifted to the GLB height).
  • Sweep + soak (GLB on): 0 throws / 0 path / 0 det / leak-free / worst 10.4ms; 0 double tills. Primitive path unchanged (glb.js-only edit). Contact sheet: glb_v1_1_r5.jpg.
  • Report + decision detail: LANE_C_GLB_VALIDATION.md; F note in LANE_C_NOTES.md. → F: C1 committed, v1.1 can be tagged.

C2 — ?dig=1 crate-riffle (v2, behind flag). Ported 90sDJsim dig.jsweb/js/interiors/dig.js: walk to a record bin, press E, riffle its sleeves (scroll/drag), click the front to pull, BUY IT (STUBBED — toast + remove; getCash constant; real economy is round 6+). Riffle items are seeded per bin via the stockAdapter(shop,'sleeve') seam (procedural fallback now — parody 90s-AU sleeves). Own scene + camera, procedural fwip/thunk audio, leak-free (per-open resources freed on close → dig soak 20× leakGeo 0 / leakTex 0). Flag-off (?dig=0) is byte-identicalbuildInterior is untouched; the dig is wired only in the test page (F owns the shell seam — hook documented in LANE_C_NOTES §"input/mode hook"). Shot: dig_riffle_r5.jpg.

C3 — interior re-measure after Lane E's bake (c522464). E baked the 6 heavy hero props to ≤8k tris. Re-measured every interior (cache-busted to bypass the dev browser's stale HTTP cache): record 43.6k (the C3 target, ~273k pre-bake), milkbar 38.9k (was the flagged 468k → over-budget flag CLOSED), all rooms ≤107k tris. book is the draw-call outlier (1,245 draws from ~9 bookshelf GLBs + primitive spines) — tris fine, interiors render one-at-a-time so gate 3 unaffected; instancing spines is the future lever. Full table in [LANE_C_GLB_VALIDATION.md](docs/LANES/LANE_C_GLB_VALIDATION.md §C3).

Round 5 complete for Lane C: C1 (v1.1 re-map) + C2 (?dig=1) + C3 (re-measure) all committed.


Update 2026-07-14 (round 4) — hero props into rooms

Round-4 task (ROUND4_INSTRUCTIONS §Lane C): map the 4 new hero props + cash_register (manifest now 14 fittings), sweep/soak with them on, screenshot each, delete the stale _local_glb/, measure a hero room. All done. Full report for Lane E: LANE_C_GLB_VALIDATION.md; contact sheet docs/shots/laneC/glb_heroprops_r4.jpg.

  • New mappings (glb.js): arcade_cabinet→new arcadeCabinet primitive (video focal prop), listening_boothlisteningCorner (record), drinks_fridgefridge (milkbar). All load, glb_law compliant, yaw 0 (no correction). A new counter-top attach (attachCounterTop) puts cash_register on every counter's till slot (hides the primitive till) and milkshake_mixer on the milk-bar benchtop — the counter primitive + keeper stand are untouched.
  • Focal-prop reliability fix: the door-counter path loop was pulling priority-4 focal props (arcade appeared in only 5/24 video rooms). Raised the 3 focal props to priority 6 + placed-first → 24/24 appearance, still 0 carves / 0 path-fails.
  • Validation: primitive sweep 810 builds {throws 0, pathFail 0, detFail 0}; GLB-on soak {throws 0, path 0, det 0, leakGeo 0, leakTex 0, worst 7.3ms}; all 5 new props confirmed in-room.
  • Room tris measured (task 4): milk-bar GLB-on ≈ 468k tris (over budget) driven by drinks_fridge (77k) + milkshake_mixer (73k) — reported to Lane E as the #1 bake-pass target (not fixed, per integrator decision #2; interiors render one-at-a-time so gate 3 / the street budget is unaffected).
  • Cleanup: deleted web/assets/_local_glb/ (R3 validation copy); test page now loads GLBs via the live depot: path (?localdepot=1 still works for offline). localBase hook kept in glb.js.
  • Standby (C task 5): when Lane E re-exports record_crate (broken) + a ~2 m counter-with-till, I re-map both. Not landed at commit time.

Update 2026-07-14 (round 3) — GLB hero-prop visual validation

Round-3 task (ROUND3_INSTRUCTIONS §Lane C): flip useGLB on and validate Lane E's fitting GLBs in real rooms. Done — full report for Lane E in docs/LANES/LANE_C_GLB_VALIDATION.md.

Verdict: 5 of 9 fitting GLBs are good and now live behind useGLB (wire_shelf, clothes_rack, bookshelf, cube_shelf_wide, work_table); 2 fall back to primitive with a report filed (record_crate broken — won't load; counter too long/no till → primitive keeps the till + keeper stand). record bins stay primitive until Lane E re-exports the crate.

Soak with useGLB on (50 rooms, all types): throws 0 · pathFail 0 · determinismFail 0 · leakGeo 0 · leakTex 0 · worst build 10.5ms · 256 upgrades applied — GLB resources dispose leak-free on room exit.

glb.js changes (my file):

  • manifest.localBase — load GLBs from a served local dir for offline validation (production still uses depot:<file>; forward-compatible with Lane E's local-depot mode).
  • Real-metre scale, clamped to the reserved footprint (was fit-to-width, which squashed tall props); matches glb_law (metres). Per-kind yaw correction (KIND_TO_YAW) for the Z-major assets (bookshelf/cube/work_table need +90°) per the manifest facing convention ("Lane B/C rotate directional props").
  • room.glbReady (interiors.js) — a promise that resolves when GLB swaps settle, so callers can await before screenshot/leak-check (Lane F can use it in the §3.4 wiring).
  • Test page (interior_test.html) gains a GLB props toggle + a GLB props: N upgraded HUD line. Local validation needs the GLBs copied in: cp pipeline/_normalized/procity_fit_*.glb web/assets/_local_glb/ (untracked; real GLBs live on the depot). Screenshot grid: docs/shots/laneC/glb_validation_grid.jpg.

Update 2026-07-14 (b) — keeper stand pose (resolves Lane F §8 · unblocks §3.2/§3.3)

Lane F's runbook asked (LANE_F_NOTES §8): "does places tag the counter slot with a stable kind:'counter' so keepers find it?" Yes — the counter interactable has always carried mesh.userData.kind === 'counter' (+ interactable:true). But Lane D's keeper needs more than a tag: keepers.spawn(group,{x,z,ry}) needs where the shopkeeper stands and which way it faces (keepers.js:28 — "ry = the counter's outward facing"). I computed the customer-facing side internally but never surfaced it — so Lane D would have read the bench's raw transform and stood a keeper embedded in the counter, possibly facing a wall. Closed that gap (my files only, purely additive):

  • New: room.counter = { mesh, pose:{x,z,ry}, stand:{x,z,ry} } (interiors.js). pose is the bench; stand is the keeper pose — behind the bench on the service side, facing the customer, clamped inside the room, in room-local space (the same space Lane D adds the keeper into via interior.group). ry uses the rig-front = local Z convention keepers.js expects.
  • The same {x,z,ry} also rides the counter interactable: place.userData.keeperStand — for the places-iteration path Lane D/F described. Deep-equal to room.counter.stand.

Lane D/F wiring (drop-in for §3.2/§3.3):

const room = buildInterior(shop, THREE);
const s = room.counter.stand;   // or: room.places.find(p=>p.userData.kind==='counter').userData.keeperStand
keepers.spawn(room.group, { ...s, shopId: shop.id, type: shop.type });
// on exit: keepers.remove(handle) / keepers.disposeAll(), then room.dispose()

Verified in-browser (9 types × 4 seeds = 36 builds, + 50-room soak): stand in-bounds ✓ · faces-customer ✓ (dot>0 for all) · places-carries-keeperStand ✓ (deep-equal) · stand deterministic ✓ · placement determinism unchanged ✓ · soak avg 3.6ms / worst 7.7ms · leak geo0 tex0 · pathFail 0 · every room has a counter+stand. No change to layout/placement of existing rooms.

Minor known cosmetic (corner counters): a corner counter sits ~0.55 m off the east wall, so the service gap (~0.25 m) is narrower than a person — the keeper stands at the till with a small hidden overlap into the solid waist-high bench (torso reads above the counter, legs behind it). Facing and side are correct; it reads as "keeper at the till." If Lane D/Fable want real standing clearance I can nudge corner counters ~0.2 m off the wall — but that changes those rooms' committed placement + screenshots, so it's a decision, not a bug.

Two contract-drift corrections for the F runbook (§2's Lane-C line was written against an earlier draft of my API): (1) there is no opts.registry — registry override is the one-time global mergeRegistry(registry) (a per-build registry mutation broke same-seed determinism; removed in the review pass). (2) recipe.counterPos is a placement-strategy string (door|forward|back|corner), not a world position — use room.counter.stand for the keeper pose.


What shipped

All files under my lane's ownership (web/js/interiors/*, web/interior_test.html) — no other lane's files touched. Lane A's core/registry.js hadn't landed, so the lane is fully standalone (its 9 type recipes live in theme.js) with a mergeRegistry() hook for Lane F to wire the registry later.

file role
interiors.js public APIbuildInterior(shop, THREE, opts)
theme.js 9 shop-type recipes (archetype/wallpaper/floor bias, clutter, counter pos, fittings mix, stock kind, signage) + aliasing + registry-override hook
shell.js room shell: floor/walls/ceiling from lot×archetype, glazed shopfront + street backdrop, blocked back doorway, interior lighting
fittings.js parametric fittings kit (ported from 90sDJsim + extended): bins, crates, racks, 4 shelf types, VHS aisle, glass case, counter+till, fridge, magazine/spinner racks, armchair, escalator, pegboard, barred window, returns slot, art frames
layout.js the placer: per-archetype zones, shuffled wall-slot system, occupancy grid, guaranteed door→counter flood-fill path
stock.js v1 visual stock: pooled canvas-texture sleeves/spines/boxes/garments/snacks with price stickers (dig.js trick) + stockAdapter hook for BaseGod data
context.js build/dispose/seed backbone: independent seeded sub-streams, shared-geometry cache, leak-free disposeAll()
glb.js optional GLB hero-prop upgrade — reads Lane E's manifest.json, swaps primitives for depot GLBs, placeholder-persists. OFF by default; primitive fallback always
interior_test.html standalone test page (below)

Public API (the contract Lane B/F build against)

import { buildInterior } from './js/interiors/interiors.js';
import * as THREE from 'three';

const room = buildInterior(shop, THREE, opts?);
scene.add(room.group);
// … player walks; on hitting room.exits[i], fire procity:exitShop …
room.dispose();               // frees ALL per-room GPU resources; removes group from parent

// room = {
//   group,                              // THREE.Group, self-contained (lights included)
//   spawn: { x, z, ry },                // enter just inside the door, facing Z (into the shop)
//   exits: [ { x, z, w, toStreet } ],   // door back to street
//   places: [ …meshes w/ userData ],    // interactables: counter, bin, case, fridge, returns, exit
//   counter: { mesh, pose, stand },     // pay point. stand:{x,z,ry} = keeper (Lane D) spawn pose
//   dims: { W, D, H, archetype, type },
//   placement,                          // deterministic summary (deep-equal per seed)
//   pathOK, buildMs, counts(), dispose()
// }

shop is tolerant — recognized fields { id, type, name, seed, storeys, lot:{w,d} }:

  • seed (uint32) is the ONLY randomness source; falls back to a hash of id/name/type if absent.
  • type — any CITY_SPEC type (record opshop toy book video pawn milkbar dept stall) or a thriftgod/Overpass alias (music charity pawnbroker market video_games …); defaults to opshop.
  • lot {w,d} metres — the room adapts to it (never exceeds it); absent → archetype ranges decide.
  • storeys drives ceiling height 3.24.5m.
  • opts: { archetype?: 'cosy'|'gallery'|'wide'|'hall'|'pokey', stockAdapter?, useGLB?, manifest?, manifestUrl? }. Registry override is a one-time globalimport { mergeRegistry } from interiors.js; mergeRegistry(registry) at init (NOT a per-build opt — a per-call mutation of the shared recipe table would break same-seed determinism).

Coordinate convention (shared with a future Lane B wiring): +Z = door/street side, Z = back, origin = room centre, floor y = 0. Everything is in the returned group's local space.

For Lane B (door wiring, later)

buildInterior is a pure function — call it on procity:enterShop, add room.group to the scene, teleport the player to room.spawn. On procity:exitShop (player reaches room.exits[0]) call room.dispose() and restore the street. The street window fake is Lane B's; I own the inside view (a bright canvas street backdrop plane is already behind the glazing).

For content phase (BaseGod / GODVERSE)

Pass opts.stockAdapter = (shop, slotKind) => ({ texture }) | ({ mesh }) | null. Returning a texture skins that stock item's face; a mesh replaces the item; null → procedural placeholder. No layout/ fittings changes needed. slotKind ∈ { sleeve, spine, box, snack, magazine, garment, treasure }.

Test page — web/interior_test.html

Serve: cd web && python3 -m http.server 8130 → open /interior_test.html.

  • seed input · type dropdown (9) · archetype override (auto + 5) · lot w×d/auto · re-roll · seed ±
  • first-person walk (PointerLock + WASD) with occupancy-grid collision + room clamp
  • debug toggles: wireframe (F), occupancy grid (G), path (spawn/counter markers)
  • ▶ 50-room soak test: builds+disposes 50 seeded rooms, asserts ms/room, leaked geometries vs baseline, and determinism (same seed → identical placement)

Acceptance — all met

requirement result
Same seed twice → identical room (deep-equal placement) 0 mismatches / 810 builds
All 9 types × 5 archetypes render sensibly contact sheet docs/shots/laneC/_grid_9types_5archetypes.jpg
Build < 50ms/room steady-state ~4ms, clean-soak worst ~8ms (first-ever build ~60ms one-time cold-JIT; occasional ~60ms GC pauses only under back-to-back torture loops — not a real per-room cost)
Dispose leak-free (info.memory → baseline) soak leak: geo 0, tex 0
Flood-fill door→counter always exists 0 path-fails, 0 carves / 810 builds
Runs with zero assets & zero network seeded flat-colour fallback under every texture; map only assigned on load

Full sweep (9 types × {auto+5 archetypes} × 3 seeds × {null,4×4,5×14,20×20,3×3} lots = 810 builds): throws 0 · pathFail 0 · determinismFail 0 · carved 0. Warm perf: all < 50ms (worst 33.2ms). Soak (50 rooms): avg 4.0ms · worst 9.6ms · leakGeo 0 · leakTex 0 · detFail 0 · pathFail 0.

Key decisions & notes for review

  • 90sDJsim fittings.js ported (it was written to be lifted): clothesRack, shelfUnit, recordBin, table, counter, pegboard, garment-silhouette canvas trick. Extended with the fittings CITY_SPEC's registry needs (cube/metal/book/VHS shelving, glass case, fridge, spinner, magazine rack, armchair, escalator prop, returns slot, barred window, listening corner).
  • thriftgod buildShop ported: the 5 archetypes, the shuffled wall-slot system, bins with fanned sleeves wearing covers, corner counter+till, crooked wall art, blocked back doorway.
  • Determinism via independent seeded sub-streams keyed by a salt string (ctx.stream('wallpaper') etc.) so adding a fitting in one subsystem never shifts another subsystem's picks.
  • Leak-free via a tracked build context: per-room geometry/material/CanvasTexture/tiled-file-texture are freed in dispose(); globally-cached file textures (core/loaders.loadTex) are shared and not disposed (bounded cache). Dispose-before-texture-load race is guarded (_disposed).
  • Perf: shared-geometry cache (ctx.boxGeo/planeGeo/cylGeo) collapses the hundreds of identical stock items in a room down to ~80110 geometries; spine density tuned so a book barn is ~600 meshes.
  • GLB upgrades: web/assets/manifest.json (Lane E) is read if present, primitive fallback always. Not present yet → 100% primitives, which is why the test page runs asset-free.

Integration with sibling lanes (landed in parallel)

  • Lane A core/registry.js — its SHOP_TYPE_IDS match my 9 recipe keys exactly. I kept the interior recipes local (the registry doesn't carry archetype-bias/clutter/counter-pos/stock detail), with mergeRegistry() as the one-time override seam for Lane F.
  • Lane E web/assets/manifest.json — its fittings section (9 depot GLBs with footprints) is now wired through glb.js (opt-in useGLB, primitive fallback). Depot GLBs may not be uploaded yet; verified the fallback (unreachable depot → primitives persist, no crash, no leak).
  • Lane D citizens/keepers.js — its keepers.spawn(group,{x,z,ry,shopId,type}) now has a clean source: room.counter.stand (also on place.userData.keeperStand). See the Update (b) section at the top. Verified the pose is on the service side, faces the customer, and is deterministic.

Adversarial review — 5 findings, all fixed

Ran a 5-dimension adversarial multi-agent review (determinism / disposal-leaks / path-guarantee / three-api / edge-robustness) with a per-finding verify pass. 5 real defects surfaced; all fixed and re-verified (830-build sweep + 50-room soak green after each fix):

# sev file defect fix
1 med interiors.js per-build opts.registry permanently mutated the shared recipe table → latent same-seed determinism break across differing registry states removed per-build option; registry override is now a one-time global mergeRegistry() (Lane F setup)
2 low interiors.js explicit null opts threw (opts = {} default only covers undefined) normalize opts = opts || {}
3 low shell.js empty archetypeBias array → bias[0][0] on [] throws fall back to uniform bias when missing or empty
4 med layout.js fittings pulled by the path loop left phantom interactables in places (dangling after dispose) collect places from survivors at the end, not incrementally at placement
5 low layout.js rebuildOcc re-stamped wall-mounted fittings that placeAtWall never stamped → phantom floor obstacles after a blocker-pull mark wall-mounted noStamp; skip in rebuildOcc and exclude from the removable pool

Verify evidence (post-fix): nullOpts ok · phantomPlaces 0/332 · determinismAcrossBuilds identical · emptyBias ok · sweep 810 {throws 0, pathFail 0, detFail 0} · soak {avg 4.2ms, worst 8ms, leakGeo 0, leakTex 0}. (Reviewers also confirmed no disposal/leak or three.js-contract defects.)

Not done (out of scope for v1 / depends on other lanes)

  • Real item data / economy (content phase — stockAdapter hook is ready).
  • GLB hero props are wired (glb.js, opt-in) but not visually validated end-to-end — needs the depot GLBs actually uploaded (Lane E) + useGLB flipped on by Lane B/F. Primitive path is the tested baseline.
  • Interior-mapping shader for street windows (Lane B stretch goal).
  • Back-room beyond the blocked doorway (v2).