PROCITY/docs/LANES/LANE_A_NOTES.md
m3ultra a78dc76f84 Lane A round 18: the real-graph lift — OSM ways → CityPlan street graph (v4.0-alpha REAL ROADS)
The epoch headline (ledger #2). A v2 cache's roads[] now builds the CityPlan street graph FROM real
OSM geometry instead of marching shops onto synthetic avenues. SAME CityPlan schema out — B/C/D/F consume
it unchanged by contract. roads absent ⇒ the v1 marched fallback, so classic/gig/osm goldens are FROZEN.

plan_osm.buildRealRoads: project ways+shops to the local metre frame → simplify each way (Douglas-Peucker
eps~6m, the fidelity knob) → snap coincident points (<=3m) to shared nodes = real intersections →
straight-segment edges → classify main by OSM highway class + shop density → seat each shop on its NEAREST
real edge (real order, real side), marched with regularised spacing + a deterministic overlap-resolve pass.

Fidelity (charter risk #4): real edge/order/side + regularised spacing — NOT pixel positions (real shops
overlap; the no-overlap invariant is load-bearing). Alpha failure list in LANE_A_NOTES §R18: overflow drop
on short edges; intersections = shared-coordinates only (a D sim-graph risk); per-edge frontage-strip
blocks not planar faces (a B ground-render item); crowded-intersection overlap-resolve.

Proven: a committed synthetic irregular graph (main + two cross streets + a real intersection + an acute
angle + a dead-end lane + a curve) passes the FULL structuralSuite + gig district + byte-identical
determinism; mutation-tested non-vacuous (flip the facade facing → 'faces its frontEdge' fails). Robust on
messy inputs (single road, coincident dups, disconnected ways, all-roads-too-short → marched). All osm
goldens frozen; E's 4 trailing caches pinned on v1; katoomba_real UNPINNED (its real-roads golden pins when
E lands roads — the A->E->A finalization). selfcheck ALL GREEN 16810/16810; scaffold + consistency GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:10:14 +10:00

37 KiB
Raw Blame History

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 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 (roads[]) + the real-graph lift

→ E: schema v2 is live (committed first) — fetch roads to it

A v2 cache adds roads: [ { kind, pts:[[lat,lon],…], id?, name? } ] (kind = OSM highway=*). Full contract in web/assets/towns/README.md; validateTownCache enforces it (schema …/1 still valid → marched). katoomba_real first (the alpha gate). Bound the highway=* query to the same per-town bbox as the shops. Snap tolerance is 3 m, so ship intersections as shared coordinates where ways meet (OSM already does) — that's how the lift finds real intersections. Drop each cache in; the selfcheck auto-builds

  • validates the real graph and prints the golden. Ping me → I pin katoomba_real's real-roads golden.

The graph lift (plan_osm.buildRealRoads) — implemented + proven on a synthetic irregular graph

Real OSM ways → the CityPlan street graph, same schema out (B/C/D/F unchanged by contract). Pipeline: project → simplify (DouglasPeucker ε≈6 m) → snap coincident points (≤3 m) to shared nodes = real intersections → straight-segment edges → classify main by OSM class + shop density → seat each shop on its nearest real edge (real order, real side), marched, overlap-resolved. Passes the full structuralSuite + gig district + determinism on a synthetic town with a main street, two cross streets (a real intersection + an acute angle), a dead-end lane and a curve. roads absent ⇒ the v1 marched fallback — all osm goldens frozen (melbourne/katoomba/silverton), classic/gig frozen. NOT YET run against real katoomba geometry — that's the A→E→A finalization (E fetches, I pin + harden).

Fidelity decision (charter risk #4 — A owns the knob) + the alpha failure list

I chose real edge + real order + real side, regularised spacing — NOT pixel-exact shop positions, because real shops overlap and the no-overlap invariant is load-bearing (venues, chunk streaming, sim all assume it). Written down for the alpha (fixed or fenced):

  1. Overflow drop. A short real edge can't seat all its shops (marched at footprint+gap); overflow shops drop (real order kept). norm.dropped counts them. Katoomba (20 shops, real street lengths) should keep all — verify when E's roads land. Fence if it bites: widen edge capacity or split long shop runs.
  2. Intersection topology = shared coordinates only. Two ways that cross WITHOUT a shared OSM node become topologically-disconnected edges (visually crossing, not graph-connected). → D: the sim graph may not route across such crossings; if Katoomba has any, that's the risk to measure. E ships shared coords at junctions (OSM convention) so most are fine.
  3. Blocks are per-edge frontage strips, not planar faces. → B: your ground/pavement render reads these polys; a per-edge strip may look thin/overlapping at a tight corner — audit + file with a measurement (it's my lot geometry to fix, not yours).
  4. Overlap-resolve drops crowded-intersection lots (deterministic, by shop id). Rare; counted in norm.dropped.

→ B/C/D: handshake state

katoomba_real still MARCHES today (it's v1 — E hasn't fetched roads yet), so I can't yet say "boots with real roads, golden pinned." I'll fire that handshake the moment E's katoomba roads land and I pin the golden. Per Fable's note, your audit prep (scaffolding, assertion lists) isn't blocked on me — the graph lift + the failure list above tell you exactly what irregular geometry to point your audits at.

Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data

→ E: the town-cache contract is live — build build_towns.py to it (half-day handshake)

Your pipeline outputs one JSON per real town in the processed shape; plan_osm marches it like the fixtures. Home: web/assets/towns/<key>.json (full spec in that dir's README.md). Shape:

{ schema:"procity-town-cache/1", key, town, source:"osm", license, attribution,
  center:{lat,lon}, shops:[ {id, name, type, lat, lon, suburb?} ] }   // + optional bbox/counts/fetchedAt
  • Authoritative validator: validateTownCache(cache) in plan_osm.js{ok, errors[], warnings[]}. Import it; don't hand-roll the check. Hard errors: finite center.{lat,lon}, shops6 (MIN_TOWN_SHOPS), finite shop lat/lon. Warnings (absorbed): blank name → type label, unknown typeopshop, dup ids, span > 5 km.
  • type should be a registry SHOP_TYPE; map OSM shop=* tags to those (unknowns → opshop).
  • Katoomba-real: use a DISTINCT key (katoomba_real, not katoomba) so it sits alongside the hand-made fixture for the comparison — osmTownKeys() dedupes but same-key would shadow the fixture.
  • Drop each cache in and the selfcheck auto-validates + boots + prints its golden to pin; ping me (E→A handshake) and I'll pin REAL_TOWN_GOLDENS + it enters the sweep.
  • ODbL: ship SOURCES.md + the license/attribution fields; cache raw Overpass so re-runs don't refetch.

plan_osm hardening — verified against real data (2,828 real Canberra shops)

I ran thriftgod's real city_source.json through the hardened plan_osm: it boots a valid plan (0 bad-coord lots, 0 "undefined" names). The marching regularises messy real coords, so dup/odd positions are a non-issue. Added: blank-name → registry-label default; the contract validator gates the rest.

→ Fable / v4 charter — the ugly-real-geometry RISK LIST (the scout's real finding)

  1. The mega-strip (the big one). An unbounded Overpass bbox = a whole region. All 2,828 Canberra shops marched into an 11.6 km single strip (350 shops/avenue) — a valid plan, an unusable "town". Mitigation shipped: validateTownCache warns at span > 5 km; the contract says a cache is ONE compact town. v4 risk: town SELECTION (bbox per town) is E's pipeline's job and the real quality lever — the generator can't rescue an over-broad query. Charter needs a per-town bbox/centre discipline.
  2. Sparse compact towns. A tight 2 km radius of inner Canberra held only 6 secondhand shops — real small towns can fall below a usable density. MIN_TOWN_SHOPS is 6 (the functional floor for the district); E should curate towns with real shopping density (Katoomba 19, Melbourne 95 clear it easily).
  3. Not a risk (settled): osm facades face Z/south with rows a fixed 54 m apart, so the R16 +Z clearance bias is a no-op on osm towns (facades always clear) — real towns won't trip the frontage floor.

Scope held: no new plan features, no golden moved

Classic 0x3fa36874, gig 0xb1d48ea1, all three osm fixtures frozen. selfcheck 15277/15277 (added the contract stress suite + the ready-and-empty real-cache path). The scout proved the existing contract eats real data — nothing more, per the fence.

Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7)

→ B + F: new gig golden 0x4f4a549d → 0xb1d48ea1 (classic/synthetic 0x3fa36874 unchanged)

The round's one sanctioned move. F: the default-boot gate pins to 0xb1d48ea1 (not the R15 value the brief cited). B: re-shoot against this. Amendment law; §v3 marker records it.

The corner-poster fix (NOT a nudge — Fable's brief premise was wrong)

The R15 finding was that a corner venue's frontage poster sits near a crossing kerb. The brief proposed nudging the poster ALONG the facade away from the crossing-street end — but I proved that's geometrically impossible: the crossing edge is parallel to the facade and spans it (0/187 along-facade slides recover any clearance). Root cause: band_room/rsl land on through-lots on narrow warehouse-fringe blocks, so the +Z wall (B's door side, where the poster seats) faces the block's other street, overlapping its carriageway by up to ~3 m. Fable's ruling (asked mid-round): bias pickVenues. So:

  • gigs.js pickVenues now prefers a band_room/rsl candidate whose +Z facade clears every carriageway by POSTER_CLEAR (0.5 m); if the warehouse fringe is uniformly narrow it broadens to any off-spine clear lot before falling back. Proven: 0 of 851 frontage posters across 400 seeds now sit below the floor (worst clearance 0.51 m). Pub is untouched (spine-fronted, +Z open — the hero shot doesn't move).
  • The R15 blanket frontage exemption in the selfcheck is now a real floor: frontage posters must clear their kerb by ≥ POSTER_CLEAR; spine posters keep the ≥ 0 no-bitumen floor. POSTER_CLEAR is exported from the barrel — import it, don't re-guess. (0.02 in the assert absorbs r2 poster-coord rounding.)

The no-spine osm fixture (ledger #7): silverton

osm_fixture.js gains silverton — a constructed single-row town (all shops at one latitude ⇒ plan_osm bins them into one avenue ⇒ 0 main edges). Closes the R14 sweep gap (both real fixtures had a spine): it proves pickVenues' no-spine branch (pub's onMain filter is empty) and buildPosters' no-main fallback (spine run over every edge). Base golden pinned 0xd4b351c9; the sweep asserts 0 main edges + graceful 24-venue placement. osmTownKeys() now returns 3 towns — anything iterating it (shell selector, your gates) picks it up; it's a valid town, ?plansrc=osm&town=silverton.

→ F: the classic-law / flags-table §v3 amendment is yours

I amended §v3 for the golden + poster bias only (localized, recorded in the FROZEN marker). The v3.1 flip (default-on, ?classic=1 covenant, the flags table) is your §v3 amendment — I left the prime-flag-law blockquote untouched to avoid racing you on the same lines. My classic golden (0x3fa36874) is your ?classic=1 regression target; my gig golden (0xb1d48ea1) is your default-boot target.

Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + the one sanctioned golden move

→ B + F: new gig golden 0x1f636349 → 0x4f4a549d (synthetic 0x3fa36874 unchanged)

The frontage-poster fix moved plan.posters, so the gig golden re-pinned — the round's ONE sanctioned move (amendment law; §v3 marker records it). B: re-shoot queue_night against this. F: the smoke's gig golden is now 0x4f4a549d.

The fix (gigs.js buildPosters item 1)

Each venue's own-frontage poster sat at local Z — the back of the building (~16 m behind, matching B's 15.9 m), invisible from the street, which is why queue_night framed no poster. Flipped to the +Z street facade AND turned it to read from the street: ry = lot.ry + π so its printed face points +Z at the queue-side camera (else DoubleSide shows a mirrored band name). Proven: pub + rsl frontage posters now sit 0.06 m from B's door, printed-face·(+Z street) = 1.000. Only item 1; spine posters untouched.

The Z/+Z convention, settled (worth recording — it's the R13 RY_FLIP π-ambiguity again)

The visible venue face is +Z: buildings.js builds the facade/door at zf = +d/2 ("front face at local +Z"), and venue.js seats the door-glow/queue/poster-camera on (sin ry, cos ry) = +Z. The selfcheck's facing = (sin ry,cos ry) "every lot faces its frontEdge" is the sim/GLB convention, 180° off from the visual front (same ambiguity D fixed at the source for the fleet in R13). A poster faces the viewer when its printed face = (sin ry,cos ry) points at them, i.e. ry = atan2(away-from-viewer).

→ B + F (eyeball in the tour): corner-venue frontage posters can sit near a crossing street

The +Z facade is the block-interior side in plan coordinates, so for a corner venue (a band_room/ rsl fronting one street with another behind — e.g. seed 1 band_room lot#481, 1.58 m from a side-street centreline) the frontage poster lands near that crossing street's kerb. That's B's building geometry (the door is there too), not a mid-road defect — so the selfcheck's no-bitumen assert now exempts frontage posters (they're building-bound) and keeps it for free-standing spine posters. The pub hero is clean (29.9 m of open ground on +Z). If a corner poster reads as floating over a road in the tour, that's a v3.1 venue-placement tweak, not a v3.0 blocker.

Round 14 (2026-07-16) — v3.0 RELEASE: invariant sweep + CITY_SPEC §v3 frozen

Release round — no new systems, no golden moves. Synthetic 0x3fa36874 and gig 0x1f636349 are unchanged; the only code I touched is selfcheck.js (test-only), so no seeded stream gained a draw.

The 400-seed district sweep (selfcheck.js, ALL GREEN 14264/14264 in ~0.7s)

  • Factored the district-contract checks out of gigSuite into districtInvariants(plan, label) so the sweep asserts them at SCALE without re-running the heavy structuralSuite (that still runs on the 6 hero SEEDS + osm parity). Behaviour-preserving — the 4 hero-seed gigSuite runs are byte-identical.
  • Sweeps seeds 1400 synthetic + the large edge seeds (2e9, 8675309) asserting the full district contract each: 24 venues, ≥1 pub, never the openLate shop, no band twice in one night town-wide, per-venue 46 nights, every poster off the carriageway. Also asserts the seeded count spans {2,3,4} across the range (measured 146/121/133) — proves the 24 range is genuinely exercised, not pinned.
  • osm graceful placement: katoomba (smallest fixture, 19 shops, no warehouse district) still lands 24 valid venues incl. a pub for every hero seed — proving pickVenues' off-spine fallback. Honest scope: both osm fixtures keep a main spine, so this proves the no-warehouse branch, not no-spine (a genuine no-spine town would need a single-row fixture that doesn't exist yet — v3.1).
  • Poster check kept as "≥ roadWidth/2 from the nearest edge centreline" (not a literal poleOffset compare, which would false-fail legit corner posters seated behind their placement edge's poleOffset but inside a crossing edge's clearance band). Added a 1e-6 epsilon on the strict < to immunise the exact kerb-line boundary against r2 rounding (only reachable on a no-main-spine fallback; not hit today).

CITY_SPEC §v3 frozen at v3.0

Final wording pass; marked FROZEN at v3.0 (round 14) with a §v2-style marker. Venues table, genre map, gigKey contract, corridor law, and gig golden 0x1f636349 are all final (genres are pubrock/grunge/ covers — Fable's R14 ruling, John did not re-flavour).

→ Lane F: the alias-retirement handshake (R13 debt #1) — I deliberately did NOT touch it

The R12 single-venue compat alias is still live (gig_state.js:141-151, read by audio.js/venue.js), so I left the §v3 alias sentences (CITY_SPEC.md ~lines 271272) intact — deleting them at freeze time would be doc-ahead-of-code and would race you on the same lines. The freeze marker explicitly carves this out as your pending edit. In your alias-retirement commit (after B drops its readers), delete both gig_state.js:141-151 and those two spec sentences together — one atomic step, one deleter, no race.

  • While you're in that runtime block: it enumerates stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf
    • markPaidOf + .venueShopIds, but omits nightOf(id) (gig_state.js:131). Add it when you edit.

→ Lane B: second edit site for the bare-string arm

When you remove the venue.update() bare-string alias arm, note venue.js:228 also self-calls update('quiet') at construction through that same arm — fix both or it falls through (harmless today: still yields 'quiet', but it's a live second site).

Round 13 (2026-07-15) — v3.0-beta THE DISTRICT (published FIRST; C/D/B/F hang off this)

Supersedes the R12 alpha interface below. Same flag (?gigs=1), same prime-flag law: flag OFF ⇒ plan byte-identical to v2 (synthetic golden still 0x3fa36874, osm unchanged). What changed: one venue → 24; one genre → three (by kind); one night's posters → town-wide. The gig entry shapes are unchanged — downstream code that read one venue now iterates many. Verified green: selfcheck.js 3273/3273, scaffold_check + consistency GREEN.

Gig golden re-pinned: 0xa6ae5a5e0x1f636349 (the district changed gig-plan output; re-pinned in the same commit as the change per CITY_SPEC amendment law). Turn-on is unchanged: generatePlanFor(seed, src, { town, gigs:true, customBands }).

The venues (NEW: 24 per town, three kinds) — plan.shops.filter(s => s.venue)

Each converted-in-place venue shop carries three new fields:

s.venue === true · s.venueKind ∈ {'pub','band_room','rsl'} · s.genreKey ∈ {'pubrock','grunge','covers'}
s.type === s.venueKind      // C keys its interior recipe off shop.type — the type IS the kind
kind genreKey placement hours reads as
pub pubrock spine end/corner [17,23] ≥2-storey corner hotel
band_room grunge warehouse fringe [18,23] single-storey tin shed
rsl covers off-spine [16,23] carpet-and-bistro club
  • Seeded count 24; a 4th venue is a second pub at the far spine end. A town always has ≥1 pub.
  • Never the openLate landmark (still exactly one openLate shop). Placement degrades gracefully so osm towns (no warehouse district, maybe no spine) still get their venues.
  • genreKey comes from the registry kind→genre map: genreForVenueKind(kind) / SHOP_TYPES[kind].genre.

THE gigKey CONTRACT (debt #1 — import it, don't build the string)

The audio-manifest bed key IS the gigKey and is always gig-<genreKey>. No mapping table anywhere:

import { gigKeyFor } from './citygen/index.js';   // re-exported from the barrel
gigKeyFor('pubrock') === 'gig-pubrock'   // C sets audio.gigKey = gigKeyFor(genreKey); E names the bed the same; B reads it; F resolves it

R12 lost a bed to a private rename (gig-pubrock vs pubrock-live) — audio fails soft, so the band just mimed. One key, zero mapping. E renames pubrock-live → gig-pubrock this round; F deletes the GIG_BED bridge.

plan.gigs — the week across the whole district (entry shape UNCHANGED)

plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ]
  • 7 nights × every venue, but not every venue plays every night: each venue plays a seeded 46 of the 7, dark the rest (reads true). gigId unique town-wide; night ∈ [0,7); genreKey ≡ its venue's.
  • Pick tonight per venue (each venue has its own state now): plan.gigs.filter(g => g.venueShopId === v.id && g.night === 0)[0]may be undefined (venue dark tonight; F/B skip it, no poster).
  • startSeg=5 endSeg=5. Same quiet → doors(seg4) → on(seg5) → done machine — F runs one per venue.
  • cover{0}[2,10], skewed by kind: pub 50% free/$210, RSL 45% free/$25, band_room 70% free/$26. F debits per venue — a night at two venues is two covers.
  • No band plays two venues on one night (guaranteed by A). Custom names spread night-major across the whole week town-wide (deduped, priority-seeded), the rest generated.

plan.posters — town-wide now (shape UNCHANGED; Lane B renders)

plan.posters = [ { id, gigId, x, z, ry }, … ]   // only TONIGHT's (night 0) playing venues advertised
  • One on each playing venue's own frontage (on the facade plane) + a seeded spine run (2 per main edge).
  • Different venues' posters share the same pole runs — that IS the district reading. p.gigId tells B/F which venue's gig each poster sells (plan.gigs.find(g=>g.gigId===p.gigId)).
  • Debt #5 fixed: posters no longer land mid-road. See the corridor law ↓.

The street-corridor law (debt #5) — NEW helpers, use for ANY street furniture

edge.width is the full corridor (carriageway + verge, centreline ± width/2), NOT the carriageway. node.x + 2 seats furniture on the bitumen. Registry now exports the split (re-exported from the barrel):

import { roadWidth, vergeBand, poleOffset } from './citygen/index.js';
roadWidth(edge)   // carriageway half-corridor (main 10m, side 68, lane=all, arcade 0)
vergeBand(edge)   // [inner,outer] — where footpath furniture belongs
poleOffset(edge)  // where a pole/poster/post seats: just behind the kerb

Selfcheck now asserts no poster stands within roadWidth/2 of any edge centreline.

→ Lane C: two recipes to add + the gigKey + a corrected field

  • getRecipe(shop.type) resolves off RECIPES[type]RECIPES.pub exists; RECIPES.band_room and RECIPES.rsl do NOT yet. Until you add them, those venues fall back to the op-shop interior. That's the handshake — you're next in order.
  • Set room.audio.gigKey = gigKeyFor(shop.genreKey) (import from the barrel) — never hand-build 'gig-…'.
  • Registry pub.interior was 'band_room' (harmless, unread — you key off shop.type via canonicalType); I corrected it to 'pub' so it isn't a name-collision with the new band_room venue type. No behaviour change.

→ Lane D: iterate the venues, one setGig each

const venues = plan.shops.filter(s => s.venue) — call setGig per venue (multiple concurrent). Tonight's gig per venue as above (skip dark ones). isOpen(shopOrHours, hour) unchanged (half-open law). Queue zone comes from B (venue.queueZone) — consume if present, else a straight line off the door.

→ Lane F: state machine goes per-venue

One latch per venue keyed by venueShopId, off the same procity:segment spine. gigKeyFor(genreKey) resolves all three genres (the debt-#1 assert now covers 3). Delete GIG_BED once E's rename lands. Cover charge is per venue (per-venue paid stamps). Keep the alpha window.PROCITY.gigs shape as a compat alias for one round (B reads it mid-round).

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)

// 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 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.jsALL 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 (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 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 names 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, field⟺threshold, is-video).

⚠ GOLDEN.hash changed: 0x098eec2b0x3fa36874. 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.