diff --git a/F-progress.md b/F-progress.md new file mode 100644 index 0000000..5ad7658 --- /dev/null +++ b/F-progress.md @@ -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`. diff --git a/docs/LANES/LANE_F_NOTES.md b/docs/LANES/LANE_F_NOTES.md new file mode 100644 index 0000000..dbd391e --- /dev/null +++ b/docs/LANES/LANE_F_NOTES.md @@ -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:[.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-.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? diff --git a/docs/V2_IDEAS.md b/docs/V2_IDEAS.md new file mode 100644 index 0000000..9cba83a --- /dev/null +++ b/docs/V2_IDEAS.md @@ -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.* diff --git a/docs/shots/README.md b/docs/shots/README.md new file mode 100644 index 0000000..9c8a55f --- /dev/null +++ b/docs/shots/README.md @@ -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. diff --git a/docs/shots/before/.gitkeep b/docs/shots/before/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/shots/references/.gitkeep b/docs/shots/references/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/shots/v1_tour/.gitkeep b/docs/shots/v1_tour/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/qa.sh b/tools/qa.sh new file mode 100755 index 0000000..f2beba8 --- /dev/null +++ b/tools/qa.sh @@ -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 diff --git a/tools/qa/consistency_check.mjs b/tools/qa/consistency_check.mjs new file mode 100644 index 0000000..5ef62a5 --- /dev/null +++ b/tools/qa/consistency_check.mjs @@ -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 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); diff --git a/tools/qa/scaffold_check.mjs b/tools/qa/scaffold_check.mjs new file mode 100755 index 0000000..0433141 --- /dev/null +++ b/tools/qa/scaffold_check.mjs @@ -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); } diff --git a/tools/shots.py b/tools/shots.py new file mode 100644 index 0000000..bab4920 --- /dev/null +++ b/tools/shots.py @@ -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/