# 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. - **THE TWO CONVENTIONS — they are different things, and conflating them cost nine rounds (ROUND27). Read both:** - **The FACADE convention (the lots contract, `lot.ry`): the visible shopfront is local `+Z`** ⇒ world `(sin ry, cos ry)`. This is B's canon and the de facto truth since v1 — `buildings.js` draws the facade quad and door at local `z = +d/2`, `venue.js` seats the door-glow/queue/ posters there. **`lot.ry` aims +Z at `frontEdge`.** - **The SIM/RIG convention (props, not lots): a GLB is modelled facing `−Z`** (origin at base, real-world scale) ⇒ rig-forward is world `(−sin ry, −cos ry)`. This describes *how a model is authored*, **not which way a lot faces.** - **They are opposites, so a check written in one silently passes over a world built in the other.** CITY_SPEC:71 wrote the SIM convention into the LOTS contract by ambiguity; each generator implemented one half; every golden stayed green over a town whose every shopfront faced away. **Never assert a lot's facing against a spec line — assert it against the geometry the renderer actually draws** (`selfcheck.js` now does exactly that). - **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 the lot's VISIBLE FACADE — local +Z — points at frontEdge. // Facade/door/frontage normal = local +Z ⇒ world (sin ry, cos ry). Lane B rotates the shell by ry // and draws the shopfront at local z = +d/2 (buildings.js), so +Z IS the street face. ROUND27. // (The SIM/rig convention — a GLB modelled facing −Z, world (−sin ry, −cos ry) — is a DIFFERENT // thing and is documented as such below. It is not the lots contract. See the amendment.) // 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.)* > > *(**Recorded amendment — ROUND20, ledger #2:** the R16 clearance bias above **widened to every venue > kind**. R16 exempted `pub` because on the synthetic/marched towns a pub's `+Z` is always open; on a real > road graph at E's widened density it is not (newtown: a pub whose facade overhung a cross street). On the > synthetic town every pub/rsl candidate already clears ⇒ no-op there, gig golden `0xb1d48ea1` frozen.)* > > *(**Recorded amendment — ROUND21 ledger #1 + ROUND22 ledger #2: the venue cluster bias.** D's relocation > finding: a venue a kilometre from the retail cluster is a gig with no passing crowd (katoomba's pub had > **1** shop within 160 m; its RSL had 13). `pickVenues` now draws each venue from a **cluster field** — > candidates with ≥ `CLUSTER_MIN` (3) other shops within `CLUSTER_R` (160 m) — and lets the kind flavours > bias *within* that field rather than override it. Venues under the floor: **11 of 15 → 0** across the > five R21 towns; Newtown's RSL moved 2,228 m → 29 m from the hub. **Real towns only, by construction:** > the bias reads `plan.source === 'osm'`, which the synthetic generator never sets, so it cannot move > `0x3fa36874`/`0xb1d48ea1` — measured, not assumed (4 synthetic candidates per seed sit under the floor, > so a blanket filter *would* have moved them). **ROUND22 graded fallback:** at pack scale three towns have > no candidate at the floor at all (`ballarat`/`toowoomba`/`newtown_godverse` top out at 2 within 160 m). > R21 dropped the bias entirely there, stranding venues at 0 neighbours; it now falls back to the **densest > available** candidates instead. Fires only where the floor is unreachable ⇒ no already-pinned town moved. > **Acceptance at pack scale: 20 of 23 towns have every venue ≥ `CLUSTER_MIN`; those three sit at their > town's genuine ceiling** — the carved-out case, stated rather than papered over.)* > **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 frozen regression target):** the `?classic=1` boot is byte-identical to the v2 > baseline — synthetic fingerprint **`0x5f76e76`**, 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. > > > **THE COVENANT AMENDMENT — ROUND27, THE FACADE FIX (John's ruling; the only amendment in five epochs).** > > The covenant froze `0x3fa36874` "forever" from R16 to R27. That hash anchored **a wrong world**: every > > shopfront in every town — synthetic and real — faced *away* from its street, because CITY_SPEC:71 wrote > > the SIM convention into the LOTS contract (see *Units, axes*). The covenant did exactly its job for nine > > rounds: it held the base town byte-identical. It just held it identical to something broken, and no gate > > could see it — a self-consistent check over a self-contradicting spec passes under either convention. > > > > **The covenant's purpose is anti-drift, not the preservation of a defect.** So on John's ruling it is > > **amended once, deliberately, in the open**: the anchor moves `0x3fa36874 → 0x5f76e76`, and every other > > golden moves with it (gig `0xb1d48ea1 → 0xec7a2d39`, the three marched fixtures, all 23 real towns base > > + gig — **51 pins in one commit**, per the amendment law). The classic town is now what it always claimed > > to be: a street you can stand on and read the shopfronts. **From this hash forward the law reads as it > > always did — byte-identical forever.** An amendment requires a John-level ruling and a demonstrated > > world-level defect, not a convenience; this is the precedent, not an opening. > > > > *Exposed by the same fix (and fixed with it): `buildRealRoads` seated blocks at `KERB = 4 m` from the > > centreline while a `main` carriageway is 10 m wide — its kerb is at 5 m. Every main-street building has > > stood 1 m inside the road since R18. Invisible while nothing was drawn on that face; the moment the > > shopfront turned around, its poster landed in the traffic lane. Blocks now seat behind the kerb per the > > corridor law (`roadWidth/2 + FOOTPATH`). Pack drops 6.4% → 6.6%, counted.* > - **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 | **The cluster field comes first (ROUND21/22 — real towns only).** On an osm plan every kind rule in the table runs **inside** the retail cluster. Order per venue: cluster field (≥`CLUSTER_MIN` other shops within `CLUSTER_R`, or the densest available if none reach the floor) → the kind flavour → the facade-clearance filter → `farthestFirst` shortlist → seeded pick, each step degrading to the previous set rather than to the whole town. A fringe band_room *in the cluster* beats a fringe band_room a kilometre from anyone. Synthetic plans skip the field entirely (`plan.source` is unset) ⇒ goldens frozen. See the amendment note. 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 lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** (ROUND16 ledger #6, **widened from band_room/rsl to every venue kind in ROUND20** — 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**; a candidate that would overhang a **real** crossing carriageway is skipped (ROUND19, E's finding), and the run is **capped at `POSTER_PER_SHOP × shops`** (ROUND20) — a sparse real town has few shops but a big edge graph, so the raw "2 per edge" run reads busy (katoomba 438 → 31). The cap never binds on the shop-dense synthetic town, so its gig golden is frozen. 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). ## Town caches — the real-map scout (v3.2, ROUND17 ledger #6) > Bounded groundwork for **v4 THE REAL MAP** (John's pick). The `?plansrc=osm` producer already eats > processed OSM data (three fixtures in `osm_fixture.js`); v3.2 lets it eat **real town caches** E scrapes > from Overpass — **no new plan features**, the existing contract proven against real geography. **The town-cache contract** (Lane-A-owned; authoritative implementation is `validateTownCache()` in `web/js/citygen/plan_osm.js`, home `web/assets/towns/.json`, full spec in that dir's `README.md`): a processed town is `{ schema:"procity-town-cache/1", key, town, source:"osm", license, attribution, center:{lat,lon}, shops:[ { id, name, type, lat, lon, suburb? } ] }` (+ optional `bbox`/`counts`/`fetchedAt`). E's `build_towns.py` produces it (raw Overpass → dedupe/suburb/parody-name/seeded-hours); `plan_osm` marches it identically to the fixtures. **Hard requirements:** finite `center.{lat,lon}`; `shops` ≥ `MIN_TOWN_SHOPS` (6 = up to 4 venues + the openLate landmark + a spare); finite shop `lat`/`lon`. **Absorbed (warnings):** blank name → type label, unknown `type` → `opshop`, dup ids, and **span > 5 km → "likely more than one town"** (a cache is ONE compact town — an over-broad bbox marches into an unusable multi-km strip). **The spacing floor (v5.0-alpha, ROUND23 ledger #5 — D's R22 finding as law).** `MIN_TOWN_SHOPS` counts shops; what makes a town **read alive** is *clustering*. D measured both 12-shop towns in the pack and they disagree — which is the finding: **darwin ALIVE** (one strip; 1,455 patronage checks → 18 visits) vs **toowoomba DEAD** (scattered over a 4.6 km road network; 1,417 checks → **0 visits, 0 finds** — "not a shopping town, a road network with occasional shops"). Both pass every other law. So `validateTownCache` now **warns when `medianShopSpacing(cache) > MAX_MEDIAN_SPACING_M` (150 m)**: - **The metric** (exported from the barrel — import it, don't re-derive it): for each shop, the distance to its **nearest other shop**; take the **median**. Equirectangular metres about `center.lat`, the same convention as the span check. `null` for < 2 usable shops. - **Warn, never error.** A flagged cache still boots a valid district — toowoomba shipped green through the entire v4.0 pack. **E curates** (re-bbox onto the retail strip, or retire the town, counted either way); the validator only flags. The span warn catches a town too **wide**; this catches one too **sparse**, a case span cannot see (toowoomba is *inside* the 5 km law). - **The threshold is measured, not chosen.** Across the 23-town pack the line is **forced** into `(119.3, 254.9]`: braddon at 119.3 m is alive by D's own hub test (7 shops within 160 m — the same as darwin's 7), and ballarat at 254.9 m is not. 150 m sits in that gap — the pack's widest (**×2.14**) — with 1.26× margin below and 1.70× above. It flags **2 of 23**: ballarat 255 m, toowoomba 396 m. - **⚠ CACHE space, not PLAN space — they do not agree.** This runs on raw lat/lon, before any lift; D's notes quote **plan** figures (post-lift metres). The lift marches shops at a regularised cadence, so it **compresses** dense towns (darwin **68.3 m cache → 27.2 m plan**) and leaves sparse ones alone (toowoomba 396.1 → 386.8). Both numbers are right; they measure different spaces. Cache space is correct *here* — the validator's job is to flag a bad bbox **at ingest**, before a lift exists — and it is faithful exactly where the warn lives (ballarat plan/cache **1.01**, toowoomba **0.98**). Known limit: one town flips (`newtown_godverse`, cache 94.4 → plan 158.3); no cache-space line can catch it without false-positiving braddon. See `LANE_A_NOTES` §R23. **Wiring:** `generatePlanOSM(seed, key, { cache })` marches a cache; `registerTownCache(key, cache)` adds it to the registry (`osmTownKeys()` + `generatePlanFor(seed,'osm',{town:key})` then see it). `selfcheck.js` auto-loads `web/assets/towns/*.json` — validates, runs the structural + gig suites, pins a per-town golden. **Provenance:** OSM is **ODbL**; attribution ships with the data (E owns `SOURCES.md` + the `license`/ `attribution` fields; raw Overpass responses cached so re-runs never re-fetch). ### `godverseShopId` — the stock identity (v5.0-alpha, ROUND24 ledger #2; A + G) The optional shop key that resolves **`stock_godverse//`**. **Schema stays v2** — optional field, absent everywhere except godverse caches, so every shipped cache stays valid *by construction*. **It is NOT `shop.id` and NOT "the census id"** — the round doc's "the POS shop id IS the id" holds for census shops but breaks on the shop the alpha is about. Two disjoint id spaces ride one opaque field: census shops use thriftgod `shop.id` (max **2992**), while **Monster Robot Party is not in thriftgod's census at all** and uses its **dealgod store id 3962749**. So: never derive it from `id`, never assert equality, never build a mapping table (the R12 gigKey law — one key, zero mapping). **G emits, all read.** **A godverse cache is MIXED, by design — RULING 2 (confirmed as law, ROUND25).** It carries a **keyed GODVERSE layer** (census shops + injected shops like Monster Robot) **plus an inherited OSM texture layer that is legitimately unkeyed**. G measured that the texture layer is load-bearing, not cosmetic: census-only Red Hill shipped a 306 m spacing warn and a poster inside a kerb; with the texture layer it passes. **So "shops without an id" is the design, and no gate may assert it away** — mine did, in R24, and failed 54/72 on a healthy town. **`validateTownCache` arms** (split by hazard, not tidiness): a **malformed** id (not a positive integer) is an **error** — it names a path segment, and `"3962749"` vs `3962749` is exactly the coercion drift that broke the atlas contract; a **duplicate** id is an **error** — two shops sharing one atlas *is* mis-stocking (charter risk #3: "never mis-stocked"); a godverse cache with an **entirely empty GODVERSE layer** (no id on any shop) is a **warn** — it's an osm cache wearing the wrong `source`, and it stays a warn because the charter's determinism boundary says *world = seeded and gated, stock = data tiers*: a town must never fail to load because its **stock** identity is absent. Tier 0 is the designed ladder, not a defect. **The `selfcheck` arm runs per-layer** — every assert is over the **keyed subset only**: the layer reaches the plan, its ids are **unique over defined ids only** (the R24 version compared a Set of *all* ids, which collapses every `undefined` into one entry — it failed structurally on a mixed cache *and* could hide a real duplicate behind that collapse), and the lift invents no identity. Plus the **orphaned-atlas gate**: a committed atlas under `stock_godverse//` whose shop the lift dropped means real stock keyed to a shop that isn't in the town — it fails, and SKIPs by name when a town has no atlas. Dropped keyed shops are printed, never silent (the R19/R20 drop-and-count ruling). **It survives the lift:** `plan_osm` copies it onto `plan.shops[]` (the runtime reads the plan, never the cache — otherwise the field is decoration and F's per-shop `base` can never resolve). Absent ⇒ the key is **not written**, so `JSON.stringify` is byte-identical and **every pinned golden holds until G re-emits**; that re-emit is the sanctioned golden move. ### Schema v2 — real roads (v4.0-alpha, ROUND18 REAL ROADS) > The gap the scout left: the marched fallback lays real shops onto **synthetic** parallel avenues. > Schema v2 closes it — a cache carries the town's **real OSM street geometry** and `plan_osm` builds the > CityPlan street graph FROM it. **Opt-in by the cache** (a v2 cache with `roads[]` = real roads; a v1 > cache, or `roads` absent, = the marched fallback), so default + classic boots are untouched by > construction (they never load a town cache) and every shipped cache stays valid. A **v2** cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` — simplified OSM ways, `kind` the OSM `highway=*` class (`validateTownCache` accepts schema `…/1` and `…/2`; a road needs ≥2 finite points, `ROAD_KIND` maps the class to a CityPlan edge kind). The graph lift (`plan_osm.buildRealRoads`): 1. **project** ways + shops to the local metre frame; **simplify** each way (Douglas–Peucker, ε≈6 m — the fidelity knob, charter risk #4); 2. **snap** coincident points (≤3 m) to shared nodes = **real intersections**; straight-segment **edges**; 3. **classify** `main`/`side` from the OSM class, promoting the shop-densest edge to the `main` spine if none came from OSM; 4. **seat** each shop on its **nearest real edge** at its real order + real side, marched with regularised spacing (real shops overlap — pixel-exact positions can't satisfy the no-overlap invariant), a frontage-strip block per edge, then a deterministic **overlap-resolve** pass (crowded intersections drop the later-id lot). **Same CityPlan schema out** — B/C/D/F consume it unchanged by contract. **The fragmentation ruling (ROUND19, Fable — A owns the details).** Real geometry fragments the naïve graph (katoomba: **105 components**, 58% of street-metres on islands, D's finding). Two mechanisms, both handled: (a) **junction-protected simplification** — a point shared by ≥2 ways is a junction; Douglas–Peucker runs only *between* junctions, never through one (the collinear junction-drop that tee-off side streets hit). That alone reconnects to 94–100% main-net. (b) **cull/bridge** — the town is the **main component + joined near-fragments** (endpoints ≤ `JOIN_TOL` 12 m, or a shop-bearing island ≤ 200 m, get a bridge edge); a **far shopless island is culled** (bbox-clipped noise + dead streaming budget). A **stranded shop is always connected or dropped with a count** (`norm.dropped`). Result on all five towns: **one connected component**. A spine poster that would overhang a real crossing carriageway is **skipped** (E's finding; no-op on the synthetic town, so its gig golden is frozen). Golden re-pins per the amendment law (all five real towns pin on their real-roads output; synthetic + marched fixtures + classic/default untouched — the alpha changed nothing outside the cache-schema path). > **FINAL at `v4.0` (ROUND22 ledger #6, the epoch close).** This section — schema v2, the lift, the > connectivity rulings above, the venue cluster bias and poster cap in §v3 — is the authoritative real-map > contract at the tag. Changes are an amendment + a golden re-pin in the same commit, as always. > > **The alpha's five became the epoch's 23** (22 real towns + Lane G's `newtown_godverse`; **1,210 shops**), > and every ruling written above held at that scale with **no new hardening**: **one connected component and > three venues on every one of the 23 towns**, fitzroy's **160 shops** — 2× anything the pipeline had seen — > included. All **36 goldens** (`REAL_TOWN_GOLDENS` + `REAL_TOWN_GIG_GOLDENS`, 23 each) are pinned; selfcheck > runs **161,300** checks green, cross-process deterministic. Read "all five towns" above as the measurement > of record *when the ruling was written* — the ruling itself is now pack-wide. > > **The honest tail, carried not hidden:** 6.4% of shops (77 of 1,210) don't seat, every drop attributed by > cause, dominated by overflow where a town has more shops than its road graph has frontage (daylesford 19%, > the pack's thinnest graph; fitzroy 13%). See `V4_REAL_MAP.md` risk #6. The R20 ruling stands: **drop and > count beats teleport.** The frozen set is unmoved by the entire epoch: synthetic `0x3fa36874`, gig > `0xb1d48ea1`, marched fixtures, classic. ## The stock ladder (v5 — THE REAL SHOP; frozen at `v5.0`, ROUND27 ledger #6) > **v4 made the towns real; v5 makes the shops real.** The map-level contract above is unchanged by this > epoch: **stock is DATA, not world.** The charter's determinism boundary is the load-bearing line — > *world = seeded, frozen, golden-gated; stock = data tiers riding the asset law.* Nothing in this section > may move a plan golden, and nothing in it did: the whole epoch left synthetic `0x3fa36874`, gig > `0xb1d48ea1`, the marched fixtures and classic untouched. **The tier ladder.** Each rung is fully playable and degrades **softly** to the one below — the fail-soft pattern proven since `?noassets` on day one, made structural: | tier | what | flag | deterministic? | |---|---|---|---| | **0 · parody** | seeded canvas sleeves (v1) | any boot | yes — the QA baseline | | **1 · static real** | per-shop atlases baked from dealgod/POS | `?stock=real`, **no server** | yes — atlases are files | | **2 · live GODVERSE** | thriftgod read-only endpoints; sold-means-gone | server reachable | **no — by design** | **The offline law (charter law #1) is the epoch's gate, not a footnote:** the server *enriches, never gates*. No boot path, gate, or tag may depend on a reachable server. Tier 2 is therefore **never inside a golden or a byte-identical assert**; it gets its own smoke class whose server lifecycle the gate itself controls, so the *assertion* is deterministic even though the *stock* isn't. **`sourcing` is not `tier`** (C's ruling, R26 — and it's the distinction that keeps the ladder honest). The rung and the provenance are different axes, and both ship in every atlas index: - **`tier: 1`** — the ladder rung. A minted atlas is *still* a static file, so it is *still* rung 1. - **`sourcing: "real" | "mint"`** — where the contents came from. **`real`** = a POS snapshot (Monster Robot Party's actual crates). **`mint`** = a seeded `random.Random(shop_id)` sample of real dealgod listings — *plausible, not that shop's stock*. Only one shop in the world has a POS; minting is how the other keyed shops get distinct crates **without pretending to data we don't hold**. Current pack: **15 keyed atlases = 1 real + 14 mint**. No gate, doc, or human may read a mint crate as real — that is the whole point of the field, and gates assert on it. **The id-space fences.** Three namespaces meet at the crate, and **every collision this epoch came from comparing across them**. They are fences, not decoration: | space | form | scope | fenced from | |---|---|---|---| | **plan `shop.id`** | small int, `0..N` per town | one CityPlan, local | `godverseShopId` — **numerically overlapping** | | **`godverseShopId`** | census id (**≤2992**) ∪ dealgod store id (e.g. **3962749**) | the stock namespace | its two halves are numerically **disjoint by construction** (G) | | **atlas slot id** | **`sku_`** / **`mint_`** | one atlas index | each other, by **prefix** | - **plan id vs `godverseShopId` — never compare a bare number across them.** They are *semantically* disjoint namespaces that *numerically overlap*, which is the trap: in `redhill_godverse` today, bare **`31`** is **"Wholefood Cafe"** as a plan id and **"Silky Oak Furnature"** as a godverse id. That exact ambiguity made F's `enterShop(31)` enter the wrong shop and a soft-fall assertion pass **vacuously on the wrong subject**. Verified still live in the data — so this is a standing rule, not a fixed bug. - **`sku_` vs `mint_` is a sourcing-scoped namespace fence** (C §7.2a as amended, R26 wave 4). A crate wearing the other sourcing's prefix **fails**. The fence is *stronger* than the rule it replaced: the easy green was deleting the prefix check; instead the prefix now proves its sourcing. - **Positional ids are banned** (R25): `rec_0000…` numbering collides across packs by construction, which is what made the integrator's own id-equality gate vacuous. Slot ids are sku-derived and unique across packs, stable across re-emits. **The manifest — `web/assets/stock_godverse/index.json`** (G emits, F consumes, E validates, C's §7.2d). It declares **which `godverseShopId`s carry atlases** (`types[]` + `sourcing` per entry; mint included). **Existence is declared, never probed.** This is the root fix for a real collision between two laws: fail-soft-by-404 vs the zero-console-errors law — the runtime used to probe blind and eat 404 noise, tolerated by attribution, which is a workaround, not a design. No manifest entry ⇒ no fetch ⇒ **zero 404s, no exceptions**. Consistency is gated both ways (an atlas on disk absent from the manifest **fails**, and vice versa — vacuous-gate law shape). Today: **15 entries, 15 dirs on disk.** **Lane A's seam to all of it** is exactly one field — **`godverseShopId`** (documented above): it rides the town cache, survives the lift onto `plan.shops[]`, and is what the runtime resolves `stock_godverse//` from. **`'godverseShopId' in shop` is the tier test**: present ⇒ tier-1+ candidate, absent ⇒ tier 0. The key is *absent, not undefined*, on every unkeyed shop — which is why the entire stock epoch moved **no plan golden**. `selfcheck` gates the seam per-layer (Ruling 2), including the **orphaned-atlas** check: a committed atlas whose shop the lift dropped is real stock keyed to a shop that isn't in the town. **Ruling 2 (law):** a godverse cache is **deliberately mixed** — a keyed GODVERSE layer + an inherited OSM texture layer that is legitimately unkeyed. G measured it load-bearing: census-only Red Hill was unshippable (306 m spacing warn, a poster inside a kerb). **No gate may assert it away** — mine did, and called two healthy towns broken. ## 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`).