From 2b13dd4ad73c662ed0933e88ca7dd5ed9cf7fa96 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 12:35:30 +1000 Subject: [PATCH] Lane A round 16: v3.1 corner-poster clearance bias (#6) + no-spine osm fixture (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger #6 fix + the round's one sanctioned gig-golden re-pin, and ledger #7. Classic/synthetic golden 0x3fa36874 frozen (classic boot byte-identical across all seeds); base plan untouched. - Corner poster: the R15 finding (frontage poster near a crossing kerb) is NOT fixable by the brief's 'nudge along the facade' — proved the crossing edge is PARALLEL and spans the facade (0/187 slides help); band_room/rsl land on through-lots on narrow warehouse blocks whose +Z wall (B's door side) overlaps the block's other street by up to ~3 m. Surfaced to Fable; ruling: bias pickVenues. It now prefers a band_room/rsl candidate whose +Z facade clears every carriageway by >= POSTER_CLEAR (0.5 m), broadening off-spine if the fringe is uniformly narrow. Result: 0/851 frontage posters below floor across 400 seeds (worst 0.51 m); pub untouched (hero shot unmoved). - Gig golden re-pinned 0x4f4a549d -> 0xb1d48ea1 (posters moved). selfcheck's R15 blanket frontage exemption becomes a real floor: frontage >= POSTER_CLEAR, spine >= 0 (POSTER_CLEAR exported from the barrel). Mutation-tested non-vacuous. - No-spine osm fixture 'silverton' (single-row town, 0 main edges) proves pickVenues' no-spine branch + buildPosters' no-main fallback, closing the R14 sweep gap. Base golden pinned 0xd4b351c9; sweep asserts 0 main edges + graceful 2-4-venue placement. selfcheck ALL GREEN 14769/14769; scaffold + consistency GREEN. - CITY_SPEC v3 amended (golden, poster clearance-bias prose, no-spine note, recorded amendment). Left the prime-flag-law blockquote for F (the v3.1 classic-law + flags-table is F's amendment). For F: default-boot golden = 0xb1d48ea1; ?classic=1 target = 0x3fa36874. Co-Authored-By: Claude Opus 4.8 --- docs/A-progress.md | 33 ++++++++++++++++++++-- docs/CITY_SPEC.md | 21 ++++++++++---- docs/LANES/LANE_A_NOTES.md | 35 +++++++++++++++++++++++ web/js/citygen/gigs.js | 42 +++++++++++++++++++++++++-- web/js/citygen/index.js | 2 +- web/js/citygen/osm_fixture.js | 24 ++++++++++++++++ web/js/citygen/selfcheck.js | 53 ++++++++++++++++++++--------------- 7 files changed, 177 insertions(+), 33 deletions(-) diff --git a/docs/A-progress.md b/docs/A-progress.md index 0cccb0a..2dae22c 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,10 +1,37 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check all-green (14264/14264); rounds 2–15 closed** (see below). -v3.0 release close-out: frontage-poster +Z flip (ledger #1) + the one sanctioned gig-golden move -(`0x1f636349 → 0x4f4a549d`); synthetic golden `0x3fa36874` frozen.* +*Status: **all deliverables landed; self-check all-green (14769/14769); rounds 2–16 closed** (see below). +v3.1 (THE FLIP): corner-poster fix via `pickVenues` +Z-clearance bias (ledger #6, gig golden +`0x4f4a549d → 0xb1d48ea1`) + a no-spine osm fixture (silverton, #7). Classic/synthetic `0x3fa36874` frozen.* *Date: 2026-07-16 · owner: PROCITY-A (Opus 4.8)* +## Round 16 (2026-07-16) — v3.1 THE FLIP: corner-poster clearance bias (#6) + no-spine osm fixture (#7) + +Lane A ran **first** among the parallel lanes (F bookends with the flip; A's golden feeds F's default-boot +gate). The R15 finding was that a corner venue's frontage poster can sit near a crossing kerb. + +Delivered (one commit): +1. **Corner-poster fix — via `pickVenues`, not a nudge.** I proved the brief's proposed fix (nudge along + the facade) is geometrically impossible: the crossing edge is **parallel** to the facade and spans it + (0/187 along-facade slides help). Root cause: band_room/rsl land on through-lots on narrow warehouse + blocks, so the `+Z` wall (B's door side) faces the block's other street, overlapping its carriageway by + up to ~3 m. **Surfaced to Fable; ruling: bias `pickVenues`.** It now prefers a band_room/rsl candidate + whose `+Z` facade clears every carriageway by ≥ `POSTER_CLEAR` (0.5 m), broadening off-spine when the + fringe is uniformly narrow. Result: **0/851** frontage posters below floor across 400 seeds (worst + 0.51 m); pub untouched (hero shot unmoved). Feasibility pre-checked: a clear lot always exists. +2. **Gig golden re-pinned `0x4f4a549d → 0xb1d48ea1`** — the sanctioned move (posters moved); synthetic/ + classic `0x3fa36874` frozen. §v3 FROZEN marker records the amendment. +3. **Selfcheck: exemption → floor.** R15's blanket frontage exemption is now a real invariant — frontage + posters must clear their kerb by ≥ `POSTER_CLEAR` (exported from the barrel), spine posters keep ≥ 0. +4. **No-spine osm fixture `silverton`** (ledger #7): a constructed single-row town (all shops one latitude + ⇒ 0 `main` edges). Closes the R14 gap (both real fixtures had a spine); proves `pickVenues`' no-spine + branch + `buildPosters`' no-main fallback. Base golden pinned `0xd4b351c9`; sweep asserts 0 main edges + + graceful 2–4-venue placement. Selfcheck **ALL GREEN 14769/14769**; scaffold + consistency GREEN. +5. **CITY_SPEC §v3 amended** (golden line, poster clearance-bias prose, no-spine note, recorded amendment). + Left the prime-flag-law blockquote for F (the v3.1 classic-law + flags-table is F's §v3 amendment). + +For F: default-boot golden = `0xb1d48ea1`; `?classic=1` regression target = `0x3fa36874`. + ## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + one sanctioned golden move Lane A ran **first** (B's hero re-shoot + F's tour hang off it). The R14 review found every venue's diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 0eba8e9..dd5066f 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -218,6 +218,13 @@ night in v1; interior-mapping shader is a stretch goal). > facade (it had been on the `−Z` back, invisible from the street); this moved `plan.posters`, so the gig > golden re-pinned `0x1f636349 → 0x4f4a549d` in the same commit under the amendment law above. The > synthetic golden `0x3fa36874` did not move — this is the round's one sanctioned gig-golden move.)* +> +> *(**Recorded amendment — ROUND16, ledger #6:** on a narrow through-lot a band_room/rsl's `+Z` facade +> can abut the block's *other* street, putting its frontage poster over that carriageway. `pickVenues` +> now **prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** (0.5 m; +> broadens off-spine if the warehouse fringe is uniformly narrow). This moved `plan.posters`, so the gig +> golden re-pinned `0x4f4a549d → 0xb1d48ea1`; synthetic `0x3fa36874` still frozen. The v3.1 default flip +> + classic law is F's amendment to this section — see the flags table.)* > **Prime flag law.** The gig layer is a **post-hoc augmentation** applied by the selector only when > `?gigs=1`. With the flag OFF, `generatePlanFor(seed, src)` returns the byte-identical v2 base plan — @@ -286,8 +293,10 @@ Only **tonight's** (night 0) playing venues are advertised. Each such venue gets frontage** — seated on the **+Z street facade** (B's canon: `buildings.js` facade + `venue.js` door/queue/ camera are all `+Z`; position `lot + (sin ry, cos ry)·(d/2+ε)`), turned to read *from* the street (`ry = lot.ry + π` so its printed face points `+Z` at the queue-side camera — **ROUND15 ledger #1**; before -R15 it was on the `−Z` back and invisible from the street). Plus a seeded run along the spine — 2 per main -edge, at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole +R15 it was on the `−Z` back and invisible from the street). So the poster never sits over a back-street's +bitumen, **`pickVenues` prefers a band_room/rsl lot whose facade clears every carriageway by ≥ `POSTER_CLEAR`** +(ROUND16 ledger #6 — exported from the barrel; the selfcheck asserts the same floor). Plus a seeded run along +the spine — 2 per main edge (or, when a town has no main spine, every edge), at `t ∈ [0.2,0.8]` of the block, on **either verge**. Different venues' posters share the same pole runs — that IS the district reading. `ry` follows the house convention (a poster's printed face = `(−sin ry, −cos ry)`, so `atan2` of the *away-from-viewer* normal); B places E's poster skin at `(x, z)` rotated `ry`; F wires "poster → that venue's tonight-gig". @@ -316,9 +325,11 @@ by seed, spread **night-major across the whole week's slots town-wide** (not one canonical half-open predicate (`open ≤ hour < close`) for D's gig-night patronage surge and everyone else. **Gig golden** (guards gig-layer drift; pure-generator path, no custom file), seed `20261990` synthetic: -**`0x4f4a549d`** (**ROUND15 re-pin** — the frontage-poster +Z flip, ledger #1, moved `plan.posters`; was -`0x1f636349` the R13 district, `0xa6ae5a5e` the R12 alpha). Asserted by `selfcheck.js` alongside the frozen -v2 goldens; changing gig output re-pins it. +**`0xb1d48ea1`** (**ROUND16 re-pin** — the band_room/rsl facade-clearance bias, ledger #6, moved +`plan.posters`; was `0x4f4a549d` the R15 +Z flip, `0x1f636349` the R13 district, `0xa6ae5a5e` the R12 +alpha). Asserted by `selfcheck.js` alongside the frozen v2/classic golden; changing gig output re-pins it. +The gig sweep now also runs a **no-spine osm fixture** (`silverton`, single-row) proving `pickVenues` +places venues with no main spine (ROUND16 ledger #7). ## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read) diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index f275364..0e35464 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,41 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## 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 2–4-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) diff --git a/web/js/citygen/gigs.js b/web/js/citygen/gigs.js index 77d842e..ca718f5 100644 --- a/web/js/citygen/gigs.js +++ b/web/js/citygen/gigs.js @@ -10,12 +10,26 @@ // → town-wide. See LANE_A_NOTES §Round 13 for the interface C/D/B/F hang off. import { rng, seedFor, mulberry32, pick, shuffle, irange } from '../core/prng.js'; -import { SHOP_TYPES, poleOffset } from '../core/registry.js'; +import { SHOP_TYPES, poleOffset, roadWidth } from '../core/registry.js'; import { shopName, bandName } from './names.js'; const r2 = v => Math.round(v * 100) / 100; const r4 = v => Math.round(v * 10000) / 10000; +// A venue's own frontage poster seats on the +Z street facade, `d/2 + FACADE_PROUD` out from the lot +// centre (shared by pickVenues' clearance test and buildPosters). ROUND16 (ledger #6): band_room/rsl +// selection prefers a lot whose facade clears every carriageway by ≥ POSTER_CLEAR, so the poster can +// never sit on a back-street's bitumen (a narrow through-lot otherwise abuts the block's other street). +// selfcheck asserts the same floor — import POSTER_CLEAR there, don't re-guess it. +export const POSTER_CLEAR = 0.5; +const FACADE_PROUD = 0.06; +// nearest point on segment AB to P (same helper the selfcheck uses; kept local so gigs.js stays pure). +const nearestSeg = (px, pz, ax, az, bx, bz) => { + const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1; + let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t)); + return [ax + dx * t, az + dz * t]; +}; + // A night gig: doors at DUSK (seg4), on during NIGHT (seg5) — see lighting.js segment→hour map. const NIGHTS = 7; const GIG_START_SEG = 5; // NIGHT (22:00) — gig is "on" @@ -55,11 +69,25 @@ function pickVenues(plan, citySeed) { const blockById = new Map(plan.blocks.map(b => [b.id, b])); const mainEdges = new Set(plan.streets.edges.filter(e => e.kind === 'main').map(e => e.id)); + const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n])); const lotOf = s => lotById.get(s.lot); const blockKindOf = s => { const l = lotOf(s), b = l && blockById.get(l.block); return b ? b.kind : null; }; const onMain = s => { const l = lotOf(s); return !!l && mainEdges.has(l.frontEdge); }; const dist2 = s => { const l = lotOf(s); return l ? l.x * l.x + l.z * l.z : 0; }; const farthestFirst = arr => arr.slice().sort((a, b) => dist2(b) - dist2(a) || a.id - b.id); + // clearance of a shop's +Z frontage-poster point to the nearest carriageway (ROUND16 ledger #6). + const facadeClear = s => { + const l = lotOf(s); if (!l) return Infinity; + const off = l.d / 2 + FACADE_PROUD; + const px = l.x + Math.sin(l.ry) * off, pz = l.z + Math.cos(l.ry) * off; + let best = Infinity; + for (const e of plan.streets.edges) { + const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; + const [nx, nz] = nearestSeg(px, pz, a.x, a.z, b.x, b.z); + best = Math.min(best, Math.hypot(px - nx, pz - nz) - roadWidth(e) / 2); + } + return best; + }; const base = plan.shops.filter(s => { if (s.openLate) return false; // must not displace the night landmark @@ -93,6 +121,16 @@ function pickVenues(plan, citySeed) { if (far.length) cand = far; } } + // ROUND16 ledger #6: keep the off-spine kinds off a back-street's bitumen. Prefer candidates whose + // +Z facade clears every carriageway by ≥ POSTER_CLEAR; if the warehouse fringe is uniformly narrow, + // broaden to ANY off-spine clear lot before falling back. (pub is spine-fronted — its +Z is open — + // so it keeps its placement untouched; the hero shot doesn't move.) Always a clear lot exists in the + // synthetic town; the fallback only ever fires on a degenerate/osm plan. + if (kind === 'band_room' || kind === 'rsl') { + let clear = cand.filter(s => facadeClear(s) >= POSTER_CLEAR); + if (!clear.length) clear = pool.filter(s => !onMain(s) && facadeClear(s) >= POSTER_CLEAR); + if (clear.length) cand = clear; + } if (!cand.length) cand = pool; // last resort: anything left const shortlist = farthestFirst(cand).slice(0, Math.min(5, cand.length)); const shop = pick(rng(citySeed, 'venuepick', i), shortlist); @@ -208,7 +246,7 @@ function buildPosters(plan, citySeed, venues, gigs) { // read FROM the street: venue.js renders a poster's printed face along (−sin ry, −cos ry) = A's // outward normal, so ry = lot.ry + π points that face outward (else DoubleSide shows a mirrored name). const fx = Math.sin(lot.ry), fz = Math.cos(lot.ry); // +Z: the street facade (B's canon) - const off = lot.d / 2 + 0.06; // out onto the facade plane, proud of it + const off = lot.d / 2 + FACADE_PROUD; // out onto the facade plane, proud of it add(lot.x + fx * off, lot.z + fz * off, lot.ry + Math.PI, gig.gigId); } diff --git a/web/js/citygen/index.js b/web/js/citygen/index.js index 444dee4..c61cd15 100644 --- a/web/js/citygen/index.js +++ b/web/js/citygen/index.js @@ -10,7 +10,7 @@ export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; export { shopName, townName, bandName } from './names.js'; export { generatePlanOSM, osmTownKeys } from './plan_osm.js'; -export { withGigs, gigKeyFor } from './gigs.js'; +export { withGigs, gigKeyFor, POSTER_CLEAR } from './gigs.js'; // The street-corridor law + venue vocabulary, re-exported so consumers get the whole Lane A contract // from one import (ROUND13). `roadWidth`/`vergeBand`/`poleOffset` are the road-vs-verge split of // `edge.width` — see the note in registry.js; `gigKeyFor` is the one and only genre→audio-key mapping. diff --git a/web/js/citygen/osm_fixture.js b/web/js/citygen/osm_fixture.js index 54aa4f8..cc63d4c 100644 --- a/web/js/citygen/osm_fixture.js +++ b/web/js/citygen/osm_fixture.js @@ -134,6 +134,30 @@ export const OSM_TOWNS = { {"id":11242974232,"name":"Sanctus Catholic Books and Gift Shop","type":"book","lat":-33.715718,"lon":150.31095,"suburb":""} ], }, + // ROUND16 (ledger #7): a CONSTRUCTED single-row town — every shop shares one latitude, so plan_osm + // bins them into ONE avenue and builds NO main spine (spineNodes.length === 1 ⇒ 0 'main' edges). Unlike + // Melbourne/Katoomba (8 lat rows ⇒ a multi-node spine), this proves the no-spine placement branch of + // gigs.js pickVenues + the no-main-spine poster fallback in buildPosters. Not a scraped cluster; the + // names are flavour (Silverton is a real one-street NSW town). ids are synthetic (9-prefixed) seeds. + silverton: { + town: "Silverton", + source: "constructed single-row fixture (no-spine test, ROUND16 ledger #7)", + center: {"lat":-31.8600,"lon":141.2255}, + shops: [ + {"id":9000001,"name":"Silverton Op Shop","type":"opshop","lat":-31.8600,"lon":141.2200,"suburb":"Silverton"}, + {"id":9000002,"name":"Old School Book Exchange","type":"book","lat":-31.8600,"lon":141.2210,"suburb":"Silverton"}, + {"id":9000003,"name":"Outback Vinyl","type":"record","lat":-31.8600,"lon":141.2220,"suburb":"Silverton"}, + {"id":9000004,"name":"Silverton Video","type":"video","lat":-31.8600,"lon":141.2230,"suburb":"Silverton"}, + {"id":9000005,"name":"Desert Trading Post","type":"pawn","lat":-31.8600,"lon":141.2240,"suburb":"Silverton"}, + {"id":9000006,"name":"Mundi Collectables","type":"toy","lat":-31.8600,"lon":141.2250,"suburb":"Silverton"}, + {"id":9000007,"name":"CWA Op Shop","type":"opshop","lat":-31.8600,"lon":141.2260,"suburb":"Silverton"}, + {"id":9000008,"name":"Ghost Town Books","type":"book","lat":-31.8600,"lon":141.2270,"suburb":"Silverton"}, + {"id":9000009,"name":"Silverton Corner Store","type":"milkbar","lat":-31.8600,"lon":141.2280,"suburb":"Silverton"}, + {"id":9000010,"name":"The Old Wares","type":"pawn","lat":-31.8600,"lon":141.2290,"suburb":"Silverton"}, + {"id":9000011,"name":"Dusty Grooves","type":"record","lat":-31.8600,"lon":141.2300,"suburb":"Silverton"}, + {"id":9000012,"name":"Salvos Silverton","type":"opshop","lat":-31.8600,"lon":141.2310,"suburb":"Silverton"} + ], + }, }; export const DEFAULT_TOWN = "melbourne"; diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index 485d3a7..fbddf9e 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; import { generatePlanOSM, osmTownKeys } from './plan_osm.js'; -import { generatePlanFor, gigKeyFor } from './index.js'; +import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js'; import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js'; import { xmur3 } from '../core/prng.js'; @@ -190,25 +190,24 @@ function districtInvariants(plan, label) { } ok(clash === null, `${label}: no band plays two venues on one night` + (clash !== null ? ` (night ${clash})` : '')); - // ── posters: town-wide, on the verge, never on the bitumen (debt #5) ── + // ── posters: town-wide, never on the bitumen — with a real FRONTAGE floor (ROUND16 ledger #6) ── const gigIds = new Set(plan.gigs.map(g => g.gigId)); ok(Array.isArray(plan.posters) && plan.posters.every(p => gigIds.has(p.gigId) && isFiniteNum(p.x) && isFiniteNum(p.z) && isFiniteNum(p.ry)), `${label}: posters reference a real gig with finite coords`); ok(plan.posters.every(p => plan.gigs.find(g => g.gigId === p.gigId).night === 0), `${label}: posters advertise tonight's gigs`); - // The no-bitumen rule (the R12 regression: spine posters at node.x+2) is for FREE-STANDING verge - // posters. A venue's own FRONTAGE poster is bound to the building B draws — since R15 it sits on the - // +Z street facade (B's canon: buildings.js facade + venue.js door/queue/camera are all +Z). A corner - // venue whose facade abuts a crossing street legitimately seats its poster near that street's kerb — - // B's building geometry, not a mid-road defect — so frontage posters are exempt here and validated by - // the placement asserts above (they reference tonight's gigs with finite coords). + // Two poster classes, two floors. A FRONTAGE poster sits on its venue's +Z street facade (B's canon: + // buildings.js facade + venue.js door/queue/camera are +Z); ROUND16 pickVenues now PREFERS a band_room/ + // rsl lot whose facade clears every carriageway, so — no more R15 blanket exemption — a frontage poster + // must clear its nearest kerb by ≥ POSTER_CLEAR. A SPINE poster is free-standing on the verge and keeps + // the plain no-bitumen floor (≥ 0, the R12 regression guard). −0.02 absorbs r2 poster-coord rounding vs + // pickVenues' un-rounded clearance test (worst observed frontage clearance is ~0.51 m). const lotByIdD = new Map(plan.lots.map(l => [l.id, l])); const onFacade = p => venues.some(v => { const lot = lotByIdD.get(v.lot); if (!lot) return false; - const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1 + const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1 (FACADE_PROUD) return Math.hypot(p.x - (lot.x + Math.sin(lot.ry) * off), p.z - (lot.z + Math.cos(lot.ry) * off)) < 0.5; }); let onRoad = null; for (const p of plan.posters) { - if (onFacade(p)) continue; // frontage poster: on the venue building, not the verge let best = Infinity, bestEdge = null; for (const e of plan.streets.edges) { const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b); @@ -217,11 +216,13 @@ function districtInvariants(plan, label) { const d = Math.hypot(p.x - nx, p.z - nz); if (d < best) { best = d; bestEdge = e; } } - // −1e-6 immunises the exact kerb-line boundary against r2 coord rounding (only reachable on a - // no-main-spine fallback where poleOffset == roadWidth/2; never hit by the current sweep). - if (bestEdge && best < roadWidth(bestEdge) / 2 - 1e-6) { onRoad = [p.id, bestEdge.id, +best.toFixed(2)]; break; } + if (!bestEdge) continue; + const frontage = onFacade(p); + const clearance = best - roadWidth(bestEdge) / 2; + const floor = frontage ? POSTER_CLEAR - 0.02 : -1e-6; + if (clearance < floor) { onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; break; } } - ok(onRoad === null, `${label}: no poster stands on a carriageway` + (onRoad ? ` (poster ${onRoad[0]} is ${onRoad[2]}m from edge ${onRoad[1]}'s centreline)` : '')); + ok(onRoad === null, `${label}: every poster clears its kerb (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]} by only ${onRoad[2]} m)` : '')); } const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id); @@ -271,7 +272,7 @@ for (const s of SEEDS) { // (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are // skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874. section('OSM plan source parity (fixture-driven, zero-network)'); -const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510 }; +const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 }; const OSM_SEEDS = [20261990, 1, 42, 777]; for (const town of osmTownKeys()) { const report = {}; @@ -307,7 +308,7 @@ ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.str ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`); } // gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file) -const GIG_GOLDEN = 0x4f4a549d; // ROUND15 re-pin: frontage posters flipped to the +Z street face (ledger #1 bug fix). Was 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha). +const GIG_GOLDEN = 0xb1d48ea1; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha). { const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0; if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`); @@ -333,14 +334,22 @@ for (const s of SEEDS) if (s > SWEEP_N) districtInvariants(generatePlanFor(s, 's ok([2, 3, 4].every(n => sweepVenueCounts.has(n)), `sweep: venue count spans {2,3,4} across ${SWEEP_N} seeds (saw {${[...sweepVenueCounts].sort((a, b) => a - b).join(',')}})`); -// osm graceful placement: katoomba is the smallest fixture (19 shops) with NO warehouse district, so -// pickVenues' band_room/rsl rules hit their off-spine fallback — yet still land 2–4 valid venues incl. -// a pub. (Both osm fixtures keep a main spine, so this proves the no-warehouse branch, not no-spine.) +// osm graceful placement — two branches of pickVenues' degradation, each proven by a fixture that +// genuinely lacks the structure the synthetic town has: +// • katoomba (19 shops): NO warehouse district ⇒ band_room/rsl hit their off-spine fallback. +// • silverton (single-row): NO main spine at all ⇒ pub's onMain filter is empty and buildPosters' +// spine run falls back to every edge (ROUND16 ledger #7 — closes the R14 gap where both osm +// fixtures had a spine). Both still land 2–4 valid venues incl. a pub. for (const seed of GIG_SEEDS) { - const plan = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' }); - ok(plan.blocks.every(b => b.kind !== 'warehouse'), + const kt = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' }); + ok(kt.blocks.every(b => b.kind !== 'warehouse'), `sweep osm/katoomba ${seed}: fixture has no warehouse district (graceful-placement precondition)`); - districtInvariants(plan, `sweep osm/katoomba ${seed}`); + districtInvariants(kt, `sweep osm/katoomba ${seed}`); + + const sv = generatePlanFor(seed, 'osm', { gigs: true, town: 'silverton' }); + ok(!sv.streets.edges.some(e => e.kind === 'main'), + `sweep osm/silverton ${seed}: fixture has no main spine (no-spine placement precondition)`); + districtInvariants(sv, `sweep osm/silverton ${seed}`); } // ── 4. every facade skin referenced by the registry exists on disk ──────────────────