From dab5ecd273989ea123907e6d547b7d121d9fd54d Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 7 Aug 2026 12:27:51 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20R40=20=C2=A740.2:=20D1-D4=20settled?= =?UTF-8?q?=20=E2=80=94=20the=20poster=20that=20stood=20in=20the=20road,?= =?UTF-8?q?=20and=20no=20silent=20carry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D4 CLOSED with a fix: 8/108 gig plans placed a spine poster inside a NON-nearest edge's carriageway (worst 1.78m inside a main, northbridge seed 42, behind a kerbless arcade at 1.45m) — the exact filing, reproduced before touching anything. gigs.js onCarriageway now tests inside-ANY-edge; selfcheck's poster arm asserts minimum clearance over ALL edges. After: 0/108. Poster counts 0 diffs across all 108 plans; synthetic byte-identical. The 7 REAL_TOWN_GIG_GOLDENS re-pinned (D4 explicitly required it; old hashes in comments). D1 A-side CLOSED: chunkIndexByCoverage is canonical (chunkIndex kept as verified alias); the naive swap would multi-build 258/681 lots — measured, documented in CITY_SPEC. D2 RE-FILED to F (isOpen still 0 importers; divergent predicate at index.html:541). D3 RE-FILED to B (SHOP_REGISTRY missing pub/band_room/rsl; 81 venue lots wear op-shop signage; bite drifted to buildings.js:465). Both re-filings carry numbers and a reason. selfcheck ALL GREEN 157,647/157,647; fingerprint 0x5f76e76 and GIG_GOLDEN frozen. Co-Authored-By: Claude Fable 5 --- docs/A-progress.md | 28 +++++++++++- docs/CITY_SPEC.md | 30 ++++++++++--- docs/LANES/LANE_A_NOTES.md | 88 +++++++++++++++++++++++++++++++++++++ web/js/citygen/gigs.js | 15 ++++--- web/js/citygen/index.js | 6 ++- web/js/citygen/plan.js | 13 +++++- web/js/citygen/selfcheck.js | 40 ++++++++++------- 7 files changed, 188 insertions(+), 32 deletions(-) diff --git a/docs/A-progress.md b/docs/A-progress.md index 6c6a8d2..6f01eaf 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,6 +1,10 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check ALL GREEN (157,647/157,647); rounds 2–30 + 37 + 39 closed**. +*Status: **all deliverables landed; self-check ALL GREEN (157,647/157,647); rounds 2–30 + 37 + 39–40 closed**. +R40 (§40.2): the v8 debt ledger settled — D4 CLOSED (all-edges poster bound; 8/108 plans → 0; poster +counts frozen on all 108; synthetic byte-identical; 7 town gig goldens re-pinned), D1 A-side CLOSED +(`chunkIndexByCoverage` canonical + compat alias), D1(B)/D2/D3 formally re-filed with re-measured +numbers and owners in `LANE_A_NOTES §40`. R39 (v9 item 39.1): THE ADDRESS LAYER — 17,835 named ways stop being dropped on the floor. New pure module `address.js`; **1,192/1,219 corpus shops (97.8%) resolve to a real street name**, 0 wrong names on 884 shop-bearing edges against an independent resolver, **all goldens frozen, zero re-pins.** @@ -9,6 +13,28 @@ law and stayed green while all 14 posters on the default boot stood on B's bitum arms with a permanent control; proven to fail on the pre-ruling geometry where the old gate cannot. All goldens frozen, zero re-pins.* +## Round 40 (§40.2) — (2026-08-04) THE DEBT ROUND: D1–D4 closed or re-filed, every verdict with a number + +Per the R40 brief: no third option, no silent carry. Every number below was produced by a command run on +today's tree (the §37 filing's numbers were re-derived, and two of them changed — which is the point). +Full prose + owner handoffs in `LANE_A_NOTES §40`. + +| item | close/re-file | number | evidence command | +|---|---|---|---| +| **D4** poster gate inner bound nearest-edge; `arcade` camouflages a main | **CLOSED** — `gigs.js onCarriageway` + selfcheck arm 1 both now ALL-EDGES | before: **8/108** gig plans w/ spine poster inside a non-nearest carriageway, worst **1.78 m** inside a main (northbridge_real 42, nearest edge `arcade` @ 1.45 m, kerb 0); after: **0/108**; frontage all-edges clean **238/238**; poster counts **0 diffs on 108 plans** (old vs new placer pairwise); synthetic **byte-identical 0/4** | scratch `d4_measure.mjs` / `d4_frontage.mjs` / `d4_counts.mjs` (corpus sweep over 4 syn seeds + 3 fixtures + 23 towns × 4 seeds), then `node web/js/citygen/selfcheck.js` | +| **D1(A)** `chunkIndex` two shapes, false swap-comment invites a streamer break | **CLOSED (A-side)** — `chunkIndexByCoverage` canonical in `plan.js`/barrel/selfcheck; `chunkIndex` kept as compat alias (map.html imports it); CITY_SPEC §chunk-key + producer API amended | a naive swap would multi-build **258/681 lots** (A coverage: 258 lots in ≥2 chunks; B centre: 0/681); container `Object` vs `Map`, elements ids vs objects — all three axes live today; alias verified `chunkIndex === chunkIndexByCoverage` | `node -e` three-axis diff over seed 20261990 (in-session, printed) | +| **D1(B)** `planutil.js:4` false comment + B-side name | **RE-FILED → B** | same 258/681; comment still present at `world/planutil.js:4` (grep) — can wait because both consumers are correct today and the trap only fires on the invited future swap, now contradicted by the shipped canonical name | `grep -n chunkIndex web/js/world/planutil.js web/js/world/chunks.js web/map.html` | +| **D2** `isOpen` ×3 impls + ad-hoc 4th; shell inverts A's no-hours law | **RE-FILED → F** | A's canonical still **0 importers**; divergent predicate live at `index.html:541` + `audio.js:516` + **19 call sites / 5 tools files**; `sim.js:363` third impl; `dbg.js:241,279,286` fourth site; still **latent: 0/1,838 corpus shops lack hours** (27 plans @ golden seed) — can wait until the first hours-less lot kind ships, fix is ~5 lines in F's `index.html` | grep census + node corpus sweep (27 gig plans), printed in-session | +| **D3** two shop registries; venues wear op-shop signage | **RE-FILED → B** | `SHOP_REGISTRY` still **9 types**, missing `pub`,`band_room`,`rsl` (A's `SHOP_TYPES`: **12**, all with `sign:{bg,fg,accent,style}`); bite line now `buildings.js:465`; **81 venue lots** corpus-wide @ golden seed render op-shop palette — can wait because cosmetic-only (no gate, no plan field), and the fix may move a **screenshot** golden (F re-pins), never a plan golden | node corpus sweep (venue count) + `grep -n SHOP_REGISTRY web/js/world/buildings.js web/js/world/fixture_plan.js` | + +**GOLDENS:** synthetic fingerprint **`0x5f76e76` FROZEN** · `GIG_GOLDEN 0xec7a2d39` FROZEN · all base +town goldens + 3 osm fixtures FROZEN · **7 `REAL_TOWN_GIG_GOLDENS` re-pinned under D4's explicit +requirement** (adelaide/bendigo/fitzroy/fremantle/geelong/marrickville/northbridge — old hashes kept in +the pin table comments; the §37 filing predicted 11-of-26 on the R37 tree, today it is 7-of-24). +Selfcheck **ALL GREEN 157,647/157,647** after all edits. Files touched: `web/js/citygen/gigs.js`, +`web/js/citygen/plan.js`, `web/js/citygen/index.js`, `web/js/citygen/selfcheck.js`, +`docs/CITY_SPEC.md`, `docs/LANES/LANE_A_NOTES.md`, this file. Nothing outside Lane A's set. + ## Round 39 (2026-08-03) — v9 item 39.1: THE ADDRESS LAYER (the approved thing; B and C consume it) **The full contract, per-town table and reasoning live in `LANE_A_NOTES.md §39`** — published in the diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 4e75064..633b803 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -133,9 +133,15 @@ Schema v1 (Lane A owns it; extend, don't break): > **Full frozen v2 producer contract below** (§"CityPlan v2 — frozen producer contract"). Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a -`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across -the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture, -footpath NPCs) resolves to a chunk whose `edges` list includes its edge. +`chunkIndexByCoverage(plan)` helper (R40 canonical name; `chunkIndex` remains as a compat alias): +chunk key → { lots, shops, edges } **touching** that cell — the COVERAGE question. A lot lands in every +chunk its rotated-footprint AABB covers; buckets are plain objects keyed `"cx,cz"`; elements are **ids**. +Edges are bucketed across the full road **corridor** (centreline ± `width/2`), so anything placed in the +verge (street furniture, footpath NPCs) resolves to a chunk whose `edges` list includes its edge. +**This is NOT the runtime streamer's index**: Lane B's `world/planutil.js chunkIndex(plan)` answers the +CENTRE question (each lot in exactly the chunk holding its centre; `Map` container; object elements) and +the two are not interchangeable on any of the three axes — measured R40: a swap would multi-build +258/681 lots on the default synthetic town. ## CityPlan v2 — frozen producer contract @@ -150,7 +156,8 @@ footpath NPCs) resolves to a chunk whose `edges` list includes its edge. - `generatePlan(seed) → CityPlan` — synthetic producer (unchanged, byte-identical). - `generatePlanOSM(seed, town = 'melbourne', { report } = {}) → CityPlan` — osm producer; pass a `report` object to receive the normalization log (below). `osmTownKeys() → string[]`. -- `chunkIndex(plan, chunkSize = 64) → { chunkSize, chunks }`; `CHUNK = 64`; `chunkKey(cx, cz)`. +- `chunkIndexByCoverage(plan, chunkSize = 64) → { chunkSize, chunks }` (coverage form; `chunkIndex` + is a compat alias of it — see the chunk-key note above); `CHUNK = 64`; `chunkKey(cx, cz)`. - Geometry helpers (shared with the harness): `lotCorners(lot)`, `obbOverlap(a, b)`. **Determinism & goldens.** Same inputs ⇒ byte-identical plan, forever. The determinism gate keys on @@ -378,7 +385,8 @@ bitumen, **`pickVenues` prefers a lot whose facade clears every carriageway by (ROUND16 ledger #6, **widened from band_room/rsl to every venue kind in ROUND20** — 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**; -a candidate that would overhang a **real** crossing carriageway is skipped (ROUND19, E's finding), and the +a candidate that would overhang a **real** crossing carriageway is skipped (ROUND19, E's finding; the +test is against **all edges**, not the nearest — R40 §40.2 D4, see the amendment under ROUND37 below), and the run is **capped at `POSTER_PER_SHOP × shops`** (ROUND20) — a sparse real town has few shops but a big edge graph, so the raw "2 per edge" run reads busy (katoomba 438 → 31). The cap never binds on the shop-dense synthetic town, so its gig golden is frozen. Different venues' posters share the same pole @@ -406,8 +414,16 @@ it as footpath rather than as road. The selfcheck's poster-clearance check used "is the poster outside `roadWidth/2`?" — which A's own placer already guaranteed, because `poleOffset` is *derived from* `roadWidth`. Gate and subject were the same function agreeing with itself, so it stayed green for 24 rounds while all **14** gig posters on the default boot stood on painted bitumen. It is now -**three arms**: (1) no poster on a carriageway, measured through `vergeBand(e)[0]` — the published law -itself, the same call B's ground consumes, so gate and renderer cannot drift apart; (2) every **spine** +**three arms**: (1) no poster on **any** carriageway, measured through `vergeBand(e)[0]` — the published +law itself, the same call B's ground consumes, so gate and renderer cannot drift apart. **R40 amendment +(§40.2 D4): the clearance is the MINIMUM over ALL edges, not the nearest edge's.** The nearest edge is +not always the binding one — a kerbless `arcade` (roadWidth 0) wins the nearest-edge race and always +"clears", camouflaging a main whose carriageway the poster stands inside. Measured on the R40 tree: +8/108 gig plans carried a spine poster up to **1.78 m inside a non-nearest main** (northbridge_real +seed 42, nearest edge an arcade at 1.45 m) that the nearest-edge form could not see by construction. +The placer's `onCarriageway` skip is now all-edges too; poster **counts** are unchanged on all 108 +plans (the cap re-subsamples a smaller pool to the same size), the synthetic town is byte-identical +(0/4 plans moved — goldens frozen), and 7 real-town **gig** goldens re-pinned; (2) every **spine** poster stands *inside* some edge's `vergeBand` (the bound the old check never had — "not on the road" also passed for a poster in a paddock); (3) **the control** — count the posters that would stand on bitumen under the pre-ruling ground (`?verge=0`, road quad = full `e.width`) and assert that count is diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 3c2870c..7f3cfcc 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,94 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 40 (2026-08-04) — §40.2: THE v8 DEBT LEDGER, SETTLED — D1–D4 each closed or re-filed with a number + +The four entries filed in §37 below, three rounds carried. Every number here was re-measured on the +R40 tree (commands in `A-progress.md §Round 40`), not quoted from the filing. Verdicts: **D4 CLOSED** +(A-owned, fix landed) · **D1 A-side CLOSED, B-side re-filed** · **D2 re-filed (owner F)** · **D3 +re-filed (owner B)** — the re-filed three all live in files A may not edit this round; measurements and +recommendations are below so the owning lane can land each without re-deriving anything. + +### D4 — CLOSED: the poster gate's inner bound is now ALL-EDGES, and the arcade can no longer camouflage a main + +Re-measured on today's tree before touching anything: **8/108 gig plans** carried a spine poster inside +a *non-nearest* edge's carriageway, invisible to the nearest-edge rule **by construction** — worst +**1.78 m inside a main on northbridge_real seed 42**, where the nearest edge is an `arcade` at 1.45 m +whose `roadWidth` is 0 (kerb 0 ⇒ it always wins the nearest race and always passes). Bendigo, fitzroy +×2, geelong ×3 seeds, glebe also affected; depths 0.08–1.51 m; every violating far edge was a `main`. + +**Landed, both halves:** +- `gigs.js onCarriageway(x,z)` — spine-candidate skip is now inside-ANY-edge, not inside-nearest-edge. +- `selfcheck.js` poster arm 1 — clearance is now the **minimum over all edges** of + `d(e) − vergeBand(e)[0]`, frontage floor unchanged (`POSTER_CLEAR − 0.02`), spine floor unchanged (0). + Frontage posters pass the all-edges floor **238/238 measured** — no exemption needed, because + `pickVenues.facadeClear` was *already* all-edges; the gate now asks the placer's own question. + +**The numbers after the fix:** violations **8/108 → 0/108** · poster **counts unchanged on all 108 +plans** (old placer vs new, compared pairwise — the R20 cap re-subsamples a smaller candidate pool to +the same size) · **synthetic byte-identical, 0/4 plans moved** ⇒ fingerprint `0x5f76e76`, `GIG_GOLDEN +0xec7a2d39`, all base town goldens, all 3 osm fixtures **frozen** · **7 real-town GIG goldens re-pinned** +(adelaide `0x1a00e20f` · bendigo `0x9f82e059` · fitzroy `0x60b8bba0` · fremantle `0x8d7bcb63` · geelong +`0x1f866ae7` · marrickville `0xac4c34d0` · northbridge `0x4ea89275` — old hashes in the table's comments; +the §37 filing predicted 11 of 26 on the R37 tree, today's tree re-pins 7 of 24: two towns retired and +plan drift since, which is exactly why the brief said measure, don't quote). Selfcheck **ALL GREEN +157,647/157,647**. CITY_SPEC amended in the same change (poster-law §ROUND37 block + spine-run note). + +### D1 — A-side CLOSED (the rename supplied) · B-side RE-FILED, owner B, number attached + +**A's side, landed:** `chunkIndexByCoverage` is the canonical export (`plan.js`, barrel, selfcheck call +sites all renamed; header states which question it answers). `chunkIndex` **remains exported as a compat +alias** — `map.html:89,320` imports it and map.html is not A's file to edit this round. Alias verified +live: `chunkIndex === chunkIndexByCoverage` and map.html's import path resolves. CITY_SPEC §chunk-key + +producer API updated to the two-question wording. + +**The number that keeps this honest:** the divergence is not stylistic — on the default synthetic town +(seed 20261990, 681 lots) a naive swap of B's streamer onto A's form would build **258/681 lots in ≥2 +chunks** (A coverage-buckets; B centre-buckets, 0/681 multi-chunk). Container (`Object`-in-wrapper vs +`Map`) and element type (ids vs objects) divergences re-confirmed live on today's tree too. + +**RE-FILED for B (cannot close from A):** `world/planutil.js:4` still says *"If Lane A later ships an +official chunkIndex(plan) we swap the import; the shapes match"* — **the shapes do not match on any of +three axes, and the swap would multi-build 258/681 lots.** B should delete the false comment, and may +rename its own to `chunkIndexByCentre` if it wants the symmetry (A's alias means no rush). Why it can +wait: both consumers are correct *today* — the trap only fires on the invited future swap, and the +comment is now contradicted by A's shipped canonical name + CITY_SPEC. Half a line of work. + +### D2 — RE-FILED, owner F, still latent — the number is 0/1838 and the day it stops being 0 is the deadline + +Re-measured today: A's canonical `isOpen` (`plan.js:28`, no-hours ⇒ open) still has **zero importers** +(grep over `web/` + `tools/`: only the barrel re-export). The live predicate is still +`index.html:541` — `shop && shop.hours && …` ⇒ **falsy for a hours-less shop, the exact inversion of +A's law** — published as `window.PROCITY.isOpen` and consumed by `world/audio.js:516` + **19 call sites +across 5 tools files** (`flags_check.py` 13, `v2_tour.py` 2, `interior_scale_check.py` 2, `shots.py` 1, +`qa/r39_runtime.py` 1). `citizens/sim.js:363 _openAt` is still the third implementation (A's semantics, +its own clock), `world/dbg.js:241,279,286` still carry the ad-hoc inline hour predicates. + +**Why it can wait, measured:** **0 of 1,838 corpus shops** (27 gig plans: synthetic + 3 fixtures + 23 +towns at the golden seed) lack `hours` — the divergence cannot fire on any shippable plan today, and +the selfcheck asserts sane hours per shop, so a hours-less shop cannot arrive silently. **Why it must +not ride another epoch:** v9+ keeps adding lot kinds (carts, stalls, civic) and the first one that +ships hours-less flips every consumer of the shell's predicate to "permanently closed". **The fix is +still ~5 lines, all in F's files:** `index.html` imports A's `isOpen` from the barrel and passes +`currentHour()`; sim keeps its clock, same predicate. A's export needs no change. + +### D3 — RE-FILED, owner B — the bite re-measured: 81 venue lots wear op-shop signage today + +Re-measured today: `world/fixture_plan.js SHOP_REGISTRY` still has **9 types** — `pub`, `band_room`, +`rsl` still missing (A's `core/registry.js SHOP_TYPES`: 12, each with full `sign:{bg,fg,accent,style}`). +The bite is now `buildings.js:465` (was :344 — line drifted, defect identical): +`SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop` ⇒ every venue falls to op-shop signage +(`#2c2820`/`#f0e2c0`) instead of the registry's gilt/stencil/club palettes. **Corpus-wide at the golden +seed that is 81 venue lots across 27 towns**, ~3/town, on the town's most photographed buildings — the +gig venues. The stale header (`fixture_plan.js:26-27`, "registry.js hasn't landed yet") is now **25 +rounds** old; registry.js landed R13. + +**Why it can wait one more round (and no more):** cosmetic-only — no gate red, no plan field wrong, no +golden guarding sign colours. **Why B should do it in R41:** the fix is two imports and a header +(`buildings.js` reads `SHOP_TYPES[type].sign.bg/.fg`; `SHOP_REGISTRY` shrinks to the honest +fallback-only table), and F should expect **a screenshot golden may move — never a plan golden**. A +supplies nothing further; the registry has carried the three venue sign palettes since R13. + ## Round 39 (2026-08-03) — v9 item 39.1: THE ADDRESS LAYER — the contract, published **`web/js/citygen/address.js` is landed, green, and yours to consume.** Pure module: **zero imports**, diff --git a/web/js/citygen/gigs.js b/web/js/citygen/gigs.js index 055ef78..5996f4f 100644 --- a/web/js/citygen/gigs.js +++ b/web/js/citygen/gigs.js @@ -297,20 +297,21 @@ function buildPosters(plan, citySeed, venues, gigs) { // ROUND19 (E's finding): on a REAL street graph a spine edge is crossed by side streets, so a pole // placed on the spine verge can overhang a crossing carriageway. Skip any candidate that lands on a - // carriageway — measured EXACTLY as the selfcheck's floor (nearest edge, `roadWidth/2`). This is a - // no-op on the synthetic town (its spine posters already clear every carriageway, so nothing skips and - // the gig golden is frozen); it only drops the overhanging ones on real graphs. `pr()` is consumed - // before the check, so the rng stream — hence the synthetic output — is unchanged. + // carriageway. ROUND40 (§40.2 D4): the test is ALL EDGES, not nearest-edge — the nearest edge is not + // always the binding one. A kerbless `arcade` (roadWidth 0) can sit nearer than a main whose kerb the + // candidate is INSIDE, and it always "clears": measured 8/108 gig plans with a pole up to 1.78 m inside + // a non-nearest main (northbridge_real 42) that the nearest-edge rule structurally could not see. A + // no-op on the synthetic town (0/4 plans, before AND after — measured); `pr()` is consumed before the + // check, so the rng stream — hence the synthetic output — is unchanged. const onCarriageway = (x, z) => { - let best = Infinity, bestE = null; for (const e2 of plan.streets.edges) { const a2 = nodeById.get(e2.a), b2 = nodeById.get(e2.b); if (!a2 || !b2) continue; const ex = b2.x - a2.x, ez = b2.z - a2.z, L2 = ex * ex + ez * ez || 1; let tt = ((x - a2.x) * ex + (z - a2.z) * ez) / L2; tt = Math.max(0, Math.min(1, tt)); const d = Math.hypot(x - (a2.x + ex * tt), z - (a2.z + ez * tt)); - if (d < best) { best = d; bestE = e2; } + if (d < roadWidth(e2) / 2 - 1e-6) return true; } - return bestE && best < roadWidth(bestE) / 2 - 1e-6; + return false; }; const pr = rng(citySeed, 'posters', 0); const PER_EDGE = 2; diff --git a/web/js/citygen/index.js b/web/js/citygen/index.js index 0376211..6bb462c 100644 --- a/web/js/citygen/index.js +++ b/web/js/citygen/index.js @@ -7,7 +7,11 @@ // (Originated as a Lane F integration shim because the shell imports ./js/citygen/index.js while the // generator ships as plan.js; adopted here as the real Lane A entry point, per the shim's own note.) -export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; +// R40 (§40.2 D1): `chunkIndexByCoverage` is the canonical name — AABB-coverage bucketing, plain-object +// container, id elements; answers "which cells does this thing touch?". It is NOT shape-compatible with +// Lane B's runtime `world/planutil.js chunkIndex(plan)` (centre-cell / Map / objects — the streamer's +// question). `chunkIndex` remains as a compat alias of the coverage form for existing importers. +export { generatePlan, chunkIndexByCoverage, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap, isOpen } from './plan.js'; export { shopName, townName, bandName } from './names.js'; export { generatePlanOSM, osmTownKeys, registerTownCache, getTownCache, validateTownCache, MIN_TOWN_SHOPS, medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; diff --git a/web/js/citygen/plan.js b/web/js/citygen/plan.js index d78f0ce..8fb78c9 100644 --- a/web/js/citygen/plan.js +++ b/web/js/citygen/plan.js @@ -408,10 +408,19 @@ export function generatePlan(citySeed) { } // ── chunk index: chunk key "cx,cz" → { lots, shops, edges } touching that 64m cell ────── +// ROUND40 (§40.2 D1): this is the COVERAGE form — canonical name `chunkIndexByCoverage`. It answers +// "which cells does this thing TOUCH?": a lot lands in EVERY chunk its rotated-footprint AABB covers, +// buckets are plain objects keyed "cx,cz", elements are IDS. That is the right shape for coverage +// questions (the selfcheck's gap-free corridor assert, map.html's cell census) and the WRONG shape for +// a streamer, which wants each thing built exactly once. Lane B's `world/planutil.js chunkIndex(plan)` +// is the CENTRE form the runtime streamer uses — centre-cell bucketing, Map container, object elements +// — and the two are NOT interchangeable on any of those three axes: a one-line import swap would build +// AABB-spanning lots up to four times. `chunkIndex` stays exported as a compat alias for existing +// importers (map.html); new code should import the name that states its question. export const CHUNK = 64; export const chunkKey = (cx, cz) => `${cx},${cz}`; -export function chunkIndex(plan, chunkSize = CHUNK) { +export function chunkIndexByCoverage(plan, chunkSize = CHUNK) { const cell = v => Math.floor(v / chunkSize); const chunks = {}; const bucket = k => (chunks[k] ||= { lots: [], shops: [], edges: [] }); @@ -464,3 +473,5 @@ export function chunkIndex(plan, chunkSize = CHUNK) { } return { chunkSize, chunks }; } +// Compat alias (pre-R40 name) — same function, question-free name. Prefer chunkIndexByCoverage. +export const chunkIndex = chunkIndexByCoverage; diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index eba59b9..bf8b1ae 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -7,7 +7,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js'; +import { generatePlan, chunkIndexByCoverage, lotCorners, obbOverlap, CHUNK } from './plan.js'; import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS, medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js'; @@ -128,13 +128,13 @@ function structuralSuite(plan, label) { } ok(!crossBlock, `${label}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : '')); - const idx = chunkIndex(plan); + const idx = chunkIndexByCoverage(plan); const covered = new Set(); for (const cellB of Object.values(idx.chunks)) { for (const id of cellB.lots) covered.add(id); ok(cellB.lots.every(id => lotIds.has(id)) && cellB.shops.every(id => plan.shops.some(sh => sh.id === id)) && cellB.edges.every(id => edgeIds.has(id)), `${label}: chunk buckets reference real ids`); } - ok(plan.lots.every(l => covered.has(l.id)), `${label}: chunkIndex covers every lot`); + ok(plan.lots.every(l => covered.has(l.id)), `${label}: chunkIndexByCoverage covers every lot`); const cell = v => Math.floor(v / CHUNK); let edgeGap = null; @@ -153,7 +153,7 @@ function structuralSuite(plan, label) { } if (edgeGap) break; } - ok(!edgeGap, `${label}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : '')); + ok(!edgeGap, `${label}: chunkIndexByCoverage covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : '')); ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `${label}: JSON round-trip lossless`); @@ -261,25 +261,31 @@ function districtInvariants(plan, label) { 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; }); + // ROUND40 (§40.2 D4): ARM 1 clearance is the MINIMUM over ALL edges, not the nearest edge's. The + // nearest edge is not always the binding one — a kerbless `arcade` (vergeBand inner 0) wins the + // nearest-edge race and always passes, camouflaging a main whose carriageway the poster stands INSIDE + // (measured: 8/108 gig plans, worst 1.78 m inside a main on northbridge_real 42, invisible to the + // nearest-edge form by construction). Frontage posters pass the all-edges floor too, measured, 238/238 + // — pickVenues' facadeClear was ALREADY all-edges, so the gate now asks the placer's own question. const segs = edgeSegments(plan); let onRoad = null, offFootpath = null; for (const p of plan.posters) { const frontage = onFacade(p); - let best = Infinity, bestEdge = null, onSomeFootpath = false; + let best = Infinity, bestEdge = null, minClear = Infinity, bindEdge = null, onSomeFootpath = false; for (const [e, ax, az, bx, bz] of segs) { const [nx, nz] = nearestOnSeg(p.x, p.z, ax, az, bx, bz); const d = Math.hypot(p.x - nx, p.z - nz); if (d < best) { best = d; bestEdge = e; } const [inner, outer] = vergeBand(e); + if (d - inner < minClear) { minClear = d - inner; bindEdge = e; } // ARM 1 — binding kerb over ALL edges if (outer > inner && d >= inner - 1e-6 && d <= outer + 1e-6) onSomeFootpath = true; } if (!bestEdge) continue; - const clearance = best - vergeBand(bestEdge)[0]; // ARM 1 — the kerb IS vergeBand's inner edge const floor = frontage ? POSTER_CLEAR - 0.02 : -1e-6; - if (clearance < floor && onRoad === null) onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; + if (minClear < floor && onRoad === null) onRoad = [p.id, bindEdge.id, +minClear.toFixed(2), frontage ? 'frontage' : 'spine']; if (!frontage && !onSomeFootpath && offFootpath === null) offFootpath = [p.id, bestEdge.id, +best.toFixed(2)]; } - ok(onRoad === null, `${label}: no poster stands on a carriageway — vergeBand inner edge (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + + ok(onRoad === null, `${label}: no poster stands on ANY carriageway — binding vergeBand inner edge over all edges (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]}'s kerb by only ${onRoad[2]} m)` : '')); ok(offFootpath === null, `${label}: every spine poster stands INSIDE some edge's vergeBand (the footpath B paints in to roadWidth/2)` + (offFootpath ? ` (poster ${offFootpath[0]} is ${offFootpath[2]} m from nearest edge ${offFootpath[1]} and inside no verge band)` : '')); @@ -764,28 +770,32 @@ const REAL_TOWN_GOLDENS = { // absorb to come) are regression-guarded byte-exact, not just by districtInvariants. // ROUND21: re-pinned for the venue cluster-adjacency bias (D's relocation finding). Only the GIG goldens // move — venue selection is gig-layer, so the base town goldens above are untouched. +// ROUND40 (§40.2 D4): 7 towns re-pinned for the all-edges onCarriageway rule (adelaide, bendigo, +// fitzroy, fremantle, geelong, marrickville, northbridge). Dropping the candidates that stood inside a +// non-nearest carriageway re-shuffles the postercap subsample; poster COUNTS are unchanged on all 108 +// plans (measured old-placer vs new, 0 count diffs), synthetic byte-identical 0/4, base goldens frozen. const REAL_TOWN_GIG_GOLDENS = { - adelaide_real: 0xda729bae, + adelaide_real: 0x1a00e20f, // R40 D4 re-pin (was 0xda729bae) ballarat_real: 0xf097c331, - bendigo_real: 0xe3e8d892, + bendigo_real: 0x9f82e059, // R40 D4 re-pin (was 0xe3e8d892) bowral_real: 0x3f37bbcd, braddon_real: 0xbc51aed2, brunswick_real: 0xc0a90188, castlemaine_real: 0x3c3cf400, darwin_real: 0xd37b37cd, daylesford_real: 0x7b24c5c5, - fitzroy_real: 0xcd31b1ab, - fremantle_real: 0xc6449dc, - geelong_real: 0xcd0ceac4, + fitzroy_real: 0x60b8bba0, // R40 D4 re-pin (was 0xcd31b1ab) + fremantle_real: 0x8d7bcb63, // R40 D4 re-pin (was 0xc6449dc) + geelong_real: 0x1f866ae7, // R40 D4 re-pin (was 0xcd0ceac4) glebe_real: 0x8d2b12ca, hobart_real: 0xda3211c9, katoomba_real: 0x5eece2d4, launceston_real: 0xb619b65b, - marrickville_real: 0x5dc00b5f, + marrickville_real: 0xac4c34d0, // R40 D4 re-pin (was 0x5dc00b5f) newcastle_real: 0xfc3cb144, newtown_godverse: 0x4153dee2, newtown_real: 0xb6a75bb1, - northbridge_real: 0x49fe0f2b, + northbridge_real: 0x4ea89275, // R40 D4 re-pin (was 0x49fe0f2b) // toowoomba_real RETIRED by E in R23 — see the note on the base map above. redhill_godverse: 0xb53f695d, redhill_real: 0x21db6d1,