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:
parent
5313402e43
commit
aa4ed4fdca
134
F-progress.md
Normal file
134
F-progress.md
Normal 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 (A–E) 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 1–4,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
257
docs/LANES/LANE_F_NOTES.md
Normal file
@ -0,0 +1,257 @@
|
||||
# LANE F — NOTES (integration runbook + QA gates)
|
||||
|
||||
*Lane F is integration + QA. It runs **after** A–E land. This file is the durable technical spec:
|
||||
the exact wiring edits, the contracts F needs from each lane, and the QA gate procedures. It is
|
||||
written **before** A–E finished so the wiring is a fast, mechanical pass the moment they do —
|
||||
and so each lane can see the small hook F will ask of it and build it in up front.*
|
||||
|
||||
Status of this file: **living**. Update the ✅/⏳ markers as wiring lands. Snapshot of lane
|
||||
readiness lives in [`F-progress.md`](../../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 1–4,6)
|
||||
The house shot/soak harnesses drive the game through a debug object. **Ask Lane B to expose**, on
|
||||
`window.DBG` (guarded, only when `?dbg=1`):
|
||||
- `DBG.ready` → true once first chunks built + assets settled (soak/shots wait on it).
|
||||
- `DBG.shot(name)` → snap camera to a named bookmark (`street_noon`, `arcade`, `market_square`,
|
||||
`milkbar_dusk`, `night_neon`, …); returns after the frame settles.
|
||||
- `DBG.teleport(x,z,ry)` 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 1–6; 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
92
docs/V2_IDEAS.md
Normal 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 B–F 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 E1–E4 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
34
docs/shots/README.md
Normal 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.
|
||||
0
docs/shots/before/.gitkeep
Normal file
0
docs/shots/before/.gitkeep
Normal file
0
docs/shots/references/.gitkeep
Normal file
0
docs/shots/references/.gitkeep
Normal file
0
docs/shots/v1_tour/.gitkeep
Normal file
0
docs/shots/v1_tour/.gitkeep
Normal file
80
tools/qa.sh
Executable file
80
tools/qa.sh
Executable 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 A–E 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
|
||||
137
tools/qa/consistency_check.mjs
Normal file
137
tools/qa/consistency_check.mjs
Normal 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
225
tools/qa/scaffold_check.mjs
Executable 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 A–E 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 A–E 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
129
tools/shots.py
Normal 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
161
tools/soak.py
Normal 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()
|
||||
142
web/js/world/interior_mode.js
Normal file
142
web/js/world/interior_mode.js
Normal 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; },
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user