interior_mode.js bridges Lane B's enterShop seam to Lane C buildInterior and Lane D keepers (spawn at counter keeperStand, greet, leak-free over 5 cycles). CitizenSim wired into the street loop (pop 140, ~96 active midday, worst ~191 draws). tools/: qa.sh gate runner (all 4 landed gates GREEN --strict), scaffold/consistency checks, shots.py + soak.py browser harnesses. docs/shots/ reference tree, LANE_F_NOTES runbook, V2_IDEAS parking lot. (F's inline seam edits to index.html/skins.js landed with the Lane B commit.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 KiB
LANE F — NOTES (integration runbook + QA gates)
Lane F is integration + QA. It runs after A–E land. This file is the durable technical spec: the exact wiring edits, the contracts F needs from each lane, and the QA gate procedures. It is written before A–E finished so the wiring is a fast, mechanical pass the moment they do — and so each lane can see the small hook F will ask of it and build it in up front.
Status of this file: living. Update the ✅/⏳ markers as wiring lands. Snapshot of lane
readiness lives in F-progress.md; run node tools/qa/scaffold_check.mjs
for the live matrix.
0. Ground truth / environment
- Repo root on this machine:
/Users/m3ultra/Documents/procity(the lane prompts say/Users/jing/Documents/PROCITY— that path is from the authoring machine; use the real path above). Reference repos are cloned alongside:../90sDJsim,../thriftgod. Ultra box (existing 3D library / Blender / clip bank):ssh johnking@100.91.239.7. - Serve:
cd web && python3 -m http.server 8130. Node 26, Python 3.14 present. web/package.jsonhas"type":"module"(Lane A) sonode web/js/**/*.jsruns as ESM.- F never edits another lane's internals. F adds wiring — new files it owns, and small, clearly-marked hook edits at the seams. Where a hook must live inside a lane's file, F first looks for the hook that lane already exposed (see contracts below); the lane owns its file, F owns the glue.
1. Files Lane F owns / creates
| path | purpose |
|---|---|
tools/qa.sh |
the QA gate runner (scaffold + citygen selfcheck + manifest validator) ✅ |
tools/qa/scaffold_check.mjs |
scaffold + PRNG determinism law + live lane-readiness matrix ✅ |
tools/shots.py |
Playwright tour-capture → docs/shots/v1_tour/ + contact sheet (ported from 90sDJsim tools/shots.py) |
tools/soak.py |
Playwright 10-minute scripted-walk soak (chunk/heap/leak/error gate) |
web/js/citygen/index.js |
barrel so B's import('./js/citygen/index.js') resolves to A's plan.js ✅ (§3.1) |
web/js/world/interior_mode.js |
interior bridge — B's shell ↔ C's buildInterior; owns the interior mode branch ✅ (§3.2) |
docs/V2_IDEAS.md |
parked-scope catalogue ✅ |
docs/shots/ |
reference shot tree (laneA…E/, v1_tour/, before/, references/) |
F-progress.md |
status report for fable |
The wiring is kept as focused F-owned modules (e.g. interior_mode.js) that the shell imports,
rather than logic sprinkled through B's files. Each seam edit inside index.html is one line,
commented [Lane F integration], and reversible. That keeps ownership clean: B owns the shell +
mode machine, C/D own their libraries, F owns the glue modules and the marked seams that call them.
2. Contracts F depends on (verify these as lanes land)
Pulled from CITY_SPEC + each lane prompt. The scaffold check's readiness matrix tracks presence; these are the shapes F wires against. If a lane ships a different shape, fix it here and in the wiring, not by guessing.
⚠ Corrected against the REAL landed code (2026-07-14). The bullets below are what the lanes actually shipped — several differ from the spec-derived guesses in an earlier draft (verified by reading each file + the Lane F self-audit workflow).
Lane A — web/js/citygen/plan.js (landed, verified)
generatePlan(citySeed) → CityPlan(schema in CITY_SPEC §Layer 1). <3ms, deterministic, JSON-clean.chunkIndex(plan) → { chunkSize, chunks }wherechunks["cx,cz"] = { lots:[id…], shops:[…], edges:[…] }(a plain object, not a callable; a lot may bucket into several chunks). Also exportschunkKey(cx,cz).web/js/core/registry.jsexportsSHOP_TYPES— per type{ label, facades:[<full filename>.jpg…], sign, interior, fittings:[…], storeys:[min,max], hours:{open,close} }.facadesare full filenames (facade-timber-teal.jpg), not bare keys — see the skins seam in §7.web/map.html— 2D plan viewer (verified rendering real towns).web/js/citygen/index.jsis the barrel Lane F added so the shell'simport('./js/citygen/index.js')resolves (§3.1).
Lane B — web/index.html + web/js/world/* (landed, verified live)
- Mode state machine
MODE ∈ {map, street, interior}(interior branch not yet used), one renderer. createChunkManager(plan, scene, ctx)— a factory, not a class; returns{ update(pos), warmup(pos), getColliders(x,z), setNight(bool), getDoorMeshes(), count, dispose() }. NoonChunkBuilt/onChunkDisposedlifecycle hooks — so §3.3 must drive citizens off the street graph, not chunk lifecycle (or F adds a hook; prefer graph-driven).- Door pick is wired via HUD:
createHUD({ onEnterShop: (shopId,name)=>enterShop(...) });enterShop()dispatchesprocity:enterShopand is otherwise a stub (hud.showToast('interiors coming')) — this is the seam F completes in §3.2. Interior returns viaprocity:exitShop. - HUD shows draws / tris / fps / seed / time-of-day (F reads these for the budget gate).
- Exposes
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins }(F builds thewindow.DBGharness hook on top of this — §4).
Lane C — web/js/interiors/interiors.js (landed, verified)
buildInterior(shop, THREE, opts={registry,archetype,stockAdapter}) → { group, spawn:{x,z,ry}, exits:[…], places:[…], dims, recipe:{counterPos,…}, dispose() }. Matches the wiring plan.- Caller must attach lot dims: pass
{ ...shop, lot: plan.lots.find(l=>l.id===shop.lot) }.dispose()frees tracked GPU resources (soak gate asserts baseline).
Lane D — web/js/citizens/* (landed, verified)
sim.jsexportsclass CitizenSim—new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet, group }). It is street-graph driven, not per-chunk: there is nospawnRoster/despawn/setDaySegment— build it once from the street graph and tick it in the loop.keepers.jsexportsclass KeeperManager—new KeeperManager({ camera, citySeed, fleet }), thenspawn(target, { x, z, ry, shopId, type }) → handle,remove(handle),disposeAll(),update(dt, playerPos). (NotspawnFor/disposeFor.)
Lane E — web/assets/manifest.json + pipeline/validate_manifest.py
- Manifest:
{depot, fittings:{id:{file,footprint,thumb}}, furniture:{…}, skins:{facade,sky,ground,wall,interior}}. python3 pipeline/validate_manifest.pygreen (F's gate 5 runs it).- B/C read the manifest for GLB upgrades; primitive fallbacks must still fire under
?noassets=1.
3. Wiring checklist (LANE_F §Wiring — expanded to exact steps)
All of this lives in web/js/world/wiring.js (F-owned) + the minimal hooks each lane exposes.
3.1 Plan source swap (A → B) ✅ DONE (verified live)
- Lane B already reads
?seed=N(default20261990), guards the citygen import, and surfaces the town name + seed in the start panel and HUD. But it imported./js/citygen/index.js, which did not exist (Lane A shippedplan.js), so it silently fell back to the 14-shop fixture. - Fix: Lane F added
web/js/citygen/index.js(barrel re-exportinggeneratePlan/chunkIndex/chunkKeyfromplan.js). Verified live: shell logs "using Lane A generatePlan", HUD shows the real town ("Boolarra Heads", 523 shops), 31 chunks stream. - Map key M is wired by Lane B to an in-shell minimap (not map.html). Fine for v1.
- Gate: plan determinism asserted green by
scaffold_check.mjs. Map-PNG determinism pending the shot harness + DBG hook (§4).
3.2 enterShop → interiors (B ↔ C) ✅ DONE (verified live, 2026-07-14)
Implemented as an F-owned bridge module web/js/world/interior_mode.js (createInteriorMode),
plus a thin marked seam in the shell. The bridge renders the interior into its own dark
THREE.Scene — the street scene is simply left frozen (not disposed → instant return, CITY_SPEC L3
"pause, not dispose"), which is cleaner than hiding B's ground+chunk meshes in a shared scene.
enterShop(shopId, name): // shell seam (was the toast stub)
shop = plan.shops.find(s => s.id === shopId) // id is numeric; matches door.shopId
lot = plan.lots.find(l => l.id === shop.lot) // attach {w,d} — Lane C sizes the room to it
interiorMode.enter({ ...shop, lot:{w,d} }, name); setMode('interior')
interiorMode.enter(shop): // F module
current = buildInterior(shop, THREE); scene.add(current.group) // Lane C
doorReturn = {x,y,z,ry of the player on the street}
camera → current.spawn (x, 1.6, z, ry); exitArmed = false
loop [MODE==='interior']: if interiorMode.update(dt,keys) leaveShop()
update: walk via current._debug.grid occ/bounds (mirrors interior_test.html) → renderer.render(ownScene)
exit arms once player steps >2m from the door, then fires within 1m of exits[].toStreet
leaveShop() / 'procity:exitShop' / Esc(unlock): // three exit routes, all → leaveShop
interiorMode.exit(): current.dispose() (frees GPU + unparents); camera → doorReturn
setMode('street') // pointer stayed locked → walking resumes at once
- Verified live (seed 20261990 "Boolarra Heads"): door → Toy Shop "Numbat Playthings"
renders (ivy wallpaper, stocked shelves, counter, exit doorway); exit state-machine correct
(no instant-exit at spawn; arms then fires at the door); leak-free — GPU memory 134 geo/37 tex
→ 217 loaded → back to 134/37 after
dispose(). Swept all 9 shop types (toy/video/pawn/ opshop/book/record/stall/dept/milkbar), every oneok+pathOK+leftClean, worst build 8.4ms (budget 50), zero console errors. - Gate: determinism (byte-identical placement per seed) is asserted by Lane C's own soak page;
memory-to-baseline after
dispose()confirmed above. Real pointer-lock walk-in is the one bit the automated browser can't drive — needs a human (or the DBG hook in §4).
3.3 Citizens + keepers (D)
Keepers ✅ DONE (verified live). Wired into the interior bridge (interior_mode.js), not the shell:
new KeeperManager({ camera, citySeed: plan.citySeed }) — no fleet passed → asset-free placeholder
actor (rig-fleet upgrade is later, with §3.4). On enter, find the counter interactable
(places.find(p => p.userData.keeperStand) — Lane C tags {x,z,ry} there) and
keepers.spawn(interior.group, { x, z, ry, shopId, type }); tick keepers.update(dt) each interior
frame (playerPos defaults to the camera); keepers.disposeAll() on exit, before the room disposes.
- Verified: placeholder keeper (11-mesh humanoid) stands at the counter pose (record shop:
{2.55,-2.46, ry 1.571}), turns to greet a nearby player (0.21 rad) and eases back when they leave (~0), disposes to 0. Leak-free: 5 enter/leave cycles over 5 shop types → after-leave memory constant at 96 geo/16 tex, net growth 0; 0 console errors.
CitizenSim (street peds) ✅ DONE (verified live). Happy surprise: plan.streets already IS the
graph — { nodes:[{id,x,z}], edges:[{id,a,b,width,kind}] } — so graph: plan.streets passes
straight in, no adapter needed (the runbook's earlier streetGraphFrom is unnecessary). In the
shell: new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets }),
setPopulation(140) (?pop= tunable), then in the street branch each frame:
setTimeOfDay((clk.seg + clk.frac)/6) + update(dt) — note update takes only dt and reads
this.camera.position internally (the runbook's update(dt,pos) was wrong). visibilitychange → setPaused. No fleet → asset-free placeholder(near)/impostor(mid) tiers; sim self-manages NEAR/MID/FAR
LOD off camera distance (not chunk-driven — chunks.js has no lifecycle hook).
- ⚠ gotcha (fixed):
getClock().houris a display string ('12:30'), sohour/24→ NaN → 0 peds. Use the numeric day fraction(seg + frac)/6instead. - Verified: 140 pop → 96 active midday (density curve;
~7at night), peds walk footpaths at ~1.5 m/s, deterministic per seed (Lane D property; F passes the same graph+seed). Budget: 1 near placeholder ≈ 11 draws (11 meshes), bounded byNEAR_MAX=24; but a hamlet never fills it — worst across all 6 main/high edges was near 3, ~191 composer draws (budget 300). Coexists with interiors+keepers (round-trip regression clean); 0 console errors. - Note for §3.4: placeholder near-tier is the draw cost; the rig-fleet upgrade (shared meshes +
impostor promotion) is what lets
?pop=rise well past 140. Until then 140 is the safe lively default.
3.4 Point B/C at E's manifest ⏳
- Shell loads
web/assets/manifest.jsonif present →window.PROCITY.manifest. B/C prefer manifest GLB upgrades over primitives; registry.js (Lane A) stays the type→skin source of truth, the manifest is the GLB-upgrade + validated-skin layer. ?noassets=1: shell setswindow.PROCITY.noassets = true; manifest load is skipped and every loader call is short-circuited to the primitive/flat-colour fallback. (F defines the flag; B/C/E checkwindow.PROCITY.noassetsbefore touchingloaders.js.)- Gate: asset-free —
?noassets=1full playthrough, zero crashes, town still legible.
3.5 Hours / closed shops (A data → B facade + C gate) ⏳
isOpen(shop, seg)fromshop.hours:[open,close]vs the current day segment (map 6 segments → hour). Closed ⇒ B renders dark windows + CLOSED plate (facade state); enterShop → locked toast.- Exactly one late-night shop per town (Lane A seeds a shop whose
hoursrun late). F asserts it exists:plan.shops.some(s => s.hours[1] >= 22)(tune threshold to A's convention). - Gate: at night, ≥1 open shop; closed shops visibly closed + un-enterable.
4. QA gates (all green ⇒ v1). tools/qa.sh --strict is the umbrella.
| # | gate | how | tool | state |
|---|---|---|---|---|
| 1 | Determinism | seed 1234 → identical map PNG + identical first-interior placement list, 2 runs/machines | scaffold_check.mjs (plan) + shots.py --seed 1234 (map PNG) + interior placement hash |
⏳ needs A,B,C |
| 2 | Soak | 10-min scripted walk, ≥30 chunks, ≥15 shops; renderer.info.memory geo/tex back to baseline after each interior; JS heap stable; 0 console errors |
tools/soak.py |
⏳ needs B,C |
| 3 | Budget | busiest intersection, midday: HUD ≤300 draws, ≤200k tris | read HUD via soak.py/browser |
⏳ needs B |
| 4 | Asset-free | ?noassets=1 full playthrough, zero crashes |
soak.py --noassets |
⏳ needs B |
| 5 | Selfchecks | node web/js/citygen/selfcheck.js + python3 pipeline/validate_manifest.py green |
tools/qa.sh |
✅ runner ready; ⏳ inputs |
| 6 | Shots | refresh docs/shots/*; add docs/shots/v1_tour/ (10 beauty shots) |
tools/shots.py |
⏳ needs B,C |
DBG hook F requests from Lane B (for gates 1–4,6)
The house shot/soak harnesses drive the game through a debug object. Ask Lane B to expose, on
window.DBG (guarded, only when ?dbg=1):
DBG.ready→ true once first chunks built + assets settled (soak/shots wait on it).DBG.shot(name)→ snap camera to a named bookmark (street_noon,arcade,market_square,milkbar_dusk,night_neon, …); returns after the frame settles.DBG.teleport(x,z,ry)andDBG.setSegment(seg)→ drive the soak walk + time of day.DBG.enterShop(shopId)/DBG.exitShop()→ scripted interior visits (shotrecord_interior).DBG.info()→{drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode}(budget/soak read this). This mirrors 90sDJsim'swindow.DBG.ST/window.DBG.shot()(90sDJsim/tools/shots.py:31-33). Until it exists,shots.py/soak.pydegrade to booting the default view and warn.
5. Order of operations once lanes land
- ✅
node tools/qa/scaffold_check.mjs— A's plan determinism + registry confirmed present. - ✅ Wire §3.1 (plan swap) → generated seed boots on the shell (barrel + skins fix, verified live).
- ✅ Wire §3.2 (interiors) → door opens, enter/walk/exit + dispose verified across all 9 types.
- ✅ Wire §3.3 (citizens + keepers) → keeper greets at the counter; peds walk the graph, budget-safe.
- ⏳ Wire §3.4 (manifest +
?noassets) — also the rig-fleet upgrade. Then §3.5 (hours). Commit. - ⏳ Run gates 1–6; capture
docs/shots/v1_tour/; write final numbers intoF-progress.md. - ⏳
tools/qa.sh --strictgreen ⇒ tag v1.
7. Live smoke-test findings (2026-07-14, real generated town, seed 20261990)
Booted the wired game in a browser and drove the camera via window.PROCITY. State:
Boolarra Heads · 523 shops · 711 lots · 31 chunks live · 31 door meshes · 24.7k tris.
- ✅ Seed → real town works after the barrel fix (§3.1). Contiguous strip, awnings, verandah posts, doors, windows, gum trees, sky dome, 6-segment day cycle — all render.
- ✅ Facade skins seam (FIXED).
skins.js facadeMatbuiltassets/gen/facade-<name>.jpgbut Lane A'sfacadeSkinis the full filename (facade-fibro-blue.jpg) → every facade requestedfacade-facade-…jpg.jpg(404 → flat colour). Lane F normalized the name at thefacadeMatchokepoint (strip a leadingfacade-+ image extension; idempotent for bare keys). Verified: facade JPEGs now return 200 OK, town is textured. Root cause is a cross-lane naming contract ambiguity — registry stores filenames, skins.js expected bare keys. Worth a CITY_SPEC line so ground/awning/interior skins don't hit the same trap. - ⚠ Budget: ~334 draw calls at spawn (radius 3), over the CITY_SPEC ≤300. Tris fine (~25k). Options for Lane B: drop default radius to 2, or merge more per chunk. Gate 3 will fail until shaved.
- ⏳ Interiors not wired (§3.2), citizens not wired (§3.3), manifest/
?noassetsnot wired (§3.4) — the remaining integration passes.
8. Open questions for the lanes (surface, don't guess)
- B: will you expose the
window.DBGhook (§4) and theChunkManagerlifecycle callbacks (§3.3)? If not, F wires citizens via a MutationObserver-style scan — slower, please prefer callbacks. - A: which field marks the "open late" shop, and what's the
hoursencoding (24h ints? segment idx)? - C: does
placestag the counter slot with a stablekind:'counter'so keepers find it? - E: is
?noassetshonored inside your manifest-consuming code, or does F gate it at the shell? - D: roster spawn/despawn API name + does it accept a chunk key directly?