# 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 5–20s 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 ~2–3; 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). ## CityPlan v3 — gig layer (`?gigs=1`) — THE DISTRICT > **FROZEN at `v3.0` (round 14).** The authoritative Layer-1 **gig-layer** contract — venues, the week's > schedule, posters, the `gigKey` map, the corridor law, and the runtime surface. `web/js/citygen/selfcheck.js` > enforces every law here, including the round-14 **400-seed district invariant sweep** + an osm > graceful-placement case. Changes are a **CITY_SPEC amendment in the same commit + a gig-golden re-pin**, > never silent drift. **One edit is still pending and lands with Lane F's alias-retirement commit** (R13 > debt #1): the R12 single-venue compat alias in the runtime block below retires; everything else here is > final for the `v3.0` tag. > > *(**Recorded amendment — ROUND15, ledger #1:** the frontage poster was flipped to the `+Z` street > facade (it had been on the `−Z` back, invisible from the street); this moved `plan.posters`, so the gig > golden re-pinned `0x1f636349 → 0x4f4a549d` in the same commit under the amendment law above. The > synthetic golden `0x3fa36874` did not move — this is the round's one sanctioned gig-golden move.)* > > *(**Recorded amendment — ROUND16, ledger #6:** on a narrow through-lot a band_room/rsl's `+Z` facade > can abut the block's *other* street, putting its frontage poster over that carriageway. `pickVenues` > now **prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** (0.5 m; > broadens off-spine if the warehouse fringe is uniformly narrow). This moved `plan.posters`, so the gig > golden re-pinned `0x4f4a549d → 0xb1d48ea1`; synthetic `0x3fa36874` still frozen. The v3.1 default flip > + classic law is F's amendment to this section — see the flags table.)* > **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector when the gig > flag is on. With it off, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan — **all v2 > goldens stay frozen**. So this section adds fields that exist *only on gig-on plans*; it does not touch > the v2 contract above. Deterministic from `(citySeed, customBands)`; works on synthetic AND osm towns. > Node-pure (no THREE, no runtime fetch — the shell loads the band drop-in and passes it in). > > **Prime flag law — REWRITTEN at `v3.1` (ROUND16, THE FLIP; Lane F amendment).** The covenant MOVED, it > did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full > experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and > **`?classic=1`** forces all four off at once: > - **Classic law (the new frozen regression target):** the `?classic=1` boot is byte-identical to the v2 > baseline **forever** — synthetic fingerprint `0x3fa36874`, zero gig layer, **zero fetch delta** (no > `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate. > - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover > smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block) > now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays > silent-and-fine. > - The four flags read through one rule in the shell — `flagOn(name) = !classic && param !== '0'` (winmap > reads the same rule in `buildings.js`); the shell publishes the intent on `window.PROCITY.flags`, and the > seated drummer's `sit.glb` rides `!classic` so classic keeps its zero-fetch-delta. > > *(v3.0-alpha was one pub, one genre, one night's posters. **ROUND13 → v3.0-beta, the district:** 2–4 > venues of three kinds, a full week's schedule across town, town-wide posters. Same flag, same laws.)* **Turning it on** (Lane F): `generatePlanFor(seed, source, { gigs:true, customBands }) → plan` with the gig fields below (else the base plan, untouched). `generatePlanFor` stays synchronous — F fetches `custom_bands.json` in the bootstrap and passes the array. `withGigs(plan, seed, opts)` is also exported. **The venues (2–4 per town — a seeded count).** Three registry venue kinds — **`pub`**, **`band_room`**, **`rsl`** — each with **no district weights** (never auto-placed). The layer converts that many plain `shop`s **in place** (keeps each lot id + geometry ⇒ enterable, no overlap), tags them `shop.venue === true`, `shop.venueKind`, `shop.genreKey`, and **never** takes the openLate landmark (the town keeps exactly one openLate shop). Kinds are added in the order `pub, band_room, rsl, pub` (a 4-venue town gets a second pub at the far end of the spine). Placement rules (each degrades to "any remaining plain shop" so osm towns still get venues): | kind | `genreKey` → gigKey | placement | hours | reads as | |---|---|---|---|---| | `pub` | `pubrock` → `gig-pubrock` | spine **end**/corner (main edge, farthest) | `[17,23]` | ≥2-storey corner hotel | | `band_room` | `grunge` → `gig-grunge` | warehouse-district fringe | `[18,23]` | single-storey tin shed | | `rsl` | `covers` → `gig-covers` | **off**-spine (not main, not warehouse) | `[16,23]` | carpet-and-bistro club | Venue hours all close `23` (open through DUSK+NIGHT so the gig runs and D's surge fills the room; drains at close per the closing-time ruling above). Venues are the *only* gig-plan shops allowed to close ≥22 besides the openLate video (selfcheck exempts them). `genreKey` is set from the registry kind→genre map (`SHOP_TYPES[kind].genre`, mirrored by `genreForVenueKind(kind)`); John may re-flavour the two new genres (names only — E synthesises to order). **The gigKey contract (ROUND13 debt #1 — one key, zero mapping).** The audio-manifest bed key for a genre **IS** the gigKey, and it is **always** `gig-`. There is no mapping table anywhere: A emits `shop.genreKey` + `gig.genreKey`, C sets `audio.gigKey = 'gig-'+genreKey`, E names the manifest bed identically, B's spill reads it and F resolves it — all through the single exported helper **`gigKeyFor(genreKey) => 'gig-'+genreKey`** (`web/js/citygen/gigs.js`, re-exported from the barrel). Import it rather than building the string; R12 lost a bed to a private rename (`gig-pubrock` vs `pubrock-live`) and, because audio fails soft, the band just mimed. **`plan.gigs`** — the week's schedule across the whole district (present only on gig-on plans): ``` gigs: [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover } ] ``` - **7 nights × every venue, but not every venue plays every night**: each venue takes a seeded **4–6** of the 7 and goes dark the rest (dark nights read true). `gigId` unique town-wide; `night` = 0-based, **F selects tonight's** (night `0`) per venue. `venueShopId` → the venue shop id; `genreKey` ≡ that venue's genre. - `startSeg = 5` (NIGHT / 22:00), `endSeg = 5`. F's state machine (now **per venue**): `quiet → doors (startSeg−1 = DUSK) → on → done`. (Day-segment→hour map is `lighting.js`: `0 DAWN … 5 NIGHT`.) **Runtime surface** F publishes on `window.PROCITY.gigs` (B's `venue.js`/`audio.js` and D's `setGig` read it): `.byVenue → { [venueShopId]: 'quiet'|'doors'|'on'|'done' }` plus per-venue getters `stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/nightOf/paidOf(id)` + `markPaidOf(id)` and `.venueShopIds`. Every read is keyed by `venueShopId` — there is no scalar alias (the R12 single-venue alias retired in R15). - `cover` — integer in `{0} ∪ [2,10]`, **~half free town-wide, skewed by kind**: pub 50% free / `$2–10`, RSL 45% free / `$2–5` (cheap — the bistro pays the bills), band_room 70% free / `$2–6` (a hat on a milk crate). F debits the wallet **per venue** at each door; a night at two venues is two covers. - **No band plays two venues on one night** (≤1 slot per name per night across town): custom names are deduped and used at most once; a generated name that would clash tonight advances its stream until it doesn't (deterministic — the generator index only moves forward). **`plan.posters`** — town-wide gig advertising (Lane B renders): `posters: [ { id, gigId, x, z, ry } ]`. Only **tonight's** (night 0) playing venues are advertised. Each such venue gets one poster on **its own frontage** — seated on the **+Z street facade** (B's canon: `buildings.js` facade + `venue.js` door/queue/ camera are all `+Z`; position `lot + (sin ry, cos ry)·(d/2+ε)`), turned to read *from* the street (`ry = lot.ry + π` so its printed face points `+Z` at the queue-side camera — **ROUND15 ledger #1**; before R15 it was on the `−Z` back and invisible from the street). So the poster never sits over a back-street's bitumen, **`pickVenues` prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** (ROUND16 ledger #6 — exported from the barrel; the selfcheck asserts the same floor). Plus a seeded run along the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole runs — that IS the district reading. `ry` follows the house convention (a poster's printed face = `(−sin ry, −cos ry)`, so `atan2` of the *away-from-viewer* normal); B places E's poster skin at `(x, z)` rotated `ry`; F wires "poster → that venue's tonight-gig". **The street-corridor law (ROUND13 debt #5 — published as functions, not plan data).** `edge.width` is the **full corridor** (carriageway + verge/footpath, centreline ± width/2), never the carriageway alone — so a naive `node.x + 2` seats furniture mid-bitumen. `web/js/core/registry.js` now exports the split as pure functions over an edge (no plan fields change ⇒ base goldens stay frozen), re-exported from the barrel: **`roadWidth(edge)`** (full carriageway width — main 10 m, side 6–8, lane = all, arcade 0; the kerb sits at `roadWidth/2` from the centreline), **`vergeBand(edge) → [inner,outer]`** (where footpath furniture lives), **`poleOffset(edge)`** (where a pole/poster seats — just behind the kerb). Poles, verandah posts and **spine** posters belong in the verge band; the selfcheck asserts no **spine** poster stands within `roadWidth/2` of any edge centreline. A **frontage** poster is exempt — it is bound to its venue building (the +Z facade B draws), so at a corner venue whose facade abuts a crossing street it may legitimately sit near that street's kerb (B's building geometry, not a mid-road defect). **Band names + the drop-in.** `bandName(seed)` generates 90s-Aussie pub-rock names from wordlists. `web/assets/custom_bands.json` is an **OPTIONAL** editable drop-in (schema `{ "bands": ["The Feral Galahs", …] }`, Lane-A-owned per ROUND12): present → those names get **priority**, deduped and shuffled by seed, spread **night-major across the whole week's slots town-wide** (not one venue's residency); **absent → pure generator, zero errors, zero fetch** (the `?noassets=1` path). Deterministic given `(seed, file contents)`. John edits the file and reloads. **Closing-time law as code (v2 debt paid).** `isOpen(shopOrHours, hour)` is exported from the barrel — the canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patronage surge and everyone else. **Gig golden** (guards gig-layer drift; pure-generator path, no custom file), seed `20261990` synthetic: **`0xb1d48ea1`** (**ROUND16 re-pin** — the band_room/rsl facade-clearance bias, ledger #6, moved `plan.posters`; was `0x4f4a549d` the R15 +Z flip, `0x1f636349` the R13 district, `0xa6ae5a5e` the R12 alpha). Asserted by `selfcheck.js` alongside the frozen v2/classic golden; changing gig output re-pins it. The gig sweep now also runs a **no-spine osm fixture** (`silverton`, single-row) proving `pickVenues` places venues with no main spine (ROUND16 ledger #7). ## 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 | | `pub` (v3 venue) | djsim-pub, federation | pub | bar counter, stage, PA stack, tables, stools | | `band_room` (v3 venue) | warehouse-tin, besser, corrugated | band_room | bar counter, stage, PA stack, stools | | `rsl` (v3 venue) | brickveneer, redbrick | rsl | bar counter, stage, PA stack, bistro tables, stools | ## 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/assets/custom_bands.json` | Lane A (v3 band drop-in — carve-out from `web/assets/*`, per ROUND12) | | `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 A–E 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=.glb`, `GET /a/`, `GET /api/list` (CORS on). The shared GLB CDN all sibling games use. - **MeshGod** `https://digalot.fyi/meshgod` — image→GLB 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`).