PROCITY/docs/LANES/LANE_A_NOTES.md
m3ultra acf734a1e5 Lane A round 12: v3.0-alpha gig layer (?gigs=1) — venue, plan.gigs, band names, closing-time debt
Publishes the gig interface the whole round hangs off (A runs first). Everything is
behind ?gigs=1 as a POST-HOC augmentation (gigs.js withGigs, applied by the selector),
so generatePlan is untouched and all v2 goldens stay frozen (synthetic 0x3fa36874, osm
melbourne 0x34cfdec0, katoomba 0x0f652510). Source-agnostic (synthetic + osm).

- Venue: one 'pub' per town — a spine-end plain shop converted in place (keeps lot
  id/geometry, enterable), shop.venue=true, never the openLate landmark. Registry gains
  a weight-0 'pub' type (interior:'band_room' for Lane C) → never auto-placed → base
  plan byte-identical.
- plan.gigs: 7 nightly {gigId,venueShopId,bandName,genreKey:'pubrock',night,startSeg:5,
  endSeg:5,cover}; cover ~half free / half $2-$10 (F charges at the door).
- Band names: bandName(seed) generator (90s pub-rock) + OPTIONAL web/assets/custom_bands.json
  drop-in (priority into the pool; absent → pure generator, no fetch under ?noassets).
- plan.posters: ~6 seeded {id,gigId,x,z,ry} for Lane B.
- Closing-time debt paid: isOpen(shopOrHours,hour) exported (half-open law as code, D consumes).
- map.html?gigs=1: venue ring + poster pins + gig panel line. Flags-off unchanged.

Self-check ALL GREEN 3074/3074 (gigSuite ×2 sources, flags-off≡base, gig golden
0xa6ae5a5e). qa.sh --strict GREEN 6/6. Verified in-browser both modes. CITY_SPEC v3
section + LANE_A_NOTES handshake for C/D/B/F. Atomic pathspec commit (shared-tree race).

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

228 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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 (startSeg1 = 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 520s 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 CDF 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 15150 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 (charityopshop, booksbook,
musicrecord, toystoy, video_gamesvideo, antiques/second_handopshop/pawn).
3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`. The unpinned town prints a ready-to-paste
line `⚠ osm/<key>: UNPINNED — add to OSM_GOLDENS → <key>: 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 BF 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 **415 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, fieldthreshold, 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.