Ship round. Docs + one selfcheck log-line only; no generator change ⇒ all three goldens unchanged (synthetic 0x3fa36874, osm/melbourne 0x34cfdec0, osm/katoomba 0x0f652510). 1. CITY_SPEC v2 contract FROZEN — new "CityPlan v2 — frozen producer contract" section: producer API, goldens table (keyed seed/plansrc/town) + how to pin a new one, town-key mechanism, osm normalization rules, hours/openLate/storeys laws. Matches code as shipped (F diffs claims vs code). Refreshed the stale round-6 Plan-sources blockquote to multi-town reality. Layer-1 changes now require a CITY_SPEC amendment + golden re-pin in the same commit. 2. Recipe sanity — DRY-RAN end-to-end: appended a throwaway Newtown (42 shops), selfcheck auto-ran the full parity suite (1931/1932, only golden unpinned) and printed a paste-ready `newtown: 0x9045e577,`. Reverted; no 3rd town shipped. Added the selfcheck `⚠ UNPINNED — add to OSM_GOLDENS` line so the recipe is mechanical; fixed the recipe wording to match. 3. Closing-time occupant ruling (D's on-call Q) — ratifies + freezes what D's sim.js already does: half-open hours, no new entry when closed, occupants DRAIN (finish bounded dwell) never popped, player not force-ejected, openLate video is the night crowd. In CITY_SPEC + LANE_A_NOTES. No code change asked of D. qa.sh --strict GREEN; selfcheck 1727/1727. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
12 KiB
Markdown
174 lines
12 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 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.
|