PROCITY/C-progress.md
m3ultra c134584b88 Lane C R21 (v4.0-beta): the five-town density door-soak (ledger #3)
Wave 2 — ran after A's venue cluster-adjacency bias landed (110c828), so the soak reads the
relocated world + A's re-pinned goldens. The carried R20 #6.

Five towns at their new densities, ~25 deterministically sampled doors/town (sample takes one
of EVERY type present, then stride-fills, so the texture classes' first real appearance is
always covered):

  town              shops  sampled  venues  types  worst draws   verdict
  katoomba_real      72      25       3      10       157         PASS
  newtown_real       67      25       3      11       155         PASS
  fremantle_real     79      25       3      11       153         PASS
  bendigo_real       35      25       3      10       154         PASS
  castlemaine_real   23      23(all)  3       8       156         PASS

123 doors: 0 throws / 0 carves / 0 path-fails / 0 over-budget, worst 157 draws (<=350).
typesInTown == typesSampled in every town (all archetypes entered, incl. all 3 venue kinds).
All five towns landed 3 venues -> A's cluster bias + district placement hold at density.

§6 mapping verified live: katoomba cache = 20 heroes + 60 texture (exactly E's 3x cap, heroes
untouched). Texture's first real appearance renders right -- "Blue Mountains Chocolate Company"
(real OSM shop=chocolate) wears milkbar as a believable confectionery counter shop. Texture,
not takeover. Cache->plan deltas are A's counted drops, not an interior concern.

C->F: density verdict GREEN. Docs-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:05:17 +10:00

57 KiB
Raw Permalink 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-16 (round 21) · owner: PROCITY-C · reviewer: Fable


Update 2026-07-16 (round 21, v4.0-beta close) — the five-town density door-soak (ledger #3)

R21 §Lane C (wave 2 — ran after A's venue cluster-adjacency bias landed, 110c828, so the soak reads the relocated world + A's re-pinned goldens). The carried R20 #6: every one of the five beta towns at its new density, deterministically sampled ~25 doors/town — the sample takes one of every type present then stride-fills, so the texture classes' first real appearance (milkbar/dept/stall on real shops) is always covered.

town shops (in plan) sampled venues types (all sampled) throws carves path-fails over-budget worst draws
katoomba_real 72 25 3 10 0 0 0 0 157
newtown_real 67 25 3 11 0 0 0 0 155
fremantle_real 79 25 3 11 0 0 0 0 153
bendigo_real 35 25 3 10 0 0 0 0 154
castlemaine_real 23 23 (all) 3 8 0 0 0 0 156

123 doors · 0 throws · 0 carves · 0 path-fails · 0 over-budget · worst 157 draws (≤350). In every town typesInTown === typesSampled — every archetype present was entered, including all three venue kinds (pub/band_room/rsl) and the texture classes. All five towns landed 3 venues → A's cluster bias + district placement hold at density.

§6 mapping verified live (John's SUBTLE directive, working by construction): katoomba's cache is 20 heroes + 60 texture — exactly E's 3× cap, heroes untouched. In-plan (after A's counted drops): 17 heroes (opshop 10 · book 4 · record 2 · video 1) + 52 texture (milkbar 29 · dept 18 · stall 5) + 3 venues = 72. The texture classes' first real appearance renders right: "Blue Mountains Chocolate Company" — a real Katoomba shop=chocolate — now wears milkbar (70s geo wallpaper, cube shelves of price-tagged goods, counter, wall rack): a believable confectionery counter shop. The widening reads as texture, not takeover.

(Cache→plan deltas — e.g. katoomba 80→72 — are A's overflow/fragmentation drops, counted by cause in A's R20 accounting; not an interior concern, and every surviving door builds clean.) C→F: density verdict GREEN. Docs-only. C otherwise on-call; my §6 mapping + this soak close both C items for the beta.


Update 2026-07-16 (round 20, v4.0-beta FIVE TOWNS) — the class→archetype mapping (ledger #2, C's half)

R20 §Lane C (I lead — E builds to it, half-day rule): published the OSM shop-class → interior archetype mapping in LANE_C_PUB.md §6 (new v4.0-beta amendment; the frozen venue contract §0§5 untouched). This is the density widening — real towns fetch general retail, not just the thriftgod secondhand classes, so the main street holds its real bakery/cafe/chemist/clothes shops.

Design (John's directive: SUBTLE — texture, not takeover):

  • No new archetypes. General retail wears existing recipes: food/grocer/newsagent/chemist → milkbar (the canonical AU counter shop); clothes/hardware/big-retail → dept; produce/florist → stall.
  • Secondhand stays the hero — charity/antiques→opshop, books→book, music→record, video/games→video, pawn→pawn, toys→toy; never subsampled.
  • Unknown/unlisted → opshop (R18-proven safe). Included a copy-paste JSON map for E's build_towns.py
    • a tasteful fetch list (skip vehicle/funeral/industrial/pure-services like hairdresser/bank).
  • Guardrail for E: if a town's general retail would visually drown the secondhand strip, seed-subsample the texture classes toward ~23× the secondhand count and count the drops in norm.dropped.

Mechanic note: plan_osm keeps only registry types (unknowns flatten to opshop), so E bakes a registry type per the map into each cache's shop.type — no C code change, all targets are existing recipes/registry entries, budgets unchanged. C→E handshake: §6 is live.

Pre-validated the mapping (measurement over folklore, before E builds to it): milkbar/dept/stall/opshop all build valid at the katoomba lot (8.5×14) — 0 carves, ≤143 draws, pathOK. Rendered dept (my one flagged case — the escalator centrepiece): reads as a clean dressed-retail floor (cube shelves of goods + clothes rack + counter + "GROUND FLOOR/HABERDASHERY" signage) in the 7.9×11.7 hall, escalator does not dominate — so clothes/hardwaredept is confirmed, not a concern.

Pending (ledger #6, after E re-emits the 5 widened caches): the five-town sampled door-soak — every town, sampled doors at the new density, all types resolve, no carve, budgets; one table, five rows. I'll watch that dept reads right on small real lots (the escalator centrepiece). Docs-only this commit; door-soak follows E.


Update 2026-07-16 (round 19, v4.0-alpha CLOSE) — re-soak katoomba on REAL roads (against A's in-flight pass)

R19 §Lane C (on-call, ledger #5): E's roads landed (dbb52c2 — katoomba_real is now schema v2, 484 real roads), so I re-ran the 20-door soak against the real street graph — closing the R18 "re-run when roads land" caveat. Honest caveat: the working tree also carried Lane A's UNCOMMITTED R19 fragmentation pass (plan_osm.js WIP — JOIN_TOL/BRIDGE_MAX, junction-protected DouglasPeucker, shopless-island cull / shop-island bridge), so the dev server served A's in-progress finalization, not a frozen state. Read this as "interiors survive A's real-graph pipeline as it stands mid-round"; a clean confirm follows A's commit.

Robust result (holds whatever A's WIP does): every shop the plan produced builds a valid interior — 0 throws · 0 carves · 0 path-fails · 0 over-budget · worst 147 draws (min 115); 3 gig venues (pub/band_room/rsl — district placement holds on the real graph); plan deterministic (byte-identical re-gen). Lot dims unchanged (7.58.5 × 14) → interiors are dim-identical to R18 (road-agnostic confirmed; my R18 degenerate-lot proof already spans anything the real graph can seat).

Shop count observed = 19 (NOT filed as a defect). The plan yielded 19 shops here, but against A's in-flight fragmentation code that's expected, not a bug — A's ruling explicitly culls shopless islands and bridges/ drops-with-count shop-bearing ones, so the final count is A's to pin (could land at 20 connected or an explicit 19 with a norm.dropped count). I'm not attributing it to A as a finding; I only confirm interiors build clean whichever way it resolves. (If A's committed pass surprises with a shop that can't build an interior, that would be the finding — none did in this run.)

Final C spot-confirm pending A's COMMITTED finalization + golden pin (per Fable's sequencing, C confirms after A). When A pins katoomba_real's real-roads golden I'll re-run once against the frozen state. Docs-only round for C.


Update 2026-07-16 (round 18, v4.0-alpha REAL ROADS) — interior spot-soak of katoomba_real (ledger #6)

R18 §Lane C (small): enter every katoomba_real shop and assert valid interiors + no carve + budgets. Built the town plan the way the shell does — registerTownCache(katoomba_real)generatePlanFor(seed, 'osm', {town, gigs:true}) (gigs on = the default boot) → buildInterior per shop (lot dims resolved from plan.lots). All 20 doors PASS:

metric result
shops entered 20 / 20 built
throws · carves · path-fails · over-budget 0 · 0 · 0 · 0
worst draws 153 (shop 16, opshop/hall) ≤ 350
types resolved opshop · book · record · video + venues pub · rsl · band_room
gig venues (default boot) 3 (ids 3/5/19) — all built valid gig interiors (stage + watchPoints)
draw range 108153

Degenerate-lot stress (preempts A's real-road re-seating): 32 combos (8 extreme lots {2×3, 1×1, 3×30, 40×4, 100×100, 0×0, 4.2×4.1, 2.5×18} × 4 types incl. venues) → 0 throws · 0 carves · 0 path-fails; every room floors at ≥4×4 m (computeDims Math.max(4, lot0.6)). So whatever frontage the real-graph lift seats shops on, the interior contract absorbs it.

Caveat (honest): audited against the CURRENT katoomba_real.json — schema v1, roads: none, so it boots the marched fallback (E's katoomba roads cache isn't committed yet; A's lift a78dc76 is in). Interiors are road-agnostic (they consume lot dims + type + seed, never road geometry), and the degenerate-lot proof covers any lot the real graph produces, so the conclusion is stable. Interior lane contributes 0 failures to the alpha's failure list. A one-line re-run once E's roads[] cache lands (lot dims may shift with real frontage) — I'll re-confirm if asked, but the interior contract already absorbs the range. C otherwise on-call.


Update 2026-07-16 (round 17, v3.2 THE TAIL) — backline handshake CLOSED + real-town type fallback

R17 §Lane C (ledger #3): close the R16-pending render-verify of D's wired amp. D landed the wiring (ab7a8d0): band.js _addBackline reads stage.backline[0] with an immediate primitive ampPrim() fallback (asset-free) + GLB upgrade. Handshake CLOSED — verified fresh (seed 20261990), 3 archetypes × 2 modes:

  • amp plants at backline[0] exactly (offset 0.00 GLB / 0.01 prim — rounding); 1 amp per stage (no more C primitive → the double-amp is gone).
  • no interpenetration — amp↔nearest-band AABB edge gaps all positive: pub 0.40/0.44 m, band_room 0.27/0.29 m (tightest; the bass extends downstage away from the up-stage amp), rsl 0.54/0.57 m (GLB/primitive). Rendered band_room gig confirms the read (amp up-stage-right behind the bassist, clear).

On-call (proactive, for A/E's real-map scout): confirmed C's interior type fallback eats real OSM shop types. 18 real shop= values (hairdresser/bakery/convenience/supermarket/chemist/butcher/… + ''/null/ undefined/SHOP/Op_Shop/antiques) all build with 0 throws, all pathOK, resolving to opshop (theme.js canonicalType) or a mapped alias — so a player entering any real-town shop gets a valid interior. Venue types (pub/band_room/rsl) are recognized; everything else degrades to opshop. No C change needed for v4.

Docs-only round (verification confirmed shipped behavior). C otherwise on-call.


Update 2026-07-16 (round 16, v3.1 THE FLIP) — the backline handoff (C's half of ledger #5)

R16 §Lane C (ledger #5, C's half): unify the amp. room.stage.backline[] now exposes 2 seeded up-stage amp slots {x,z,ry,y} (pure data, no meshes), and C's primitive ampStacks are dropped from placeStage — Lane D plants E's guitar_amp GLB at backline[0] (its own primitive fallback under ?noassets), one amp system. Shape published in LANE_C_PUB.md §3 (v3.1 amendment marker; rest of the frozen doc unchanged). C→D handshake: backline shape is live.

  • backline[0] = stage-right (stage.x + stage.w·0.36, deckY, stage.z stage.d·0.22) == D's R15-verified amp pose exactly, so wiring D's plant to it moves nothing on screen. backline[1] = stage-left spare (mirror). ry=π, y=deckY.
  • Verified fresh (port-isolated, seed 20261990): both slots up-stage, on-deck, clear of all 4 bandPoses (nearest-bass 0.87 m pub / 0.70 m band_room / 0.92 m rsl) and all watchPoints (≥1.95 m); determinism byte-identical (placement + stage + watchPoints); 0 leftover ampStack prop groups; drawSweep glb-off venues pub 122 · band_room 120 · rsl 131 (was 126/124/135 — the dropped primitives shaved ~4 draws each), worst 161 (opshop/hall, unchanged). ≤350 ✓.
  • Determinism/golden safety: removing the ampStack loop only drops draws on the stage rng sub-stream, which nothing downstream reads; the placement snapshot excludes ampStacks (never tracked), so it's byte-identical. Interiors aren't hashed into any golden; the ?classic=1 boot has no venues → untouched.
  • Disjoint from F's flip (interiors don't read flags) — landed early so D isn't blocked. Verify with D once wired (ledger #5 D-half): re-render each archetype, confirm the GLB at backline[0] doesn't interpenetrate (pending D's R16 commit).

Code: layout.js placeStage (backline[] + drop the amp loop), interiors.js API doc. theme.js untouched (recipes carry no ampStack fitting — the primitives were placeStage-planted, not recipe fittings).


Update 2026-07-16 (round 15, v3.0: closing the release) — verified D's amp fix

R15 §Lane C (one item): verify D's guitar_amp reposition (ledger #4). D moved the plant (commit 66aec0f) to C's §3 corrected pose exactly — band.js:236 amp.position.set(stage.x + stage.w·0.36, deckY, stage.z stage.d·0.22). Verified (fresh port-isolated context, seed 20261990, D's real GigCrew + GLB backline): matches §3 (both venues); bass clearance 0.87 m (pub) / 0.92 m (rsl) — the R14 interpenetration (0.29 m) is gone; on-deck within footprint; flanks C's primitive ampStack (opposite side, 4.4 m pub / 3.5 m rsl) as a clean up-stage backline; no clash with band/crowd/watchPoints. Rendered pub gig confirms the read (amp up-stage behind the bassist, primitive up-stage-left, band clear). LANE_C_PUB §3 updated RESOLVED. Freeze otherwise stands; no other C change this round. Nothing else blocks on C — F runs the release (continuity wiring + smoke, alias deletion, week soak, tour, tag v3.0).


Update 2026-07-16 (round 14, v3.0: the release) — venue audit + docs freeze (light round)

R14 §Lane C: interior release audit, the guitar_amp ruling, and the docs freeze. C's interior code was unchanged since R13, so venue geometry is stable by construction; the audit re-verified it against the current tree (D's R13 continuity + RY-fix + real instrument GLBs landed) and froze the contracts. Ran an adversarial audit workflow (10 agents: conformance + D-integration/amp + docs-freeze edit list, then a verify pass) — it earned its keep (caught the amp defect I'd first waved through + two stale comments).

Empirical re-verify (fresh context, port-isolated server):

  • Venue contract byte-identical to R13: caps 8/8/12, deckY 0.32/0.20/0.40, riserY 0.48/0.36/0.56, gigKeys gig-pubrock/grunge/covers, quiet-night = no gigKey, determinism true.
  • drawSweep glb-off: band_room 124 · pub 126 · rsl 135 (worst 161 opshop/hall) ≤350 — unchanged.
  • Gig-night draws with D's real GigCrew + GLB instruments (localdepot): pub 60 · band_room 52 · rsl 71 ≤350. Asset-free worst RSL 237. All 4 instruments load (guitar/bass/mic/drums).
  • Release-law confirmed (workflow, CONFIRMED): D's continuity + the RY fix ride a disjoint rng namespace (rng(citySeed,kind,id)) from C's ctx.stream(salt); the RY fix is a constant Y-rotation in buildFigure (no rng). Cannot perturb C's placement or any golden.

guitar_amp ruling (debt for C) → Lane D: NOT blessed as-is — reposition up-stage. D plants the GLB at (stage.x+stage.w·0.32, deckY, stage.z+0.15) — that +0.15 is down-stage, landing the 0.5 m amp 0.29 m from the bass rig (interpenetrates the bassist, confirmed by render + 3 audit agents), and it's redundant with C's own primitive ampStacks (placeStage plants 12 up-stage at (±stage.w·0.28, deckY, stage.z stage.d·0.18)). Corrected pose in LANE_C_PUB.md §3: move up-stage-right ≈ (stage.w·0.36, deckY, stage.z stage.d·0.22), clear of the bass and offset from C's primitives → a clean flanking backline. Full amp unification (C exposes stage.backline[], GLB replaces primitive) = v3.1. Cosmetic + ?gigs=1 opt-in → does not block the tag.

Tri observation → E/F (not a C gate): E's decimated instrument GLBs are 14k tris each (5 on a stage = 70k; peds ~2.7k each), so a settled RSL gig-night interior is ~182k tris. The interior gate is draws (passes at ≤71); the 200k tris is the street budget, so no violation. But F's gig-night smoke reports ~34k because it measures before the async instrument GLBs load — the real settled scene is ~5× heavier. A camera-distance instrument LOD is a v3.1 candidate.

Docs freeze (v3.0-FROZEN markers added):

  • LANE_C_PUB.md — retitled + frozen marker; §0 ry convention reframed past-tense (the +Z fleet was fixed at the source in D's rigs.js buildFigure, R13 — no per-consumer flips); 4-piece stated as shipped (veto language removed); the amp ruling added (§3); budgets updated to R14; in-flight handoffs removed.
  • LANE_C_AUDIO.md — frozen marker + a scope note (this covers base interior audio; gig audio gigKey lives in LANE_C_PUB); the deferred audioEmitter idea re-targeted to v3.1.

Code (comment-only, zero runtime/golden/rng impact): fixed two stale R12 inline return-shape comments in interiors.js (watchPoints 6..8 → 8/8/12 caps; stage bandPoses[3][4] + riserY) + one in layout.js; added a note at the gigKey hand-build explaining it's deliberately standalone (byte-identical to citygen's gigKeyFor, not imported — keeps the interiors lib citygen-free). theme.js untouched.


Update 2026-07-15 (round 13, v3.0-beta: the district) — two more rooms + the drummer

R13 §Lane C. The district: two new venue archetypes + the 4-piece band + the RY-facing audit. Interfaces (re)published in docs/LANES/LANE_C_PUB.mdread §0 first (the pinned ry convention + the keeper/browser back-to-front finding for Lane D). Committed C-only by pathspec (the tree had E/B/D changes landing concurrently — procity-shared-tree-hazard).

Shipped (C-owned files only): theme.js, layout.js, interiors.js.

  • band_room + rsl recipes (theme.js, both venue:true), reusing the pub venue machinery with a new venueSpec field. band_room = small/scuzzy tin shed (cosy/pokey bias, low 3.4 m ceiling, deck barely a riser deckY 0.2, near-empty standing floor). rsl = carpet-and-flock club (hall/wide bias, tall 4.0 m, proper stage deckY 0.4, bistro tables, the biggest crowd cap). A converts a shop with shop.type = kind; C keys the recipe off it. SHOP_TYPES now auto-enrols both in drawSweep/soak.
  • 4-piece band (debt #3): placeStage grows stage.bandPoses to 4 — front-line trio (guitar/vocal/bass, y=deckY) + a drummer (role:'drums', seated:true, up-stage on the drum riser, y=riserY=deckY+0.16). Each pose now carries its own y; stage.riserY added. Applies to all three archetypes (the pub gains a drummer too). Pure geometry — no new rng draw, determinism intact.
  • Per-kind venue tuning: placeStage/placeWatchPoints read recipe.venueSpec → deck size/height + crowd cap (pub/band_room 8, rsl 12 — the crowd-cap stress case). Pub values == the R12 constants, so the pub stage geometry is byte-identical (bar the new drummer).
  • Robust gigKey (debt #1): audio.gigKey = 'gig-'+genreKey, genre resolving opts.gig.genreKeyshop.genreKey (A sets it) → a kind default. Matches E's renamed manifest beds gig-pubrock/gig-grunge/ gig-covers exactly (verified: manifest has all three, zero stale pubrock-live). One key, no mapping.

RY-facing audit (debt #4) — see LANE_C_PUB.md §0 for the full write-up + fix. Headline:

  • Canonical convention pinned: rig-front = local Z at ry=0 (every C pose + the box placeholder use it).
  • The GLB fleet mesh faces +Z (opposite). Verified in a fresh context two ways: toe-bone probe (toefoot = (0,+0.99) on 5 peds) and dotting a fleet rig planted at counter.stand.ry against the intended customer-facing (0.99 = 180° backward; +0.99).
  • Finding: keepers + browsers have been facing 180° backward whenever the GLB fleet is loadedkeepers.js spawns at pose.ry with no flip, and the mesh front is +Z. The R12 band reads right only via band.js RY_FLIP=π. Placeholder (Z) and GLB (+Z) are opposite, so it's inconsistent both ways.
  • Recommended fix for Lane D (visual-only, no golden moves): normalise at the source — rotate the clone inner by π in rigs.js buildFigure so every GLB rig faces Z; then keepers.js stays flip-free (now correct) and band.js DROPS RY_FLIP. Repro below.

→ Lane F (flags_check.py): two smokes are RED by design — bump band == 3== 4. My 4th bandPose makes the band a 4-piece (D's band.js already iterates bandPoses, so it spawns 4 today), which trips F's R12 assertions verbatim as the round-13 instructions anticipated ("F's band==3 smoke moves to 4"):

  • tools/flags_check.py:674if night['band'] == 3== 4.
  • tools/flags_check.py:794if na['inRoom'] and na['band'] == 3 and na['crowd'] > 0== 4. (The noassets gig still happens — band 4, crowd 8, 0 fetches, 0 console errors; silent-and-happy holds. It only "fails" because 3 is hardcoded.) These are the ONLY qa reds from C; the other 5 gates pass.

→ Lane D: lift each band member to pose.y (front line deckY unchanged; drummer sits at riserY); seated:true on the drums pose → seated bob; raise band.js CROWD_CAP to the watchPoint cap so the RSL's 12 fill (I measured the full 12). And apply the §0 facing fix. Repro for the audit (paste in the interior test page):

const rigs = await import('./js/citizens/rigs.js'); const THREE = await import('three');
const fleet = rigs.loadPedFleet('models/peds/'); await fleet.whenReady;
const sp = rigs.spawnRig(fleet.all[2], { ry: 0, height: 1.75 }); sp.fig.updateWorldMatrix(true, true);
let foot, toe, wp = new THREE.Vector3();
sp.inner.traverse(o => { if (o.isBone){ o.getWorldPosition(wp);
  if (/LeftFoot$/i.test(o.name)) foot = wp.clone(); if (/LeftToeBase$/i.test(o.name)) toe = wp.clone(); }});
console.log('fleet forward at ry=0:', toe.clone().sub(foot).setY(0).normalize()); // → (0, +1) = +Z

Budgets (measured, fresh context, seed 20261990):

  • Empty shells (drawSweep glb-off): band_room 124 · pub 126 · rsl 135 (worst town-wide 161 opshop/hall) ≤350.
  • Gig-night incl. full crowd + 4-piece: worst RSL 237 draws asset-free (16 rigs) / 77 GLB, 40.9k tris. pub 176/56, band_room 173/53. ≤350 draws / 200k tris. ✓
  • 90-build venue soak: 0 determinism · 0 carve · 0 path-fail · worst 8.3 ms; leak geo0/tex0. soak(60) in the page: PASS 60 rooms, leak 0/0, determinism identical, worst draws 85. qa figure-scale gate: 24 figs human-sized. Shot: docs/shots/laneC/rsl_gig_r13.jpg — RSL, 4-piece facing the crowd, crowd facing the stage (facing reads correct via band.js's flip).

Drummer veto (John): default is Fable's 4-piece ruling (executed). To revert to a front-line trio, drop bandPoses[3] in layout.js placeStage (1 line) + F reverts the smoke to == 3 — say so before D wires it.


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).