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>
228 lines
15 KiB
Markdown
228 lines
15 KiB
Markdown
# 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/<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 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.
|