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>
This commit is contained in:
m3ultra 2026-07-14 14:29:03 +10:00
parent 5313402e43
commit aa4ed4fdca
13 changed files with 1391 additions and 0 deletions

134
F-progress.md Normal file
View File

@ -0,0 +1,134 @@
# PROCITY — Lane F progress (INTEGRATION & QA)
**Session:** 2026-07-14 · **Author:** PROCITY-F · **For review by:** fable
---
## TL;DR
All five lanes (AE) landed; F stood up the QA harness, **adversarially reviewed and fixed its own
tooling**, then wired the lanes into **a walkable, living town** — interiors, shopkeepers, and street
crowds all integrated and verified live:
1. **Interiors — every door opens (§3.2).** A door opens into a themed, seeded, self-lit 90s-AU
interior; walk it, walk back out (or Esc), room disposes. Verified across **all 9 shop types**,
**leak-free**, worst build **8.4 ms**. F-owned bridge [`web/js/world/interior_mode.js`](web/js/world/interior_mode.js).
2. **Shopkeepers (§3.3a).** A keeper stands at the counter of every shop and **turns to greet** you;
disposed with the room, **leak-free over 5 cycles**. (Asset-free placeholder; rig-fleet upgrade later.)
3. **Street crowds (§3.3b).** Deterministic pedestrians walk the footpaths (`graph = plan.streets`
directly), density follows time-of-day (busy midday, empty at night), LOD-tiered. **Draw budget
holds** — worst main-street view **~191 draws** (budget 300). Shell wiring in [`index.html`](web/index.html).
4. **Plan swap (§3.1)** + **facade skins fix** (prior pass): boots on Lane A's real town, textured.
`tools/qa.sh` is **GREEN (4/4 gates, 0 skipped)**. Remaining: manifest GLB upgrades + `?noassets`
(§3.4 — also upgrades keepers/peds to shared rigs), hours/closed-shops (§3.5), draw-budget shave.
Scoped in [`docs/LANES/LANE_F_NOTES.md`](docs/LANES/LANE_F_NOTES.md) §3.4§3.5,§7.
---
## Lane readiness — ALL COMPLETE ✅ (`node tools/qa/scaffold_check.mjs`)
| Lane | status | notes |
|---|---|---|
| A CITYGEN | **5/5** | plan.js, registry.js, names.js, selfcheck.js, map.html — determinism gate green, verified in-browser |
| B STREETSCAPE | **4/4** | index.html shell, chunks.js streamer, buildings.js, fixture_plan.js — walkable town verified live |
| C INTERIORS | **2/2** | interiors.js (`buildInterior`), interior_test.html |
| D CITIZENS | **3/3** | rigs.js, sim.js (`CitizenSim`), keepers.js (`KeeperManager`), citizens_test.html |
| E ASSETS | **3/3** | manifest.json (validates green), validate_manifest.py, AUDIT.md + pipeline |
---
## What F built / did this session
### QA harness (F-owned, `tools/`) — trustworthy, all green
| file | what | state |
|---|---|---|
| `tools/qa.sh` | gate runner; `--strict` = v1 umbrella | ✅ 4/4 green |
| `tools/qa/scaffold_check.mjs` | scaffold + PRNG determinism (golden-locked) + live readiness matrix + plan-determinism | ✅ |
| `tools/qa/consistency_check.mjs` | cross-lane gate: plan ↔ registry ↔ assets ↔ manifest | ✅ |
| `tools/shots.py` · `tools/soak.py` | Playwright beauty-tour + 10-min soak/budget/`--noassets` (gates 2,3,4,6) | ✅ ready; need Lane B `window.DBG` hook (§4) |
### Adversarial self-review (Workflow, 23 agents)
Ran a 4-auditor + verifier workflow against F's own scripts + runbook. It confirmed **14 real
defects in F's tooling** (false-confidence checks + wrong API assumptions). **All applied:**
- `scaffold_check`: JSON round-trip was a **tautology** (fixed → real deep-compare w/ `-0` norm);
Math.random-ban regex had **false negatives** (fixed → anchored to comments); irange golden check
re-seeded per draw (fixed → single stream).
- `consistency_check`: added a **storeys-vs-registry-range** check, fixed a dead late-night branch,
removed a dead metric.
- `soak.py`: heap heuristic no longer silently passes when heap isn't reported; shop count now
requires **verified interior loads** (not just `enterShop` calls); `--minutes` now honored.
- `shots.py`: fixed an `IndexError` on a trailing `--seed`.
- **Runbook** (`LANE_F_NOTES.md`): corrected against the **real landed APIs**`chunkIndex` returns
`{chunkSize,chunks}` (not callable); chunks is a `createChunkManager` **factory** with no lifecycle
hooks; `CitizenSim` is **graph-driven, not per-chunk**; keepers use `spawn/remove/disposeAll`.
### Integration passes (verified live in-browser)
- **`web/js/world/interior_mode.js`** (new F-owned bridge) + thin marked seam in `index.html`
**§3.2 interiors: every door opens.** `enterShop` looks the shop up in the plan, attaches its lot
dims, calls Lane C `buildInterior`, and switches to a new `interior` mode that renders the room in
its own scene and walks it via the room's occupancy grid. Exit on walk-out / `procity:exitShop` /
Esc → `dispose()` + player restored to the door. **Verified:** door → Toy Shop renders; exit
state-machine correct; **leak-free** (134 geo/37 tex → 217 loaded → 134/37 after dispose); all **9
shop types** `ok+pathOK`, worst build **8.4 ms**; **0 console errors**.
- **Keepers (§3.3a)** — wired into `interior_mode.js`: `KeeperManager` (no fleet → placeholder actor),
spawned at the counter stand pose (`places.find(p=>p.userData.keeperStand)`), ticked in the interior
loop, `disposeAll()` on exit. **Verified:** 11-mesh keeper at the counter, **greets** the player
(turns 0.21 rad, eases back), **leak-free over 5 cycles/5 types** (net growth 0).
- **Citizens (§3.3b)**`CitizenSim` in `index.html`: `graph = plan.streets` (no adapter needed),
`setPopulation(140)` (`?pop=`), `setTimeOfDay((seg+frac)/6)` + `update(dt)` in the street loop,
`visibilitychange→setPaused`. Fixed a NaN bug (`getClock().hour` is a string). **Verified:** 96
active midday, peds walk footpaths ~1.5 m/s, budget-safe (worst **~191 draws** across all main edges).
- **`web/js/citygen/index.js`** (barrel) → game boots on Lane A's real generated town (was the
fixture). Verified: console "using Lane A generatePlan", HUD "Boolarra Heads · 493 shops".
- **`web/js/world/skins.js`** (1-line chokepoint fix) → `facadeMat` normalizes full-filename skins.
Verified: `facade-*.jpg` requests now **200 OK**, town textured (was all flat-grey).
### Docs (F-owned)
- `docs/LANES/LANE_F_NOTES.md` — integration runbook (real APIs, wiring steps, QA gates, DBG hook request).
- `docs/V2_IDEAS.md` — parked scope (content firehose, override layer, weather, venue, …).
- `docs/shots/` — reference-shot tree (the other lanes are already dropping acceptance shots into it).
---
## Findings for the lanes (F flags; the two blocking-visual ones are already fixed)
| # | lane | finding | status |
|---|---|---|---|
| 1 | B | shell imported non-existent `js/citygen/index.js` → ran on fixture | **fixed by F** (barrel) |
| 2 | B | `skins.js facadeMat` double-wrapped full-filename skins → facades 404 | **fixed by F** (normalize) |
| 3 | B | street measures **~334 draw calls** at spawn (radius 3) — over CITY_SPEC ≤300 | open — shave (radius 2 or more merging) |
| 4 | A | ~10% of shop names have a **doubled possessive** ("Tex's's Toys") — `names.js` | open (cosmetic) |
| 5 | A | some shops have **storeys outside the registry type range** (e.g. video at 2, range [1,1]) | open — confirm corner-anchor intent |
| — | contract | `facadeSkin` is a full filename in the registry but skins.js expected a bare key | **worth a CITY_SPEC line** so ground/awning/interior skins avoid the same trap |
---
## Remaining integration (scoped in LANE_F_NOTES §3; ready to execute)
1. ✅ ~~**Interiors (§3.2)**~~**DONE**.
2. ✅ ~~**Citizens + keepers (§3.3)**~~**DONE** (both verified live; see Integration passes above).
3. **Manifest + `?noassets` (§3.4)** — GLB upgrades from `manifest.json`; primitive-fallback flag.
Doubles as the **rig-fleet upgrade** that makes keepers/peds cheap (shared meshes) so `?pop=` can rise.
4. **Hours / closed shops (§3.5)** and the **draw-budget shave** (finding 3).
5. **Full QA gates 14,6** once Lane B exposes the `window.DBG` hook (§4) my harnesses drive.
*Next natural step is §3.4 (manifest + `?noassets`) — it's the asset-free gate AND the fleet upgrade
that upgrades placeholder keepers/peds to shared GLB rigs. §3.5 (hours) is a small data-driven follow-on.*
**Note on verification:** pointer-lock doesn't engage in the automated browser, so a real *walk* is
best playtested by a human (`cd web && python3 -m http.server 8130` → `localhost:8130?seed=20261990`).
F drives/inspects via `window.PROCITY` + JS for programmatic checks.
## Housekeeping
- Nothing committed (all working tree). F's edits are minimal, commented `[Lane F integration]`,
reversible. **New F-owned file:** `web/js/world/interior_mode.js` (interior + keeper bridge). Shell
seam edits in `index.html`: interior wiring (import, bridge create, `enterShop`/`leaveShop`, loop
branch, `setMode` lock, Esc-exit, M/P guards, `window.PROCITY` hooks) + citizens (import, `CitizenSim`
create + `setPopulation`, street-loop `setTimeOfDay`+`update`, `visibilitychange` pause). Also edited:
`skins.js` (facade fix, prior pass). Suggested commit once reviewed — **use a pathspec** (lanes share
one tree, see memory): `git commit -- web/js/world/interior_mode.js web/index.html web/js/world/skins.js
web/js/citygen/index.js tools docs/LANES/LANE_F_NOTES.md docs/V2_IDEAS.md docs/shots F-progress.md`
msg `Lane F: integration — interiors + keepers + street peds + QA harness + barrel + skins fix`.
- A local `python3 -m http.server 8130` is running for the smoke test (harmless; kill when done).
- Lane prompts hard-code `/Users/jing/Documents/PROCITY`; real path is `/Users/m3ultra/Documents/procity`.

257
docs/LANES/LANE_F_NOTES.md Normal file
View File

@ -0,0 +1,257 @@
# 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?

92
docs/V2_IDEAS.md Normal file
View File

@ -0,0 +1,92 @@
# PROCITY — V2 IDEAS (the parking lot)
*Written by Lane F. Everything discovered during v1 but deliberately **not** built, so v1 stays
"the system: plan → streets → doors that open → themed interiors → NPCs." Each item notes where
the seam already exists so v2 is wiring, not rework. Ordered roughly by leverage.*
---
## Content firehose (the reason the system exists)
- **dig.js wiring + BaseGod real-item stock.** v1 interiors show *visual* stock only (canvas
cardboard sleeves/spines via Lane C's `stock.js`). The seam is Lane C's `stockAdapter(shop,
slotKind) => texture/mesh` hook — leave it, then in v2 feed it from GODVERSE (recordgod/toygod/
bookgod/mediagod: 44k solid-object 3D candidates, 185k vinyl w/ images —
`~/Documents/dealgod/GODVERSE.md`). Port `90sDJsim/web/world/dig.js` (self-contained crate-riffle,
`onBuy/getCash/spawnClerk/spawnFittings` callback API, already ported across 3 games) into a shop
when you walk to a bin. Real economy = thriftgod `mint()` (`server.py:792`) — per-shop-type
profiles, gem bands, CBD-distance curation, `TABLESAMPLE` over real listings.
- **Real-OSM plan import (thriftgod `build_city()` route).** Swap the synthetic
`generatePlan(seed)` for real geography: thriftgod `server.py:433` runs an Overpass query for AU
secondhand/charity/music/book/toy shops → `city_source.json` cache → dedupe → nearest-capital →
suburb inference → parody names → seeded hours (~2,918 real shops). PROCITY's Layer-1 schema was
built to accept this — `generatePlan` becomes one of two plan *sources* behind the same CityPlan
contract, so Lanes BF never change.
## Authoring / override layer (the thriftgod "big idea")
- **In-game layout editor + override tables.** Every interior is deterministic from `shop.seed`;
thriftgod's Postgres `layout_shop`/`layout_area`/`layout_asset` tables hold optional
hand-authored overrides (NULL = procedural). The E1E4 in-game editor writes them
(`thriftgod/EDITOR_PLAN.md`). v2: add an override store (localStorage → later a tiny backend) and
a `?edit=1` mode. The seeded-everything law (CITY_SPEC) is exactly what makes this possible —
don't break it.
## World depth
- **Interior-mapping window shader.** v1 fakes shop windows two ways: Lane B tints glass + warm
emissive at night; Lane C puts a backdrop plane inside. v2: the real Vuntra trick — a parallax
interior-mapping shader so a passing pedestrian sees a plausible room through the glass without
building it. Stretch goal already flagged in CITY_SPEC §Layer 3.
- **Weather + seasons.** Sky dome already seed-picks from `sky-*.jpg` (summer storm, monsoon, grey
drizzle, heat haze already in `web/assets/gen/`). v2: rain particles + wet-ground roughness,
wind on awnings/trees, seasonal palette shift. Cheap, high vibe-per-byte.
- **Trams / buses on a loop.** A scripted vehicle following main-street edges from the CityPlan
graph; a bus-stop shelter prop is already on Lane E's MeshGod shortlist. Instanced, one path,
no traffic sim in v2 — just life on the street.
- **Venue district + gig system (90sDJsim crossover).** The warehouse fringe (Lane A seeds it as
future flea-market/venue territory) becomes a live-music block; port 90sDJsim `buildVenue()`
(~line 1094) + its rigged-front-row/bobbing-box crowd. Natural bridge back to the parent game.
## Simulation & NPCs
- **Deeper citizen sim.** v1 is layered LOD (rigged near / impostor mid / culled far) walking
footpath lanes with seeded identity. v2: needs/schedules (home→shop→square by time of day),
shop patronage that reads `hours`, queues at the open late-night shop, buskers in the arcade.
Vuntra's million-NPC layering at hamlet scale — the tier machinery is already there to lean on.
- **Multiplayer presence.** Other players as citizens (the fleet impostor path already renders
cheap remote avatars). Presence-only first (see who's in town / same shop), no shared economy —
that rides on the content/override backend above.
## Assets & polish
- **Corner-building second facade.** Lane E flags the `-side` variant question; v2 gives corner
lots a proper second face + wrap-around verandah instead of reusing the front skin.
- **GLB depot expansion.** v1 ships the audit-normalized shortlist + primitive fallbacks. v2: the
full `~/Documents/3D=models/` library normalized to house GLB law, MeshGod hero props batch
(listening booth, till, VHS rewinder, spinner rack, milkshake mixer, arcade cabinet, band-poster
easel, giant novelty record, bus shelter — `pipeline/MESHGOD_BATCH.md`, John's go required).
- **Audio.** Street ambience by district, muffled shop music that swells through an open door,
the milk-bar bell. No audio in v1 at all — greenfield.
## Tech / QA carryover
- **GFX tiers.** 90sDJsim's era-tiered `GFX_TIERS` (`~line 167`) — adopt only if a real device
misses budget; CITY_SPEC says don't gold-plate now.
- **Determinism across engines.** If real-OSM or an authored backend enters, extend Lane F's
determinism gate to pin plan + first-interior hashes per seed in CI.
- **Shadow/GI.** v1 = one 2048 directional map following the player (or none — measured). v2:
cascaded shadows or baked/faked GI for the arcade + interiors.
---
*Seam summary: `stockAdapter` (Lane C), plan-source behind the CityPlan contract (Lane A), the
`shop.seed` determinism law (core), and Lane B's mode state machine are the four hinges every item
above swings on. They exist in v1 specifically so none of this is a rewrite.*

34
docs/shots/README.md Normal file
View File

@ -0,0 +1,34 @@
# docs/shots — visual regression + devlog reference tree
Owned by Lane F (each lane also drops its own acceptance screenshots here).
```
docs/shots/
laneA/ … A: 5 seeds rendered in map.html (spine, arcade, market, milkbars)
laneB/ … B: 3 fixed street cameras (P-key shot harness)
laneC/ … C: 9 types × 5 archetypes interior grid
laneD/ … D: near / mid tiers + busy main street
laneE/ … E: skin contact sheets (new skins vs originals)
v1_tour/ … F: the 10 beauty shots (see tools/shots.py TOUR) — the devlog set
before/ … F: last known-good capture, for side-by-side regression
references/ … real-world reference photos named per shot (street_noon.jpg, arcade.png, …)
contact.html … generated by tools/shots.py — this-run | before | reference
```
## Capture (Lane F, once Lane B's shell + `window.DBG` hook land)
```
cd web && python3 -m http.server 8130 # terminal 1
python3 -m venv tools/.venv # once
tools/.venv/bin/pip install playwright
tools/.venv/bin/python -m playwright install chromium
tools/.venv/bin/python tools/shots.py v1_tour --seed 20261990 # terminal 2
open docs/shots/contact.html
```
Before a risky change, snapshot the good state into `before/`
(`tools/.venv/bin/python tools/shots.py before`), then re-run and eyeball `contact.html`.
The v1 tour list (LANE_F_INTEGRATION §Shots): main-street noon, arcade, market square,
milkbar dusk, night neon, warehouse fringe, residential collar, record interior, opshop
interior, busy crossroads. Adjust `TOUR` in `tools/shots.py` as the town evolves.

View File

View File

View File

80
tools/qa.sh Executable file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# PROCITY Lane F — the QA gate runner (CITY_SPEC / LANE_F acceptance).
#
# tools/qa.sh run every gate whose inputs have landed; skip the rest
# tools/qa.sh --strict also FAIL on any still-pending lane deliverable (use at v1 sign-off)
#
# Design: F runs before AE finish. A gate whose target file does not exist yet is reported
# SKIPPED (yellow), not failed — so this is safe to run continuously as lanes land. Once every
# lane is in, `--strict` turns the skips into hard failures: that is the v1 readiness gate.
#
# Gates:
# 1. scaffold_check.mjs — scaffold + PRNG determinism law + lane-readiness matrix (F-owned)
# 2. citygen selfcheck — node web/js/citygen/selfcheck.js (Lane A ships it)
# 3. manifest validator — python3 pipeline/validate_manifest.py (Lane E ships it)
#
# Browser-driven gates (determinism-PNG, 10-min soak, budget-HUD, ?noassets run) need the running
# game; they live in tools/soak.md + tools/shots.md and run once Lane B's index.html lands.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
STRICT=0
[ "${1:-}" = "--strict" ] && STRICT=1
pass=0; failn=0; skip=0
c_red=$'\033[31m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_bold=$'\033[1m'; c_off=$'\033[0m'
hr() { printf '%s\n' "────────────────────────────────────────────────────────"; }
run_gate() { # run_gate "name" cmd...
local name="$1"; shift
printf '%s▶ %s%s\n' "$c_bold" "$name" "$c_off"
if "$@"; then printf '%s ✓ PASS%s %s\n\n' "$c_grn" "$c_off" "$name"; pass=$((pass+1));
else printf '%s ✗ FAIL%s %s\n\n' "$c_red" "$c_off" "$name"; failn=$((failn+1)); fi
}
skip_gate() { # skip_gate "name" "reason"
printf '%s▶ %s%s\n' "$c_bold" "$1" "$c_off"
printf '%s ⊘ SKIP%s %s\n\n' "$c_yel" "$c_off" "$2"
skip=$((skip+1))
}
printf '%sPROCITY QA GATES%s (%s)\n' "$c_bold" "$c_off" "$([ $STRICT = 1 ] && echo strict || echo lenient)"
hr
# ── Gate 1: scaffold + determinism + readiness (always) ──────────────────────
run_gate "scaffold_check (scaffold · PRNG determinism · readiness matrix)" \
node tools/qa/scaffold_check.mjs
# ── Gate 1b: cross-lane consistency (plan ↔ registry ↔ assets ↔ manifest) ────
# Self-skips (exit 0) until Lane A's plan.js + registry.js exist.
run_gate "consistency (plan ↔ registry ↔ assets ↔ manifest)" \
node tools/qa/consistency_check.mjs
# ── Gate 2: Lane A citygen selfcheck ─────────────────────────────────────────
if [ -f web/js/citygen/selfcheck.js ]; then
run_gate "citygen selfcheck (determinism · <100ms · lots/shops integrity)" \
node web/js/citygen/selfcheck.js
else
skip_gate "citygen selfcheck" "web/js/citygen/selfcheck.js not landed yet (Lane A)"
fi
# ── Gate 3: Lane E manifest validator ────────────────────────────────────────
if [ -f pipeline/validate_manifest.py ]; then
run_gate "manifest validator (files exist · footprints sane · JSON parses)" \
python3 pipeline/validate_manifest.py
else
skip_gate "manifest validator" "pipeline/validate_manifest.py not landed yet (Lane E)"
fi
# ── Summary ──────────────────────────────────────────────────────────────────
hr
printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d skipped%s\n' \
"$c_bold" "$c_off" "$c_grn" "$pass" "$c_off" "$c_red" "$failn" "$c_off" "$c_yel" "$skip" "$c_off"
if [ "$failn" -gt 0 ]; then
printf '%s● QA RED%s — fix the failing gate(s) above.\n' "$c_red" "$c_off"; exit 1
fi
if [ "$STRICT" = 1 ] && [ "$skip" -gt 0 ]; then
printf '%s● QA NOT READY%s — %d gate(s) still pending; not v1 yet.\n' "$c_yel" "$c_off" "$skip"; exit 2
fi
printf '%s● QA GREEN%s — every landed gate passed.\n' "$c_grn" "$c_off"; exit 0

View File

@ -0,0 +1,137 @@
#!/usr/bin/env node
// PROCITY Lane F — cross-lane consistency gate (plan ↔ registry ↔ assets ↔ manifest).
// Plain node, zero deps. Run: node tools/qa/consistency_check.mjs [seed ...]
//
// This is the seam Lane A's own selfcheck can't cover: does the generated PLAN agree with the
// shared REGISTRY, the asset SET, and (when present) Lane E's MANIFEST? These are the mismatches
// that make a shop render wrong or a door open onto the wrong theme. Runs against real generated
// output for several seeds. Skips cleanly (exit 0) until Lane A's plan.js + registry.js exist.
import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..', '..');
const R = (...p) => join(ROOT, ...p);
const PLAN = R('web/js/citygen/plan.js');
const REG = R('web/js/core/registry.js');
if (!existsSync(PLAN) || !existsSync(REG)) {
console.log('consistency_check: plan.js and/or registry.js not landed yet — skipping (Lane A).');
process.exit(0);
}
let fails = 0, warns = 0;
const fail = (m) => { fails++; console.log(` \x1b[31m✗\x1b[0m ${m}`); };
const warn = (m) => { warns++; console.log(` \x1b[33m!\x1b[0m ${m}`); };
const ok = (m) => console.log(` \x1b[32m✓\x1b[0m ${m}`);
const head = (m) => console.log(`\n\x1b[1m${m}\x1b[0m`);
const { generatePlan, chunkIndex } = await import(PLAN);
const reg = await import(REG);
const SHOP_TYPES = reg.SHOP_TYPES;
const genDir = R('web/assets/gen');
const genFiles = new Set(existsSync(genDir) ? readdirSync(genDir) : []);
// Optional Lane E manifest.
let manifest = null;
if (existsSync(R('web/assets/manifest.json'))) {
try { manifest = JSON.parse(await import('node:fs').then(fs => fs.readFileSync(R('web/assets/manifest.json'), 'utf8'))); }
catch (e) { warn(`manifest.json present but unparseable: ${e.message}`); }
}
const seeds = process.argv.slice(2).map(Number).filter(Number.isFinite);
if (!seeds.length) seeds.push(1234, 20261990, 7, 99, 424242);
// Every type in a facade pool must map to a real file. Check the registry once.
head('REGISTRY ↔ ASSETS');
{
const skins = new Set();
for (const [type, def] of Object.entries(SHOP_TYPES)) {
const pool = def.facades || def.facade || [];
if (!pool.length) warn(`registry type "${type}" has no facade pool`);
for (const f of pool) {
skins.add(f);
if (!genFiles.has(f)) fail(`registry ${type} facade "${f}" missing from web/assets/gen/`);
}
}
if (fails === 0) ok(`all ${skins.size} registry facade skins exist in web/assets/gen/`);
}
// Per-seed plan integrity + agreement with registry.
const typeKeys = new Set(Object.keys(SHOP_TYPES));
for (const seed of seeds) {
head(`PLAN(${seed})`);
const plan = generatePlan(seed);
const lotById = new Map(plan.lots.map(l => [l.id, l]));
const edgeIds = new Set((plan.streets?.edges || []).map(e => e.id));
let localFail = fails;
// shops → valid type, valid lot, skin in pool + on disk, sane hours, storeys in registry range
let lateCount = 0, skinOffPool = 0, dblApostrophe = 0, storeysOff = 0;
for (const s of plan.shops) {
if (!typeKeys.has(s.type)) fail(`shop ${s.id}: type "${s.type}" not in registry SHOP_TYPES`);
if (!lotById.has(s.lot)) fail(`shop ${s.id}: lot ${s.lot} does not exist`);
if (s.facadeSkin && !genFiles.has(s.facadeSkin))
fail(`shop ${s.id}: facadeSkin "${s.facadeSkin}" not in web/assets/gen/`);
const def = SHOP_TYPES[s.type] || {};
const pool = def.facades || [];
if (s.facadeSkin && pool.length && !pool.includes(s.facadeSkin)) skinOffPool++;
const h = s.hours || [];
if (h.length === 2) {
if (h[0] < 0 || h[0] > 24 || h[1] < 0 || h[1] > 24) fail(`shop ${s.id}: hours ${JSON.stringify(h)} out of range`);
if (h[1] >= 22) lateCount++; // Lane A encodes hours as simple 24h ints (open<close, no wrap)
}
// storeys must sit inside the type's registry [min,max] — unless it's a taller corner anchor.
const sr = def.storeys;
if (Array.isArray(sr) && sr.length === 2 && (s.storeys < sr[0] || s.storeys > sr[1])) storeysOff++;
if (/[']s[']s\b/.test(s.name || '')) dblApostrophe++;
}
if (skinOffPool) warn(`${skinOffPool} shop(s) use a facadeSkin outside their registry type pool `
+ `(ok if Lane A widened pools intentionally; else a plan↔registry drift)`);
if (storeysOff) warn(`${storeysOff} shop(s) have storeys outside their registry type range `
+ `(ok if these are the CITY_SPEC "occasional 3-storey corner anchor"; else a plan↔registry drift — ask Lane A)`);
if (dblApostrophe) warn(`${dblApostrophe} shop name(s) have a doubled possessive ("X's's") — Lane A names.js`);
if (lateCount === 0) warn(`no open-late shop (close ≥22:00) — LANE_F §3.5 wants at least one`);
else ok(`${lateCount} open-late shop(s) (close ≥22:00) — §3.5 satisfied`);
// lots → valid frontEdge, valid block
let badEdge = 0;
for (const l of plan.lots) if (l.frontEdge != null && edgeIds.size && !edgeIds.has(l.frontEdge)) badEdge++;
if (badEdge) fail(`${badEdge} lot(s) reference a frontEdge id that is not in streets.edges`);
// chunkIndex covers every lot. Real API (Lane A): chunkIndex(plan) -> {chunkSize, chunks},
// chunks["cx,cz"] = {lots:[id…], shops:[…], edges:[…]}; a lot may bucket into several chunks.
if (typeof chunkIndex === 'function') {
const idx = chunkIndex(plan);
const chunks = idx?.chunks || idx; // tolerate either {chunks} wrapper or a bare map
const covered = new Set();
for (const cell of Object.values(chunks)) {
if (cell && typeof cell === 'object')
for (const cl of (cell.lots || [])) covered.add(cl?.id ?? cl);
}
const missing = plan.lots.filter(l => !covered.has(l.id)).length;
if (missing === 0) ok(`chunkIndex covers all ${plan.lots.length} lots (chunkSize=${idx.chunkSize ?? '?'})`);
else fail(`chunkIndex misses ${missing}/${plan.lots.length} lots`);
}
if (fails === localFail) ok(`${plan.shops.length} shops, ${plan.lots.length} lots — integrity clean`);
}
// Manifest agreement (when Lane E lands): registry skins should be catalogued.
if (manifest) {
head('MANIFEST ↔ REGISTRY');
const facadeSkins = manifest.skins?.facade || {};
const cataloged = new Set(Object.values(facadeSkins).map(v => (v.file || '').replace(/^gen\//, '')));
let missing = 0;
for (const def of Object.values(SHOP_TYPES))
for (const f of (def.facades || [])) if (!cataloged.has(f)) missing++;
if (missing) warn(`${missing} registry facade skin(s) not catalogued in manifest.skins.facade`);
else ok('every registry facade skin is catalogued in the manifest');
}
head('VERDICT');
if (fails === 0) { console.log(`\x1b[32m● consistency GREEN\x1b[0m — ${warns} warning(s).\n`); process.exit(0); }
console.log(`\x1b[31m● consistency RED\x1b[0m — ${fails} failure(s), ${warns} warning(s).\n`);
process.exit(1);

225
tools/qa/scaffold_check.mjs Executable file
View File

@ -0,0 +1,225 @@
#!/usr/bin/env node
// PROCITY Lane F — scaffold + determinism + lane-readiness check.
// Plain node, zero deps. Run: node tools/qa/scaffold_check.mjs
//
// What it does, and why F owns it:
// 1. Verifies the frozen scaffold everyone builds on (vendored three, core modules, skins).
// 2. Locks the PRNG determinism law (CITY_SPEC: same citySeed => byte-identical city) against
// golden values — a regression in prng.js is the one thing that silently corrupts every lane.
// 3. Prints a LANE READINESS MATRIX: which AE deliverables have actually landed. This is the
// "state of the integration" dashboard fable/Lane F reads before attempting to wire anything.
// 4. When Lane A's citygen lands, additionally asserts plan determinism + JSON round-trip.
//
// Exit code: 0 if everything PRESENT passes. Non-zero if a present artifact fails a check.
// Absent lane deliverables are reported as PENDING, not failures — F runs before AE and grows
// teeth as they land.
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
import { isDeepStrictEqual } from 'node:util';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..', '..'); // repo root (tools/qa/ -> ../../)
const R = (...p) => join(ROOT, ...p);
let failures = 0;
const fail = (msg) => { failures++; console.log(` \x1b[31m✗ FAIL\x1b[0m ${msg}`); };
const ok = (msg) => console.log(` \x1b[32m✓ ok\x1b[0m ${msg}`);
const info = (msg) => console.log(` \x1b[36m·\x1b[0m ${msg}`);
const head = (msg) => console.log(`\n\x1b[1m${msg}\x1b[0m`);
function assert(cond, okMsg, failMsg) { cond ? ok(okMsg) : fail(failMsg || okMsg); return cond; }
// ---------------------------------------------------------------- SCAFFOLD
head('SCAFFOLD (frozen — everyone builds on this)');
for (const f of ['web/vendor/three.module.js', 'web/vendor/three.core.js']) {
const p = R(f);
assert(existsSync(p) && statSync(p).size > 100_000,
`vendored ${f} present (${existsSync(p) ? (statSync(p).size / 1024 | 0) + 'k' : 'MISSING'})`,
`vendored ${f} missing or truncated`);
}
for (const f of ['web/js/core/prng.js', 'web/js/core/loaders.js', 'web/js/core/canvas.js']) {
assert(existsSync(R(f)), `core module ${f} present`, `core module ${f} MISSING`);
}
// Syntax-check every hand-written JS file (vendor/ excluded — third party).
head('SYNTAX (node --check on all web/js + tools)');
function collectJS(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) { if (name !== 'vendor' && name !== 'node_modules') out.push(...collectJS(p)); }
else if (p.endsWith('.js') || p.endsWith('.mjs')) out.push(p);
}
return out;
}
const jsFiles = [...collectJS(R('web/js')), ...collectJS(R('tools'))];
let syntaxBad = 0;
for (const f of jsFiles) {
try { execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' }); }
catch (e) { syntaxBad++; fail(`syntax error in ${f.replace(ROOT + '/', '')}: ${String(e.stderr || e).split('\n')[0]}`); }
}
if (syntaxBad === 0) ok(`${jsFiles.length} JS/MJS files parse clean`);
// ---------------------------------------------------------------- DETERMINISM (PRNG law)
head('DETERMINISM — PRNG law (CITY_SPEC: same seed ⇒ identical city)');
{
const prng = await import(R('web/js/core/prng.js'));
const { rng, seedFor, shuffle, pick, irange } = prng;
// Golden values — captured from the frozen prng.js. If these drift, every lane's world drifts.
const g = {
rng_1234_shop_7: [0.8933273730799556, 0.7678728918544948, 0.5031793543603271,
0.10452594514936209, 0.6337878312915564],
seedFor_1234_shop_7: 3826086676,
seedFor_20261990_district_0: 604920024,
shuffle_42: [5, 2, 6, 0, 1, 3, 8, 9, 4, 7],
irange_7: [2, 2, 5, 3, 1, 4],
};
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const r = rng(1234, 'shop', 7);
const first5 = [r(), r(), r(), r(), r()];
assert(eq(first5, g.rng_1234_shop_7), 'rng(1234,"shop",7) stream matches golden',
`rng stream DRIFTED: got ${JSON.stringify(first5)}`);
assert(seedFor(1234, 'shop', 7) === g.seedFor_1234_shop_7,
'seedFor(1234,"shop",7) matches golden', 'seedFor DRIFTED');
assert(seedFor(20261990, 'district', 0) === g.seedFor_20261990_district_0,
'seedFor(20261990,"district",0) matches golden', 'seedFor DRIFTED');
// Two independent streams from the same key must be byte-identical.
const a = rng(777, 'x', 1), b = rng(777, 'x', 1);
assert(eq(Array.from({ length: 64 }, () => a()), Array.from({ length: 64 }, () => b())),
'two independent rng(777,"x",1) streams are identical (64 draws)',
'independent streams DIVERGED — determinism broken');
assert(eq(shuffle(rng(42, 'shuffle', 0), [0,1,2,3,4,5,6,7,8,9]), g.shuffle_42),
'shuffle is deterministic + matches golden', 'shuffle DRIFTED');
// Draw six values from ONE stream (a fresh rng() per draw would just repeat the first value).
{ const ir = rng(7, 'irange', 0);
assert(eq(Array.from({ length: 6 }, () => irange(ir, 1, 6)), g.irange_7),
'irange is deterministic + matches golden', 'irange DRIFTED'); }
// Math.random must NOT appear in generation code (the house ban). grep web/js excluding core FX.
const offenders = grepMathRandom(R('web/js'));
if (offenders.length === 0) ok('no Math.random() in web/js (generation-code ban holds)');
else for (const o of offenders) fail(`Math.random() found (banned in gen code): ${o}`);
}
function grepMathRandom(dir) {
const hits = [];
(function walk(d) {
if (!existsSync(d)) return;
for (const name of readdirSync(d)) {
const p = join(d, name);
if (statSync(p).isDirectory()) { if (name !== 'vendor') walk(p); continue; }
if (!p.endsWith('.js') && !p.endsWith('.mjs')) continue;
const txt = readFileSync(p, 'utf8');
txt.split('\n').forEach((ln, i) => {
// Exemptions must live inside a // comment — anchor the keywords to the comment, otherwise
// a bare "cosmetic"/"banned" anywhere on a real offending line would falsely exempt it.
if (/Math\.random\s*\(/.test(ln) && !/\/\/.*(Math\.random|cosmetic|banned)/.test(ln))
hits.push(`${p.replace(ROOT + '/', '')}:${i + 1}`);
});
}
})(dir);
return hits;
}
// ---------------------------------------------------------------- ASSETS
head('ASSETS');
{
const gen = R('web/assets/gen');
const jpgs = existsSync(gen) ? readdirSync(gen).filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f)) : [];
assert(jpgs.length >= 69, `${jpgs.length} texture skins in web/assets/gen (expect ≥69)`,
`only ${jpgs.length} skins in web/assets/gen (expect ≥69)`);
const peds = R('web/models/peds');
if (existsSync(peds)) {
const glbs = readdirSync(peds).filter(f => f.endsWith('.glb'));
info(`web/models/peds staged: ${glbs.length} GLBs (Lane D fleet — code not required for this check)`);
}
}
// ---------------------------------------------------------------- LANE READINESS MATRIX
head('LANE READINESS MATRIX (what has actually landed)');
const targets = [
['A', 'CityPlan generator', 'web/js/citygen/plan.js'],
['A', 'shop-type registry', 'web/js/core/registry.js'],
['A', 'names generator', 'web/js/citygen/names.js'],
['A', 'selfcheck', 'web/js/citygen/selfcheck.js'],
['A', '2D debug map', 'web/map.html'],
['B', 'game shell', 'web/index.html'],
['B', 'chunk streamer', 'web/js/world/chunks.js'],
['B', 'facade kit', 'web/js/world/buildings.js'],
['B', 'fixture plan', 'web/js/world/fixture_plan.js'],
['C', 'interiors API', 'web/js/interiors/interiors.js'],
['C', 'interior test page', 'web/interior_test.html'],
['D', 'citizen rigs', 'web/js/citizens/rigs.js'],
['D', 'citizen sim', 'web/js/citizens/sim.js'],
['D', 'citizens test page', 'web/citizens_test.html'],
['E', 'asset manifest', 'web/assets/manifest.json'],
['E', 'manifest validator', 'pipeline/validate_manifest.py'],
['E', 'audit', 'pipeline/AUDIT.md'],
];
const byLane = {};
for (const [lane, label, path] of targets) {
const there = existsSync(R(path));
(byLane[lane] ||= { n: 0, have: 0 }); byLane[lane].n++; if (there) byLane[lane].have++;
console.log(` [${lane}] ${there ? '\x1b[32mLANDED \x1b[0m' : '\x1b[33mpending\x1b[0m'} ${label.padEnd(20)} ${path}`);
}
head('READINESS SUMMARY');
let allLanded = true;
for (const lane of ['A', 'B', 'C', 'D', 'E']) {
const s = byLane[lane];
const done = s.have === s.n;
allLanded &&= done;
info(`Lane ${lane}: ${s.have}/${s.n} key deliverables ${done ? '\x1b[32m(complete)\x1b[0m' : ''}`);
}
info(allLanded
? '\x1b[32mAll lanes landed — Lane F integration + full QA gates can run.\x1b[0m'
: '\x1b[33mLanes still pending — F integration is BLOCKED on the above. This check re-runs as they land.\x1b[0m');
// ---------------------------------------------------------------- PLAN DETERMINISM (activates when Lane A lands)
if (existsSync(R('web/js/citygen/plan.js'))) {
head('PLAN DETERMINISM (Lane A present — running deep checks)');
try {
const mod = await import(R('web/js/citygen/plan.js'));
if (typeof mod.generatePlan !== 'function') {
fail('web/js/citygen/plan.js does not export generatePlan(citySeed)');
} else {
const t0 = performance.now();
const p1 = mod.generatePlan(1234);
const genMs = performance.now() - t0;
const p2 = mod.generatePlan(1234);
assert(JSON.stringify(p1) === JSON.stringify(p2),
'generatePlan(1234) is deterministic across two runs',
'generatePlan(1234) NON-DETERMINISTIC — two runs differ');
assert(genMs < 100, `generatePlan under 100ms (${genMs.toFixed(1)}ms)`,
`generatePlan too slow: ${genMs.toFixed(1)}ms (budget 100ms)`);
// Deep-compare the PARSED round-trip against the LIVE object (normalizing the benign -0→0
// that JSON does losslessly but SameValue treats as distinct). stringify-vs-stringify would
// be a tautology — it can't catch a plan carrying Map/Set/undefined/function/Date/NaN.
const norm = v => JSON.parse(JSON.stringify(v, (_, x) => Object.is(x, -0) ? 0 : x));
const round = JSON.parse(JSON.stringify(p1));
assert(isDeepStrictEqual(round, norm(p1)),
'plan survives JSON round-trip (no lossy Map/Set/undefined/fn fields)',
'plan carries non-JSON data — Map/Set/undefined/function/Date/NaN lost on round-trip');
if (typeof mod.chunkIndex === 'function') ok('chunkIndex(plan) exported');
else fail('chunkIndex(plan) not exported from plan.js');
info(`plan(1234): ${p1?.shops?.length ?? '?'} shops, ${p1?.lots?.length ?? '?'} lots, ` +
`${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`);
}
} catch (e) {
fail(`importing/running plan.js threw: ${e.message}`);
}
}
// ---------------------------------------------------------------- VERDICT
head('VERDICT');
if (failures === 0) { console.log('\x1b[32m● scaffold_check GREEN\x1b[0m — every present artifact passed.\n'); process.exit(0); }
else { console.log(`\x1b[31m● scaffold_check RED\x1b[0m — ${failures} failure(s) above.\n`); process.exit(1); }

129
tools/shots.py Normal file
View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""PROCITY Lane F — beauty-shot / reference-delta harness (ported from 90sDJsim tools/shots.py).
Captures the fixed camera bookmarks the game exposes on window.DBG, writes them to
docs/shots/<label>/ and builds a contact sheet comparing this run | docs/shots/before/ | reference.
This is QA gate 6 (shots) and doubles as the Vuntra-style devlog material.
Run:
cd web && python3 -m http.server 8130 # other terminal
tools/.venv/bin/python tools/shots.py [label] [--seed N]
Setup:
python3 -m venv tools/.venv
tools/.venv/bin/pip install playwright && tools/.venv/bin/python -m playwright install chromium
Contract (Lane F asks Lane B for this see docs/LANES/LANE_F_NOTES.md §4):
window.DBG.ready -> true once first chunks built + assets settled
window.DBG.shot(name) -> snap camera to a named bookmark, settle a frame
window.DBG.enterShop(id) -> for interior bookmarks (record_interior, milkbar_interior)
Boot the game with ?dbg=1 so DBG is exposed. If DBG is absent this degrades to a single
default-view frame and prints a warning (so it never hard-fails a pre-Lane-B run).
"""
import sys, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
HOST = 'http://127.0.0.1:8130'
# The v1 tour (LANE_F: 10 beauty shots). name -> optional {'enter': shopId or True}.
TOUR = [
'street_noon', # main street, midday, verandahs
'arcade', # covered pedestrian lane
'market_square', # stalls + dept anchor
'milkbar_dusk', # corner milk bar, 6-seg dusk light
'night_neon', # main street at night, window emissives
'warehouse_fringe', # sparse besser/corrugated edge
'residential_collar', # houses + front yards
'record_interior', # interior beauty shot (enters a record shop)
'opshop_interior', # a second interior archetype
'crossroads_busy', # busiest intersection, citizens (budget shot)
]
ENTER = { # shots that must open a door first; value True = let the game pick a type-appropriate shop
'record_interior': {'type': 'record'},
'opshop_interior': {'type': 'opshop'},
}
def parse_args(argv):
label, seed = 'latest', None
i = 0
rest = argv[1:]
while i < len(rest):
if rest[i] == '--seed':
seed = rest[i + 1] if i + 1 < len(rest) else None # guard trailing --seed (no value)
i += 2
else:
label = rest[i]; i += 1
return label, seed
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed — see setup header in this file.")
label, seed = parse_args(sys.argv)
url = f'{HOST}/?dbg=1' + (f'&seed={seed}' if seed else '')
out = ROOT / 'docs' / 'shots' / label
out.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errors = []
pg.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
pg.goto(url)
has_dbg = pg.evaluate("() => !!(window.DBG)")
if not has_dbg:
print("⚠ window.DBG absent (Lane B shell not landed or ?dbg hook missing) — "
"capturing a single default frame only.")
pg.wait_for_timeout(2000)
pg.screenshot(path=str(out / 'default_view.png'))
b.close()
print(f"wrote {out/'default_view.png'}")
return
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
for name in TOUR:
enter = ENTER.get(name)
if enter:
pg.evaluate("(o) => window.DBG.enterShop(o)", enter)
pg.wait_for_timeout(600)
pg.evaluate("(n) => window.DBG.shot(n)", name)
pg.wait_for_timeout(900) # settle canvas textures, lights, async GLB swaps
pg.screenshot(path=str(out / f'{name}.png'))
if enter:
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(300)
print('shot', name)
b.close()
if errors:
print(f"{len(errors)} console error(s) during capture:")
for e in errors[:10]:
print(" ", e)
# contact sheet: this run | docs/shots/before | docs/shots/references
def cell(src, cap):
return (f'<figure><img src="{src}" loading="lazy"><figcaption>{cap}</figcaption></figure>'
if src else f'<figure class="empty"><figcaption>{cap} — none</figcaption></figure>')
shots_dir = ROOT / 'docs' / 'shots'
rows = []
for name in TOUR:
ref = next(iter((shots_dir / 'references').glob(name + '.*')), None) \
if (shots_dir / 'references').exists() else None
before = shots_dir / 'before' / f'{name}.png'
cells = cell(f'{label}/{name}.png', f'{name} ({label})')
if label != 'before' and before.exists():
cells += cell(f'before/{name}.png', 'before')
cells += cell(f'references/{ref.name}' if ref else None, 'reference')
rows.append(f'<h3>{name}</h3><div class="row">{cells}</div>')
(shots_dir / 'contact.html').write_text(
'<!doctype html><meta charset="utf-8"><title>PROCITY contact sheet</title>'
'<style>body{background:#111;color:#ddd;font:14px monospace;padding:16px}'
'.row{display:flex;gap:8px}figure{margin:0;flex:1;min-width:0}img{width:100%}'
'figcaption{padding:2px 0;color:#8f8}.empty{display:flex;align-items:center;'
'justify-content:center;border:1px dashed #444;color:#666;min-height:120px}</style>'
+ ''.join(rows))
print('contact sheet: docs/shots/contact.html')
if __name__ == '__main__':
main()

161
tools/soak.py Normal file
View File

@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""PROCITY Lane F — soak / budget / asset-free gate (QA gates 2, 3, 4).
Drives a scripted walk across the town, entering shops, and asserts the CITY_SPEC contract:
30 chunks crossed, 15 shops entered
renderer.info.memory geometries + textures return to baseline after each interior (no leak)
JS heap stable (no monotonic climb)
draw calls 300 and tris 200k at the busiest sample (budget)
ZERO console errors for the whole run
Run:
cd web && python3 -m http.server 8130 # other terminal
tools/.venv/bin/python tools/soak.py [--seed N] [--minutes M] [--noassets]
Setup: same venv as tools/shots.py (playwright + chromium).
Needs Lane B's window.DBG hook (see docs/LANES/LANE_F_NOTES.md §4):
DBG.ready, DBG.info() -> {drawCalls,tris,fps,heapMB,geometries,textures,chunk,mode},
DBG.teleport(x,z,ry), DBG.enterShop(o), DBG.exitShop(), DBG.setSegment(seg),
DBG.plan -> the active CityPlan (for choosing a waypoint path + shop ids).
--noassets appends ?noassets=1 (gate 4: primitive-fallback town, must not crash).
Exits non-zero if any assertion fails, so tools/qa.sh --strict can chain it once B lands.
"""
import sys, json, time, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
HOST = 'http://127.0.0.1:8130'
BUDGET_DRAWS, BUDGET_TRIS = 300, 200_000
MIN_CHUNKS, MIN_SHOPS = 30, 15
def flag(name, default=None):
if name in sys.argv:
i = sys.argv.index(name)
return sys.argv[i + 1] if i + 1 < len(sys.argv) and not sys.argv[i+1].startswith('--') else True
return default
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed — see setup header in this file.")
seed = flag('--seed')
minutes = float(flag('--minutes', 10))
noassets = '--noassets' in sys.argv
qs = '?dbg=1' + (f'&seed={seed}' if seed else '') + ('&noassets=1' if noassets else '')
url = HOST + '/' + qs
fails, warns, chunks_seen, shops_entered, peak_draws, peak_tris = [], [], set(), 0, 0, 0
heap_series = []
with sync_playwright() as p:
b = p.chromium.launch(args=['--js-flags=--expose-gc'])
pg = b.new_page(viewport={'width': 1280, 'height': 720})
cerr = []
pg.on('console', lambda m: cerr.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: cerr.append(str(e)))
pg.goto(url)
if not pg.evaluate("() => !!window.DBG"):
sys.exit("⚠ window.DBG absent — Lane B shell/hook not landed; soak cannot run yet. "
"(This is expected pre-Lane-B; the gate is defined and ready.)")
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
plan = pg.evaluate("() => window.DBG.plan || null")
if not plan:
sys.exit("⚠ DBG.plan not exposed — need the active CityPlan to script a walk.")
shops = plan.get('shops', [])
base = pg.evaluate("() => { const i = window.DBG.info(); return {g:i.geometries, t:i.textures}; }")
# Waypoint path: sample shop lots as targets so we cross many chunks and pass many doors.
# (Duration ≈ len(targets) × per-step waits; a strict --minutes loop is a TODO to tune
# against Lane B's real DBG API — the chunk/shop minimums are what actually gate here.)
targets = shops[:: max(1, len(shops) // 40)] if shops else []
def sample():
nonlocal peak_draws, peak_tris
i = pg.evaluate("() => window.DBG.info()")
peak_draws = max(peak_draws, i.get('drawCalls', 0))
peak_tris = max(peak_tris, i.get('tris', 0))
heap_series.append(i.get('heapMB', 0))
if i.get('chunk') is not None:
chunks_seen.add(str(i['chunk']))
return i
# Walk the waypoint circuit, repeating until the --minutes budget is spent (with a safety
# cap), but always at least once. The chunk/shop minimums are the real gate.
deadline = time.monotonic() + minutes * 60
MEM = "() => { const i=window.DBG.info(); return {g:i.geometries,t:i.textures}; }"
circuit = 0
while True:
for idx, shop in enumerate(targets):
lot = shop.get('lot') or shop
x, z = lot.get('x', 0), lot.get('z', 0)
pg.evaluate("(p) => window.DBG.teleport(p.x, p.z, 0)", {'x': x, 'z': z})
pg.wait_for_timeout(1000)
sample()
# Enter shops we pass, counting ONLY verified interior loads. A closed shop
# legitimately shows a locked toast and stays in street mode — that is not a
# failure, it just doesn't count. A no-op enterShop therefore never reaches the
# ≥MIN_SHOPS gate, which is the correct way to fail it.
if idx % 2 == 0 and shops_entered < MIN_SHOPS + 5:
before = pg.evaluate(MEM)
pg.evaluate("(id) => window.DBG.enterShop({shopId:id})", shop.get('id'))
pg.wait_for_timeout(700); sample()
if pg.evaluate("() => window.DBG.info().mode === 'interior'"):
shops_entered += 1
pg.evaluate("() => window.DBG.exitShop()")
pg.wait_for_timeout(500)
after = pg.evaluate(MEM)
# Leak check: geometries/textures return to (roughly) the pre-enter baseline.
if after['g'] > before['g'] + 2 or after['t'] > before['t'] + 2:
fails.append(f"interior leak on shop {shop.get('id')}: "
f"geo {before['g']}{after['g']}, tex {before['t']}{after['t']}")
if time.monotonic() > deadline and len(chunks_seen) >= MIN_CHUNKS \
and shops_entered >= MIN_SHOPS:
break
circuit += 1
if time.monotonic() > deadline or circuit >= 4 or not targets:
break
b.close()
# ---- assertions ----
if len(chunks_seen) < MIN_CHUNKS:
fails.append(f"crossed only {len(chunks_seen)} chunks (need ≥{MIN_CHUNKS})")
if shops_entered < MIN_SHOPS:
fails.append(f"entered only {shops_entered} shops (need ≥{MIN_SHOPS})")
if peak_draws > BUDGET_DRAWS:
fails.append(f"draw calls peaked at {peak_draws} (budget {BUDGET_DRAWS})")
if peak_tris > BUDGET_TRIS:
fails.append(f"tris peaked at {peak_tris} (budget {BUDGET_TRIS})")
# JS heap: only meaningful if DBG.info() actually reports a nonzero heapMB (needs
# performance.memory / --expose-gc). If it doesn't, say so — don't silently "pass".
nonzero_heap = [h for h in heap_series if h]
if len(nonzero_heap) >= 6:
head = sum(nonzero_heap[:3]) / 3; tail = sum(nonzero_heap[-3:]) / 3
if tail - head > 100 and tail > head * 1.3:
fails.append(f"JS heap climbed {head:.0f}{tail:.0f} MB across the walk (possible leak)")
else:
warns.append("JS heap not reported by DBG.info().heapMB — heap-leak check skipped "
"(expose it via performance.memory to enable gate 2's heap arm)")
if cerr:
fails.append(f"{len(cerr)} console error(s); first: {cerr[0][:160]}")
report = {
'seed': seed, 'noassets': noassets, 'minutes': minutes, 'chunks': len(chunks_seen),
'shops_verified': shops_entered, 'peak_draws': peak_draws, 'peak_tris': peak_tris,
'heap_MB': [round(h) for h in (nonzero_heap[:1] + nonzero_heap[-1:])] if nonzero_heap else [],
'console_errors': len(cerr), 'warnings': warns, 'pass': not fails,
}
print(json.dumps(report, indent=2))
for w in warns:
print(" ! ", w)
if fails:
print("\n✗ SOAK FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("\n✓ SOAK PASSED")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,142 @@
// PROCITY Lane F — interior_mode.js [integration glue, F-owned]
// Bridges Lane B's street shell to Lane C's buildInterior library — this is the wiring behind the
// headline "every door opens". Ownership stays clean: Lane B owns the shell + mode machine, Lane C
// owns the room, Lane F owns THIS bridge (a separate module so the shell edits are a thin, marked
// seam rather than logic sprinkled through B's files).
//
// Contract (verified against the real landed code, 2026-07-14):
// • buildInterior(shop, THREE) → { group(self-lit), spawn:{x,z,ry}, exits:[{x,z,w,toStreet}],
// _debug.grid:{W,D,cols,rows,cw,cd,occ,cellOf}, dispose() }
// • Room origin = centre, floor y=0. spawn is ~1.5m inside the front door facing Z into the shop;
// the (only) street exit sits at the front wall, same x as spawn.
// • occ cells: 0 free · 1 fitting · 2 wall(border). We collide off occ==1 + room bounds, exactly
// like Lane C's interior_test.html walk loop, so behaviour matches the tester the room was tuned in.
//
// The interior renders into its OWN dark THREE.Scene (the street scene is frozen, not disposed —
// CITY_SPEC L3 "pause, not dispose"), so returning to the street is instant.
import { buildInterior } from '../interiors/interiors.js';
import { KeeperManager } from '../citizens/keepers.js'; // Lane D — shopkeeper behind the counter
const EYE = 1.6; // interior eye height (matches interior_test.html; rooms tuned for it)
const RADIUS = 0.30; // player collision radius inside the room
const SPEED = 3.2; // interior walk speed (matches the Lane C tester)
const ARM_DIST = 2.0; // must step this far from the door before the exit arms (spawn is ~1.5m away)
const EXIT_DIST = 1.0; // …then coming back within this distance leaves to the street
export function createInteriorMode({ THREE, renderer, camera, plan }) {
const scene = new THREE.Scene();
scene.background = new THREE.Color('#0e0b07');
// Lane D shopkeeper — one per interior, at the counter. No fleet passed → asset-free placeholder
// actor (the rig fleet is a later §3.4 upgrade); still turns to greet the player. Ticked in update().
const keepers = new KeeperManager({ camera, citySeed: plan.citySeed });
let current = null; // active Lane C interior handle
let doorReturn = null; // { x, y, z, ry } on the street to restore on exit
let exitArmed = false; // disarmed at spawn (spawn sits by the door); arms once player steps inside
const _fwd = new THREE.Vector3();
const _right = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
// a small unobtrusive banner (F-owned; the street HUD is hidden in interior mode)
const banner = document.createElement('div');
banner.style.cssText =
'position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:20;display:none;' +
'background:rgba(20,16,10,.82);color:#f4efe6;padding:8px 16px;border-radius:8px;' +
'font:14px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;text-shadow:0 1px 2px #000;pointer-events:none';
document.body.appendChild(banner);
// interior-grid collision — mirrors interior_test.html walkable(): room bounds + occ==1 fittings.
function walkable(x, z) {
const g = current._debug.grid;
if (Math.abs(x) > g.W / 2 - RADIUS || Math.abs(z) > g.D / 2 - RADIUS) return false;
for (const [ox, oz] of [[RADIUS, 0], [-RADIUS, 0], [0, RADIUS], [0, -RADIUS]]) {
const [cx, cz] = g.cellOf(x + ox, z + oz);
if (g.occ[cz * g.cols + cx] === 1) return false; // walls are handled by the bounds clamp
}
return true;
}
function nearestExitDist(px, pz) {
let best = Infinity;
for (const e of current.exits) {
if (e.toStreet === false) continue;
const d = Math.hypot(px - e.x, pz - e.z);
if (d < best) best = d;
}
return best;
}
// enter: build the room, show it, drop the player just inside the door. Returns the Lane C handle.
function enter(shop, name) {
if (current) exit(); // never stack interiors
current = buildInterior(shop, THREE);
scene.add(current.group);
// Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable
const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
if (counter) {
const st = counter.userData.keeperStand;
keepers.spawn(current.group, { x: st.x, z: st.z, ry: st.ry, shopId: shop.id, type: shop.type });
}
doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y };
camera.position.set(current.spawn.x, EYE, current.spawn.z);
camera.rotation.set(0, current.spawn.ry, 0, 'YXZ'); // PointerLockControls re-reads this on next mouse move
exitArmed = false;
banner.textContent = `🚪 ${name || shop.name || 'shop'} — walk out the door or press Esc to leave`;
banner.style.display = 'block';
return current;
}
// update: walk + render the interior. Returns true once the player reaches a street exit
// (the shell then calls exit() + setMode('street') — update never disposes on its own).
function update(dt, keys) {
if (!current) return false;
const f = (keys.has('KeyW') || keys.has('ArrowUp') ? 1 : 0) - (keys.has('KeyS') || keys.has('ArrowDown') ? 1 : 0);
const s = (keys.has('KeyD') || keys.has('ArrowRight') ? 1 : 0) - (keys.has('KeyA') || keys.has('ArrowLeft') ? 1 : 0);
if (f || s) {
camera.getWorldDirection(_fwd); _fwd.y = 0; _fwd.normalize();
_right.crossVectors(_fwd, _up).normalize();
const dx = _fwd.x * f + _right.x * s, dz = _fwd.z * f + _right.z * s;
const len = Math.hypot(dx, dz) || 1;
const step = SPEED * dt;
const nx = (dx / len) * step, nz = (dz / len) * step;
const p = camera.position;
if (walkable(p.x + nx, p.z)) p.x += nx; // per-axis → slide along walls/fittings
if (walkable(p.x, p.z + nz)) p.z += nz;
p.y = EYE;
}
keepers.update(dt); // Lane D: keeper idle + turn-to-greet (playerPos defaults to the camera)
renderer.autoClear = true; // the composer may have left autoClear=false; force a clean interior frame
renderer.render(scene, camera);
const de = nearestExitDist(camera.position.x, camera.position.z);
if (!exitArmed && de > ARM_DIST) exitArmed = true;
return exitArmed && de < EXIT_DIST;
}
// exit: dispose the room (frees GPU + removes group), restore the player to the street door.
function exit() {
if (!current) return;
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
current.dispose();
current = null;
banner.style.display = 'none';
if (doorReturn) {
camera.position.set(doorReturn.x, doorReturn.y, doorReturn.z);
camera.rotation.set(0, doorReturn.ry, 0, 'YXZ');
}
}
return {
enter,
update,
exit,
scene,
keepers,
get active() { return !!current; },
get current() { return current; },
};
}