PROCITY/docs/CITY_SPEC.md
m3ultra e5d4679471 Lane A round 9: freeze CityPlan v2 contract in CITY_SPEC + closing-time occupant ruling
Ship round. Docs + one selfcheck log-line only; no generator change ⇒ all three goldens
unchanged (synthetic 0x3fa36874, osm/melbourne 0x34cfdec0, osm/katoomba 0x0f652510).

1. CITY_SPEC v2 contract FROZEN — new "CityPlan v2 — frozen producer contract" section: producer
   API, goldens table (keyed seed/plansrc/town) + how to pin a new one, town-key mechanism, osm
   normalization rules, hours/openLate/storeys laws. Matches code as shipped (F diffs claims vs
   code). Refreshed the stale round-6 Plan-sources blockquote to multi-town reality. Layer-1
   changes now require a CITY_SPEC amendment + golden re-pin in the same commit.
2. Recipe sanity — DRY-RAN end-to-end: appended a throwaway Newtown (42 shops), selfcheck auto-ran
   the full parity suite (1931/1932, only golden unpinned) and printed a paste-ready
   `newtown: 0x9045e577,`. Reverted; no 3rd town shipped. Added the selfcheck `⚠ UNPINNED — add to
   OSM_GOLDENS` line so the recipe is mechanical; fixed the recipe wording to match.
3. Closing-time occupant ruling (D's on-call Q) — ratifies + freezes what D's sim.js already does:
   half-open hours, no new entry when closed, occupants DRAIN (finish bounded dwell) never popped,
   player not force-ejected, openLate video is the night crowd. In CITY_SPEC + LANE_A_NOTES. No
   code change asked of D.

qa.sh --strict GREEN; selfcheck 1727/1727.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:56:19 +10:00

321 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# PROCITY — CITY_SPEC (the shared contract)
*Every lane reads this first. If a lane needs to change something in here, it changes this file
in the same commit and says so loudly in the commit message — this doc is the treaty.*
## What we are building
A standalone, procedurally generated, fully walkable Australian shopping town — the Vuntra City
trick (every building enterable, interiors generated on demand) applied not to cyberpunk towers
but to **record stores, op shops, toy shops, book barns, video rental, pawnbrokers, milk bars,
market stalls**. 90s-Australia aesthetic, low-poly + generated texture skins. Content (real item
data, lore, economy) plugs in later via the GODVERSE/BaseGod feeds — v1 is the *system*: plan →
streets → facades → doors that open → themed interiors → NPCs walking around.
## Engine decision (settled — do not relitigate)
**three.js r175, vendored, plain JS ES modules, importmap, zero build step, zero npm.**
This is the proven house stack (90sDJsim + thriftgod both ship on it), it runs great on Apple
Silicon, and it lets us lift working code (fittings kit, rig stack, dig.js) verbatim. Unreal was
considered and parked: nothing in v1 needs it, and the asset pipeline (GLB, web-ok licensing,
3GOD depot) is web-native. Serve with `cd web && python3 -m http.server 8130`.
- `three` and `three/addons/` resolve via importmap to `web/vendor/` (copied from 90sDJsim).
- No DRACO requirement (house GLBs ship uncompressed/webp-optimized); DRACOLoader is vendored if needed.
- No external CDNs at runtime. Everything local or from `https://digalot.fyi/3god` (CORS-enabled).
## Units, axes, determinism
- **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square.
- **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal
points at its street edge. GLB convention: origin at base, facing **Z**, real-world scale.
- **Seeded everything, `Math.random()` is banned in generation code.**
`web/js/core/prng.js` is the only randomness source:
- `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`).
- `rng(citySeed, kind, id)` → mulberry32 stream.
- Same citySeed ⇒ byte-identical city, forever. This is the thriftgod/90sDJsim house rule and
it's what makes the authored-override layer possible later.
## The three-layer architecture
```
LAYER 1 CityPlan (pure data, no THREE) Lane A
LAYER 2 Streetscape (chunked 3D realization) Lane B
LAYER 3 Interiors (on-demand room scenes) Lane C
Citizens (NPCs over layers 2+3) Lane D
Assets (skins + GLB depot) Lane E
```
### Layer 1 — CityPlan (JSON-serializable, generated in <100ms)
The whole town as lightweight data, generated up front from `citySeed`, dumpable to JSON.
Schema v1 (Lane A owns it; extend, don't break):
```js
{
version: 1,
citySeed: 20261990, // uint32
name: "…", // generated town name
size: { w: 1024, d: 1024 }, // metres, city core v1 = 1km²
districts: [ { id, kind, cx, cz } ],
// kind: 'mainstreet' | 'arcade' | 'backstreets' | 'warehouse' | 'residential' | 'market'
streets: {
nodes: [ { id, x, z } ],
edges: [ { id, a, b, width, kind } ] // kind: 'main' | 'side' | 'lane' | 'arcade'
},
blocks: [ { id, district, kind, poly: [[x,z],] } ],
// district = district ID (index into districts[]); kind = that district's kind string, denormalized
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
// ry: Y-rotation (radians) so a GLB modelled facing Z ends up with its facade's OUTWARD normal
// pointing at frontEdge. World facing = (sin ry, cos ry). Lane B rotates the shell by ry.
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23.
// storeys: integer in [registryMin, permittedMax] where permittedMax = registryMax≥2 ? min(registryMax+1,3)
// : registryMax. The "+1 (cap 3)" is the CITY_SPEC "occasional 3-storey corner anchor" — it fires ONLY
// for tall-capable types (registry max ≥ 2); single-storey types (video/milkbar/stall, [1,1]) are NEVER
// boosted. So `storeys > registryMax` on a corner anchor is EXPECTED, not plan↔registry drift.
// openLate: present and `true` on EXACTLY ONE shop per town — the designated late-night landmark
// (closes ≥ 22:00; a naturally-late type, video rental or milk bar, never a market stall). Absent
// on every other shop. Lane B lights it after dark; Lane F's night gate keys off this field
// (`plan.shops.find(s => s.openLate)`), NOT a magic hours threshold.
}
```
> **Field-type contract** (Lane A is the source of truth; consumer *fixtures* must match this, not the
> reverse). Every `id` and every reference to one — `lot.block`, `lot.frontEdge`, `edge.a`/`b`,
> `block.district`, `shop.lot` — is an **integer**, not a string. `facadeSkin`, and every entry in a
> registry `facades` pool, is a **full filename** under `web/assets/gen/` (e.g. `"facade-fibro-blue.jpg"`)
> — load it verbatim; do **not** prepend `facade-` or append an extension (the shop-types table below
> abbreviates skin names for readability only). The same rule holds for Lane E's ground/awning/sky/wall/
> interior skins: store the file exactly as the manifest names it, never re-derive a key from it.
> **Layer-1 invariants Lane A guarantees** (enforced by `web/js/citygen/selfcheck.js`): fully
> deterministic per `citySeed` (with a committed golden fingerprint guarding against drift); every
> `frontEdge`/`block`/`district`/`lot` reference resolves; every lot faces its `frontEdge`; **no two
> building lots (`shop`/`anchor`/`house`/`stall`) overlap, within OR across blocks**; **exactly one shop
> carries `openLate`** (the late-night landmark); `chunkIndex` covers every lot and lists every edge in
> every chunk its road **corridor (road + verge, centreline ± width/2)** touches — so verge-placed street
> furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite
> numbers, no stray keys).
> **Plan sources.** A CityPlan can come from two producers behind this one contract, chosen via
> `generatePlanFor(seed, source, { town })` (`web/js/citygen/index.js`) — the shell selects with
> `?plansrc=` (+ `&town=` for osm):
> - `'synthetic'` (default) — `generatePlan(seed)`, the procedural town. Byte-identical, golden `0x3fa36874`.
> - `'osm'` — `generatePlanOSM(seed, town)`, a real-data import from a **checked-in fixture**
> (`osm_fixture.js` → `OSM_TOWNS`; real AU shops from thriftgod's OSM cache; **zero network**).
> Towns: `melbourne` (default, golden `0x34cfdec0`), `katoomba` (`0x0f652510`). `osmTownKeys()` lists them.
>
> An `'osm'` plan sets `source:'osm'`, reports its **real bounding-box `size`** (not 1024²), and contains
> **only real retail types** — no synthetic market square, arcade, dept anchor, milk bars, or stalls.
> All structural invariants above hold identically. Consumers that assume a synthetic-only feature
> (dept/market/milkbar/stall) must guard on `source`. The invariant suite runs on every (source, town).
> **Full frozen v2 producer contract below** (§"CityPlan v2 — frozen producer contract").
Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across
the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture,
footpath NPCs) resolves to a chunk whose `edges` list includes its edge.
## CityPlan v2 — frozen producer contract
> **FROZEN at `v2.0` (round 9).** This is the authoritative Layer-1 contract — the v3 epoch
> (editor / override layer) reads THIS. It matches the code as shipped; `web/js/citygen/selfcheck.js`
> enforces every law here. Changes are a **CITY_SPEC amendment in the same commit + a golden re-pin**,
> never a silent drift. Import everything from **`web/js/citygen/index.js`** (the barrel) — internals
> (`plan.js`, `plan_osm.js`, `osm_fixture.js`, `names.js`) may move behind it.
**Producer API** (all from `web/js/citygen/index.js`):
- `generatePlanFor(seed, source = 'synthetic', { town } = {}) → CityPlan` — the selector the shell uses.
- `generatePlan(seed) → CityPlan` — synthetic producer (unchanged, byte-identical).
- `generatePlanOSM(seed, town = 'melbourne', { report } = {}) → CityPlan` — osm producer; pass a
`report` object to receive the normalization log (below). `osmTownKeys() → string[]`.
- `chunkIndex(plan, chunkSize = 64) → { chunkSize, chunks }`; `CHUNK = 64`; `chunkKey(cx, cz)`.
- Geometry helpers (shared with the harness): `lotCorners(lot)`, `obbOverlap(a, b)`.
**Determinism & goldens.** Same inputs ⇒ byte-identical plan, forever. The determinism gate keys on
**(seed, plansrc, town)**. Committed goldens (`xmur3(JSON.stringify(plan))` at seed `20261990`):
| source / town | golden |
|--------------------|--------------|
| synthetic | `0x3fa36874` |
| osm / melbourne | `0x34cfdec0` |
| osm / katoomba | `0x0f652510` |
*Adding a town* is mechanical (recipe in `docs/LANES/LANE_A_NOTES.md`): append to `OSM_TOWNS`, run
selfcheck, paste the printed fingerprint into `OSM_GOLDENS`, tell Lane F the new hash.
**Trading-hours law.** `shop.hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. Openness is
**half-open**: `isOpen(shop, h) ⟺ open ≤ h < close` (a shop closing at 17 is shut at 17:00 exactly).
Both the shell (`index.html`) and the NPC sim (`citizens/sim.js _openAt`) use this identical predicate.
**openLate law.** **Exactly one** shop per town is the late-night landmark. It is the sole shop that
closes `≥ 22:00` (`LATE_HOUR`), so the field and the threshold are equivalent: `{s: s.openLate}`
`{s: s.hours[1] ≥ 22}` (a singleton). It is the **video rental** (a town with no video falls back to
another non-stall type, logged). Every other shop closes by 21:00 — so after ~21:00 the streets thin
to that one lit destination.
**Storeys law.** `shop.storeys ∈ [registryMin, permittedMax]`, `permittedMax = registryMax ≥ 2 ?
min(registryMax + 1, 3) : registryMax`. The occasional 3-storey corner anchor adds ≤1 storey (tall
types only); single-storey types (`video`/`milkbar`/`stall`, `[1,1]`) are **never** boosted.
**OSM normalization rules** (the importer bends the *data* to the contract, never the contract; each
change is logged into `report`): unknown OSM `shop=` kind → `opshop` (`report.typesRemapped`); any shop
closing `≥ 22:00` → clamped to 21:00 so only the one openLate landmark is late (`report.hoursClamped`);
the openLate landmark prefers a video, else a fallback non-stall type (`report.openLate`). Shop `name`s
are the real OSM names; `sign` is derived. `osm` plans carry `source:'osm'`, a real bbox `size`, and no
synthetic dept/market/milkbar/stall/arcade.
**Closing-time occupant ruling (Lane A, round 9 — ratifies Lane D's `sim.js`).** When a shop closes:
1. **No new entry.** Patronage only ever routes a ped into an *open* shop (`_nearestOpenShop` gates on
`_openAt`); the player likewise gets a CLOSED plate/toast and cannot enter (`index.html`).
2. **Occupants drain, they don't pop.** A ped already inside when the shop closes finishes its current
bounded visit (seeded 520s dwell) and re-emerges through the door normally — it is **never**
teleported out or culled at the close instant. Occupancy therefore drains to 0 within one dwell
window; `sim.occupancyOf(shopId)` falls to 0 on its own. Any browser-rig presence (Lane D/F) tracks
`occupancy`, so it clears with the same drain — no orphaned rigs in a shut shop.
3. **The player is never force-ejected.** Closing locks the door to *entry* only; a player already inside
stays until they choose to leave (leaving restores the street where they stood, as always).
4. **The openLate video shop** is the one place still filling after ~21:00 (the night crowd); at its own
close (22:00/23:00) it drains identically. Determinism holds: entry/dwell/emerge are seeded + clock
driven, so same (seed, time) ⇒ same occupancy.
### Layer 2 — Streetscape (Lane B)
Realizes CityPlan chunks around the player. Hard requirements the two parent games skipped:
- **Contiguous geometry** — no teleport seams; you can walk the whole core.
- **Chunk streaming** — build/dispose chunk Groups at radius ~23; never rebuild the world.
- **InstancedMesh from day one** for: building shells, awning boxes, verandah posts, street
furniture, trees, streetlights. Canvas signage batched into a **sign atlas texture** per chunk
(one CanvasTexture, many UV-mapped planes) — not one canvas per sign.
- Facade formula (proven in both games): instanced box shell + front `PlaneGeometry` with a
`facade-*.jpg` skin + parametric awning/verandah + sign band + door + window planes.
Skins are generated with **blank signboards** — the game overlays the shop name.
### Layer 3 — Interiors (Lane C)
Interiors are **not** street geometry. Walking into a doorway triggers
`enterShop(shop)` → separate room Group built from `shop.seed` (thriftgod archetypes + the
90sDJsim `fittings.js` parametric kit), themed by `shop.type`. Leaving restores the street
exactly where you stood. Street windows show a cheap fake (tinted glass + emissive warm glow at
night in v1; interior-mapping shader is a stretch goal).
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
> the exact facade pools, sign hints, interior archetype, fittings, storeys, hours and district
> weights. This table mirrors it. *(Lane A amendment 2026-07-14: added a variety facade to
> `toy`/`book`/`pawn` and rounded out the fittings mixes — a `counter` to shops that sell over one,
> a `freezer` to milkbar, a `glass_case`+`counter` to dept — so this table matches the registry
> lanes actually consume. Non-breaking; all facades exist in `web/assets/gen/`.)*
>
> *(Lane A amendment 2026-07-14 · round 2, responding to B/E/F integration notes: gave `stall` a 2nd
> facade `facade-corrugated.jpg` (a market-shed backdrop, matching Lane E's manifest facade→type map,
> so every type now has ≥ 2 facades per Lane E's validator). Replaced the old per-shop 6% "open late"
> dice — which yielded 0…many late shops and often none past 22:00 — with **exactly one** deterministic
> `openLate` landmark per town (see the schema note above). Widened `chunkIndex` edge coverage to the
> full road corridor. All non-breaking; self-check green at 1283/1283; golden fingerprint refreshed to
> `0x098eec2b`.)*
| type | facade pool | interior archetype | fittings mix |
|---|---|---|---|
| `record` | timber-teal, arcade-tile, djsim-record | crates + bins | record bins, crates, counter, listening corner |
| `opshop` | weatherboard, fibro-blue, djsim-opshop | racks + shelves | clothes racks, bric-a-brac shelving, book wall, counter |
| `toy` | stucco-pink, deco-pastel, boutique | shelves + glass case | cube shelves, display tables, glass case, counter |
| `book` | federation, sandstone, redbrick | halls of shelves | bookshelves, spinner racks, armchair, counter |
| `video` | stripmall, djsim-video | aisle shelves | VHS shelving, returns slot, poster wall, counter |
| `pawn` | besser, grimy, djsim-pawn, corrugated | counter-forward | glass cabinets, wall hooks, barred window, counter |
| `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer |
| `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter |
| `stall` (market) | market, corrugated | open stall | trestle tables, crates |
## NPC contract (Lane D)
- **Fleet rule (house law):** 2 rigged base meshes + shared clip bank. Canonical rigs:
`~/Documents/character_kit/` (female `grandma_game.glb` route, male `hum_character.glb`) plus
the 19 peds in `90sDJsim/web/world/models/peds/`. Game-local copies are byte-identical builds —
**copy from character_kit / 90sDJsim, never edit locally.**
- Distance tiers: near <25m = rigged `SkeletonUtils.clone` + AnimationMixer; mid = billboard
impostor (pre-rendered sprite, instanced); far = culled. Walkers path along street-edge
footpath lanes from the CityPlan graph.
- Licensing: 🟢 web-ok only (Mixamo, CC0/CC-BY, CGTrader RF). **No ActorCore in web builds.**
## Performance budget (M-series laptop, 60fps)
- 300 draw calls in street mode, 200k tris in a typical view.
- `renderer.setPixelRatio(min(devicePixelRatio, 2))`.
- Textures: skins 1024px, sign atlases 2048px, total GPU texture < 512MB.
- Chunk build must not hitch: budget 4ms/frame (build incrementally or in idle callbacks).
- Adopt 90sDJsim's `GFX_TIERS` idea later if needed; don't gold-plate now.
## Modes & shell (Lane B owns the shell)
`MODE ∈ { map, street, interior }` one renderer, one state machine, same as both parent games.
`map` = 2D canvas town directory (Lane A's debug view grows into it). Save/load = localStorage v1.
## File ownership (parallel-safety treaty)
| path | owner |
|---|---|
| `web/js/core/*` (prng, loaders, canvas) | scaffold (frozen propose changes via CITY_SPEC PR) |
| `web/js/core/registry.js` | Lane A |
| `web/js/citygen/*`, `web/map.html` | Lane A |
| `web/index.html`, `web/js/world/*` | Lane B |
| `web/js/interiors/*`, `web/interior_test.html` | Lane C |
| `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*` | Lane D |
| `pipeline/*`, `web/assets/*` + `web/assets/manifest.json` | Lane E |
| `web/package.json` | scaffold/shared `{"type":"module"}` only, added by Lane A |
| `docs/*` | everyone, additively |
Each lane also ships its own standalone test page so it can be verified without the others.
**Never edit another lane's files.** Integration (Lane F) happens after AE land.
> `web/package.json` (added by Lane A) contains only `{"type":"module"}` so `node` runs the pure
> `web/js/**` ES modules directly (e.g. `node web/js/citygen/selfcheck.js`, an acceptance criterion).
> Browsers and `python3 -m http.server` ignore it; no deps, no build step. It changes Node's module
> interpretation for every lane's `web/js/**` — which is what we want (all lanes can node-test their
> pure modules) — so it's flagged here as shared scope, not silently added.
## Infrastructure map
- **This mac** dev box, Blender + Unreal installed, Apple Silicon (no CUDA all gen is
cloud-API or tailnet).
- **ultra** `ssh johnking@100.91.239.7` (key auth works, always-on M1 Ultra): MESHGOD Blender
scripts (`~/Documents/MESHGOD/scripts/finish_glb.py`, `render_glb.py`, `media_primitive.py`),
sorted model library `~/Documents/3D=models/` (shop-fittings / street-furniture / furniture
see its `MANIFEST.md` + `CH_MAPPING.md`), `character_kit/`, `mixamo-fetch/out/` clip bank
(34 clips never re-download an existing clip). Caveats: no GNU coreutils/timeout/setsid,
system python 3.9, Homebrew at /opt/homebrew.
- **m3ultra** `http://100.89.131.57:11434` Ollama (`qwen3:235b/32b/8b`, `gemma3:4b` vision,
`gemma4`). Free local LLM for name generation, lore, cataloguing. Use it before any paid API.
- **3GOD depot** `https://digalot.fyi/3god` `POST /api/upload?name=<f>.glb`, `GET /a/<f>`,
`GET /api/list` (CORS on). The shared GLB CDN all sibling games use.
- **MeshGod** `https://digalot.fyi/meshgod` imageGLB generation (~30¢/solid; check the
DealGod canon-3D cache first; media/books use the free parametric skinner).
- **Image gen** house flow is prompt packs (see `~/Documents/FLOW_PROMPTS.md` format) +
thriftgod's `gen_assets.py` batch pipeline (OpenRouter `google/gemini-3.1-flash-image`,
~$0.004/img, style-bible prefix, resumable) and 90sDJsim's Cloudflare Flux scripts
(creds in `~/Documents/backnforth/.env`).
## Style lock (visual)
Low-poly chunky geometry, flat-ish shading, muted 90s Australian palette, warm cinematic light
the 90sDJsim POLY lock skinned with the thriftgod generated-texture kit (69 skins already in
`web/assets/gen/`). Facade prompt template (blank signboards!) is in thriftgod
`gen_assets.py:69-86` reuse it for new skins so everything matches.
## House patterns to keep (both games proved these)
- Promise-cached loaders; missing asset placeholder box, never a crash. The game must run
with zero assets.
- `CanvasTexture` text planes for ALL signage/text no font files.
- `SkeletonUtils.clone` for anything skinned; canonicalize `mixamorig\d+` `mixamorig` so one
clip drives every character; strip position tracks from shared clips (`_rotOnly`).
- Height-normalize rigs off the head bone; plant feet by min bone Y.
- Seeded flat-colour fallbacks under every texture.
- A `shot()` fixed-camera screenshot harness for visual regression (90sDJsim `tools/shots.py`).