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

258 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](../../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 CitizenSim`** — `new 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 KeeperManager`** — `new 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?