# LANE A — NOTES (cross-lane answers from CityGen) Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. ## Round 12 (2026-07-15) — v3.0-alpha GIG INTERFACE (published; C/D/B/F build off this) Everything is behind `?gigs=1` (prime flag law): with the flag OFF the plan is **byte-identical** to v2 (all goldens frozen — synthetic `0x3fa36874`, osm unchanged). The gig layer is a post-hoc augmentation applied by the selector, so it works on synthetic AND osm towns. ### How the gig layer turns on (Lane F wires this) ```js // F's shell bootstrap, when ?gigs=1: const customBands = await loadCustomBands(); // see drop-in below; [] under ?noassets or if absent const plan = citygen.generatePlanFor(seed, src, { town, gigs: true, customBands }); ``` `generatePlanFor(seed, source, { gigs:true, customBands })` → base plan + `withGigs`. Without `gigs:true`, the base plan is returned untouched. `generatePlanFor` stays **synchronous** — F does the async `custom_bands.json` fetch in the bootstrap and passes the array in. ### `plan.gigs` — the nightly schedule (NEW, present only when gigs on) ``` plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ] ``` - 7 nightly entries at the one venue. `night` = 0-based index; **F picks tonight's** (alpha: `gigs[0]`). - `venueShopId` = the pub shop's id. `genreKey` = `'pubrock'` (alpha's one genre; E ships that bed). - `startSeg=5` (NIGHT, 22:00) `endSeg=5`. F's state machine: `quiet → doors (startSeg−1 = DUSK seg4) → on (seg5) → done`. Segment→hour map is lighting.js (`0 DAWN … 5 NIGHT`). - `cover`: integer. **~half are 0 (free, walk in); the rest $2–$10** (F debits the wallet at the door). ### The venue (one `pub` per town) - A spine-END plain shop is converted in place to `type:'pub'` (keeps its lot id + geometry ⇒ no overlap, enterable like any shop). Tagged **`shop.venue === true`**; also `plan.shops.find(s=>s.type==='pub')`. - **Never** the openLate landmark (the video survives — the town still has exactly one openLate shop). - Registry `pub` type added: facades, `interior:'band_room'` (→ **Lane C** builds the pub archetype off this), fittings hints, hours `[17,23]`. It has **no district weights** so it's never auto-placed. - Venue hours `[17,23]` are open through DUSK+NIGHT so the gig runs and **D's patronage surge** fills it. ### `plan.posters` — gig advertising (NEW; **Lane B** renders) ``` plan.posters = [ { id, gigId, x, z, ry }, … ] // ~6, advertising tonight's gig (gigs[0]) ``` One on the venue frontage + a seeded few along the spine, on the poles/walls B already streams. B places E's poster skin at `(x,z)` rotated `ry`. F wires "poster → tonight's-gig discoverability". ### → Lane D: the closing-time debt is PAID — consume `isOpen` `citygen.isOpen(shopOrHours, hour)` is the canonical **half-open** law (`open ≤ hour < close`) — the frozen closing-time ruling as code. Use it for the gig-night patronage surge: the venue is open through the gig, patrons enter while open, and at close they **drain** (finish their dwell) — never popped, and the player is never force-ejected. Your `sim.js _openAt` already matches; switch to `isOpen` so there's one source of truth (or keep yours — they're identical). ### → John: the band-name drop-in (`web/assets/custom_bands.json`) Edit the file, reload — your names get **priority** on the town's posters. Schema: `{ "bands": ["The Feral Galahs", …] }`. **Delete it and the generator takes over** (no errors, no fetch under `?noassets=1`). Deterministic given (seed, file contents). An example ships with 10 names; a town has 7 nightly slots, so up to 7 of yours surface (shuffled by seed). ## Round 9 (2026-07-15) ### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it) Question: what happens to a ped browsing inside a shop when it hits closing time? **Ruling** (now a frozen contract law — CITY_SPEC §"CityPlan v2 → Closing-time occupant ruling"): 1. **No new entry when closed.** Openness is half-open: `open ≤ h < close` (a shop closing at 17 is shut at 17:00). Patronage only routes into an *open* shop; the player gets the CLOSED plate. ✓ your `sim.js _openAt` + `_nearestOpenShop` already do exactly this. 2. **Occupants drain, they don't pop.** A ped inside at close finishes its bounded 5–20s dwell and re-emerges normally — **never** teleported/culled at the close instant. `occupancyOf(shopId)` falls to 0 within one dwell window on its own, so browser rigs (the C→D→F seam) clear with the same drain. ✓ your `patronTimer`/`_emerge` already do this — do NOT add a hard close-time eviction. 3. **Player is never force-ejected** — closing locks *entry* only; a player inside stays until they leave. 4. **The openLate video shop** is the one place still filling after ~21:00; at its own close it drains identically. All seeded + clock-driven ⇒ deterministic occupancy. No code change requested of you — this pins the behavior so it can't drift, and documents it for v3. ### → everyone / F: the CityPlan v2 producer contract is FROZEN CITY_SPEC now has a **"CityPlan v2 — frozen producer contract"** section: producer API, the goldens table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`), the town-key mechanism, osm normalization rules, and the hours/openLate/storeys/closing laws — all matching code (F: diff away). Changes to Layer-1 now require a CITY_SPEC amendment + golden re-pin in the same commit. ## Round 8 (2026-07-15) ### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg. - **Selfcheck parity done**: the full invariant suite (`structuralSuite`) now runs on **every (source, town)** — synthetic and both OSM towns get identical coverage (storeys, one-shop-per-lot, facing, within/cross-block overlap, corridor coverage, determinism, exactly-one-openLate, …). `node selfcheck.js` → **ALL GREEN 1727/1727**. - **Selector gained a town arg** (`web/js/citygen/index.js`): `generatePlanFor(seed, source, { town })`. Shell wiring: read `&town`, pass it through — `generatePlanFor(seed, src, { town: q.get('town') || undefined })`. `map.html` is the reference impl (`?plansrc=osm&town=katoomba`). Unknown/absent town → the default (`melbourne`). `osmTownKeys()` lists the available towns for a UI picker. - **THREE goldens to pin** (seed 20261990), all asserted by selfcheck already: | source / town | golden | |--------------------|--------------| | synthetic | `0x3fa36874` | | osm / melbourne | `0x34cfdec0` | | **osm / katoomba** | `0x0f652510` | The determinism gate should key on **(seed, plansrc, town)**. Synthetic + melbourne goldens are **unchanged** from round 6. - Katoomba (Blue Mountains, 19 shops — op-shop/book heavy) is a deliberate small-regional contrast to inner-Melbourne (95, book heavy). Both boot end-to-end; screenshots in `docs/shots/laneA/`. ### Normalization: the importer bends the DATA to the contract, never the contract (round-8 item 4) Real OSM data that fights a CityPlan contract is normalized deterministically, and the change is **logged** (selfcheck prints a per-town line; `generatePlanOSM(seed, town, { report })` fills `report`): - unknown OSM `shop=` kind → `opshop` (`typesRemapped`); - a shop closing ≥22:00 → clamped to 21:00 so only the one openLate landmark is late (`hoursClamped`); - openLate landmark prefers a **video**; a town with no video falls back to another non-stall type and `report.openLate` records it (both Melbourne + Katoomba have video, so both read `video`). ### RECIPE — add another OSM town (mechanical, ~10 min) 1. **Pick a cluster** in thriftgod's `city_source.json` (Overpass cache). Find a centroid + a cell size that captures one coherent town (aim 15–150 on-theme shops). The extraction one-liner used for melbourne/katoomba is in `A-progress.md` (round 6/8) — reuse it with your `(cLat,cLon,cell)`. 2. **Append the town** to `OSM_TOWNS` in `web/js/citygen/osm_fixture.js`: `key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }` — sort shops by `id` (determinism). Map OSM `shop=` kinds → registry types (charity→opshop, books→book, music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn). 3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`. The unpinned town prints a ready-to-paste line — `⚠ osm/: UNPINNED — add to OSM_GOLDENS → : 0x…,` — paste it into `OSM_GOLDENS` in `selfcheck.js`, re-run → green. (The full parity suite already ran on the town; only the golden needed pinning.) 4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the full parity suite covers the town automatically. *Dry-run (round 9): verified end-to-end with a throwaway Newtown (42 shops) — the parity suite auto-ran (1931/1932, only the golden unpinned) and selfcheck printed `newtown: 0x9045e577,`. Reverted; no 3rd town shipped (didn't fall out "for free"). The recipe is mechanical as written.* ## Round 6 (2026-07-14) ### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin The second plan producer is live behind the same CityPlan contract. Full seam, invariants green (not a partial). What F needs to wire the shell bootstrap + extend the determinism gate: - **Selector API** (`web/js/citygen/index.js`): `generatePlanFor(seed, source)` — `source==='osm'` → real-data importer, anything else → the byte-identical synthetic generator. Also exported: `generatePlanOSM(seed)`, `generatePlan(seed)` (synthetic, unchanged). Unknown source falls back to synthetic. - **Shell wiring** (in `web/index.html`, F-owned — one line): read `?plansrc`, then `const src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';` and call `citygen.generatePlanFor(seed, src)` where it currently calls `generatePlan(seed)`. `map.html` already does exactly this (reference impl). - **Determinism gate**: pin BOTH goldens, keyed by (seed, plansrc), seed 20261990: - synthetic `0x3fa36874` (unchanged this round) - **osm `0x34cfdec0`** (`xmur3(JSON.stringify(generatePlanOSM(20261990)))`) `selfcheck.js` already asserts both; the smoke can just hash-compare. - **Zero network**: the fixture is `web/js/citygen/osm_fixture.js` (95 real inner-Melbourne shops from thriftgod's Overpass cache), imported as a JS module — no fetch/XHR at runtime. **⚠ OSM plans differ from synthetic in shape (by design — real towns aren't the synthetic template):** - `plan.source === 'osm'` is set; `plan.size` is the real bounding box (~362×486), **not** 1024². - **No synthetic districts/types**: an OSM town has NO market square, arcade, dept anchor, milk bars, or stalls — only the real retail types (record/opshop/toy/book/video/pawn). Any code that ASSUMES a dept/market/milkbar exists must guard on `plan.source !== 'osm'` (or just not assume). - Everything B–F actually consume is identical: nodes/edges/blocks/lots/shops schema, `ry`/ `frontEdge` semantics, `chunkIndex`, hours + the exactly-one-openLate-video rule all hold. - Shop `name`s are the REAL OSM names (e.g. "Central Catholic Bookshop"); `sign` is derived. Parody-transform of names is a documented deferred nicety, not a blocker. ## Round 4 (2026-07-14) ### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives `tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round: - `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4). - **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓ ### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video) Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **4–15 shops/seed**, not one: regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract (`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it: - **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen `openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall only if a town somehow has no video — never happens in practice). - **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate` shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the same singleton — **gate on either**, they agree. - selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video). **⚠ GOLDEN.hash changed: `0x098eec2b` → `0x3fa36874`.** This is the *intended* consequence of the A2 plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not** drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins). ## Round 3 (2026-07-14) ### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed) Your consistency gate prints ~5 warnings per run: `"N shop(s) have storeys outside their registry type range … else a plan↔registry drift — ask Lane A"`. **Verified across 8 seeds: they are all the intended "occasional 3-storey corner anchor" (CITY_SPEC), never drift.** Evidence: - Single-storey types (`video`/`milkbar`/`stall`, registry `[1,1]`) are observed at 1 storey ONLY — e.g. 412 video shops, none at 2. So the specific "video at 2" example does **not** occur. - The only shops above their registry `max` are tall-capable types (`record`/`opshop`/`toy`/`book`/`pawn` `[1,2]`, `dept` `[2,3]`) reaching 3 via the corner anchor. Round-1's `cornerBoost` gate only boosts types with registry max ≥ 2, so it can't over-raise a single-storey type. **To silence the warning correctly**, scope your check to the *permitted* max, not the strict registry max: ``` permittedMax(type) = registryMax >= 2 ? Math.min(registryMax + 1, 3) : registryMax // flag only shops with storeys < registryMin || storeys > permittedMax → that IS real drift ``` This rule is now documented in `docs/CITY_SPEC.md` (Layer-1 shops schema, `storeys` line) and enforced by `web/js/citygen/selfcheck.js` (tightened this round — a single-storey type exceeding 1 now fails loudly). ### → Lane F: `hours` / `openLate` contract is ready for your §3.5 wiring (no changes) `hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. **Exactly one shop per town** has `openLate: true` (closes ≥ 22:00; a video rental or milk bar, never a market stall); the field is absent on all others. Gate on the field — `plan.shops.find(s => s.openLate)` — not a magic hours threshold. Your ROUND3_INSTRUCTIONS §Lane F states it correctly, so consume as-is.