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>
12 KiB
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"):
- 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. ✓ yoursim.js _openAt+_nearestOpenShopalready do exactly this. - 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. ✓ yourpatronTimer/_emergealready do this — do NOT add a hard close-time eviction. - Player is never force-ejected — closing locks entry only; a player inside stays until they leave.
- 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.htmlis 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 0x3fa36874osm / melbourne 0x34cfdec0osm / katoomba 0x0f652510The 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.openLaterecords it (both Melbourne + Katoomba have video, so both readvideo).
RECIPE — add another OSM town (mechanical, ~10 min)
- 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 inA-progress.md(round 6/8) — reuse it with your(cLat,cLon,cell). - Append the town to
OSM_TOWNSinweb/js/citygen/osm_fixture.js:key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }— sort shops byid(determinism). Map OSMshop=kinds → registry types (charity→opshop, books→book, music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn). - 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 intoOSM_GOLDENSinselfcheck.js, re-run → green. (The full parity suite already ran on the town; only the golden needed pinning.) - 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, thenconst src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';and callcitygen.generatePlanFor(seed, src)where it currently callsgeneratePlan(seed).map.htmlalready 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.jsalready asserts both; the smoke can just hash-compare.
- synthetic
- 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.sizeis 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/frontEdgesemantics,chunkIndex, hours + the exactly-one-openLate-video rule all hold. - Shop
names are the REAL OSM names (e.g. "Central Catholic Bookshop");signis 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.mjsacross 7 seeds → 0 storeys warnings;qa.sh --strictGREEN (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-chosenopenLatelandmark 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 theopenLateshop, 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
maxare tall-capable types (record/opshop/toy/book/pawn[1,2],dept[2,3]) reaching 3 via the corner anchor. Round-1'scornerBoostgate 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.