PROCITY/docs/LANES/LANE_F_NOTES.md
m3ultra aa4ed4fdca Lane F: integration — interiors + keepers + street peds + QA harness
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>
2026-07-14 14:29:03 +10:00

18 KiB
Raw Blame History

LANE F — NOTES (integration runbook + QA gates)

Lane F is integration + QA. It runs after AE 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 AE 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.json has "type":"module" (Lane A) so node web/js/**/*.js runs 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 } where chunks["cx,cz"] = { lots:[id…], shops:[…], edges:[…] } (a plain object, not a callable; a lot may bucket into several chunks). Also exports chunkKey(cx,cz).
  • web/js/core/registry.js exports SHOP_TYPES — per type { label, facades:[<full filename>.jpg…], sign, interior, fittings:[…], storeys:[min,max], hours:{open,close} }. facades are 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.js is the barrel Lane F added so the shell's import('./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() }. No onChunkBuilt/onChunkDisposed lifecycle 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() dispatches procity:enterShop and is otherwise a stub (hud.showToast('interiors coming')) — this is the seam F completes in §3.2. Interior returns via procity: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 the window.DBG harness 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.js exports class CitizenSimnew CitizenSim({ renderer, scene, camera, citySeed, graph, fleet, group }). It is street-graph driven, not per-chunk: there is no spawnRoster/despawn/setDaySegment — build it once from the street graph and tick it in the loop.
  • keepers.js exports class KeeperManagernew KeeperManager({ camera, citySeed, fleet }), then spawn(target, { x, z, ry, shopId, type }) → handle, remove(handle), disposeAll(), update(dt, playerPos). (Not spawnFor/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.py green (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 (default 20261990), 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 shipped plan.js), so it silently fell back to the 14-shop fixture.
  • Fix: Lane F added web/js/citygen/index.js (barrel re-exporting generatePlan/chunkIndex/chunkKey from plan.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 one ok+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().hour is a display string ('12:30'), so hour/24 → NaN → 0 peds. Use the numeric day fraction (seg + frac)/6 instead.
  • Verified: 140 pop → 96 active midday (density curve; ~7 at 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 by NEAR_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.json if 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 sets window.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 check window.PROCITY.noassets before touching loaders.js.)
  • Gate: asset-free — ?noassets=1 full playthrough, zero crashes, town still legible.

3.5 Hours / closed shops (A data → B facade + C gate)

  • isOpen(shop, seg) from shop.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 hours run 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 14,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) and DBG.setSegment(seg) → drive the soak walk + time of day.
  • DBG.enterShop(shopId) / DBG.exitShop() → scripted interior visits (shot record_interior).
  • DBG.info(){drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode} (budget/soak read this). This mirrors 90sDJsim's window.DBG.ST / window.DBG.shot() (90sDJsim/tools/shots.py:31-33). Until it exists, shots.py/soak.py degrade to booting the default view and warn.

5. Order of operations once lanes land

  1. node tools/qa/scaffold_check.mjs — A's plan determinism + registry confirmed present.
  2. Wire §3.1 (plan swap) → generated seed boots on the shell (barrel + skins fix, verified live).
  3. Wire §3.2 (interiors) → door opens, enter/walk/exit + dispose verified across all 9 types.
  4. Wire §3.3 (citizens + keepers) → keeper greets at the counter; peds walk the graph, budget-safe.
  5. Wire §3.4 (manifest + ?noassets) — also the rig-fleet upgrade. Then §3.5 (hours). Commit.
  6. Run gates 16; capture docs/shots/v1_tour/; write final numbers into F-progress.md.
  7. tools/qa.sh --strict green ⇒ 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 facadeMat built assets/gen/facade-<name>.jpg but Lane A's facadeSkin is the full filename (facade-fibro-blue.jpg) → every facade requested facade-facade-…jpg.jpg (404 → flat colour). Lane F normalized the name at the facadeMat chokepoint (strip a leading facade- + 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/?noassets not wired (§3.4) — the remaining integration passes.

8. Open questions for the lanes (surface, don't guess)

  • B: will you expose the window.DBG hook (§4) and the ChunkManager lifecycle 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 hours encoding (24h ints? segment idx)?
  • C: does places tag the counter slot with a stable kind:'counter' so keepers find it?
  • E: is ?noassets honored 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?