Lane A R40 §40.2: D1-D4 settled — the poster that stood in the road, and no silent carry

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-07 12:27:51 +10:00
parent 97b98b15af
commit dab5ecd273
7 changed files with 188 additions and 32 deletions

View File

@ -1,6 +1,10 @@
# LANE A — CITYGEN · progress (PROCITY-A) # LANE A — CITYGEN · progress (PROCITY-A)
*Status: **all deliverables landed; self-check ALL GREEN (157,647/157,647); rounds 230 + 37 + 39 closed**. *Status: **all deliverables landed; self-check ALL GREEN (157,647/157,647); rounds 230 + 37 + 3940 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 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 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.** 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. arms with a permanent control; proven to fail on the pre-ruling geometry where the old gate cannot.
All goldens frozen, zero re-pins.* All goldens frozen, zero re-pins.*
## Round 40 (§40.2) — (2026-08-04) THE DEBT ROUND: D1D4 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) ## 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 **The full contract, per-town table and reasoning live in `LANE_A_NOTES.md §39`** — published in the

View File

@ -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"). > **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 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 `chunkIndexByCoverage(plan)` helper (R40 canonical name; `chunkIndex` remains as a compat alias):
the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture, chunk key → { lots, shops, edges } **touching** that cell — the COVERAGE question. A lot lands in every
footpath NPCs) resolves to a chunk whose `edges` list includes its edge. 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 ## 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). - `generatePlan(seed) → CityPlan` — synthetic producer (unchanged, byte-identical).
- `generatePlanOSM(seed, town = 'melbourne', { report } = {}) → CityPlan` — osm producer; pass a - `generatePlanOSM(seed, town = 'melbourne', { report } = {}) → CityPlan` — osm producer; pass a
`report` object to receive the normalization log (below). `osmTownKeys() → string[]`. `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)`. - Geometry helpers (shared with the harness): `lotCorners(lot)`, `obbOverlap(a, b)`.
**Determinism & goldens.** Same inputs ⇒ byte-identical plan, forever. The determinism gate keys on **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 (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 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**; 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 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 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 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 "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 *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 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 **three arms**: (1) no poster on **any** carriageway, measured through `vergeBand(e)[0]` — the published
itself, the same call B's ground consumes, so gate and renderer cannot drift apart; (2) every **spine** 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" 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 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 bitumen under the pre-ruling ground (`?verge=0`, road quad = full `e.width`) and assert that count is

View File

@ -2,6 +2,94 @@
Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. 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 — D1D4 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.081.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 ## 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**, **`web/js/citygen/address.js` is landed, green, and yours to consume.** Pure module: **zero imports**,

View File

@ -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 // 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 // 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 // carriageway. ROUND40 (§40.2 D4): the test is ALL EDGES, not nearest-edge — the nearest edge is not
// no-op on the synthetic town (its spine posters already clear every carriageway, so nothing skips and // always the binding one. A kerbless `arcade` (roadWidth 0) can sit nearer than a main whose kerb the
// the gig golden is frozen); it only drops the overhanging ones on real graphs. `pr()` is consumed // candidate is INSIDE, and it always "clears": measured 8/108 gig plans with a pole up to 1.78 m inside
// before the check, so the rng stream — hence the synthetic output — is unchanged. // 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) => { const onCarriageway = (x, z) => {
let best = Infinity, bestE = null;
for (const e2 of plan.streets.edges) { for (const e2 of plan.streets.edges) {
const a2 = nodeById.get(e2.a), b2 = nodeById.get(e2.b); if (!a2 || !b2) continue; 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; 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)); 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)); 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 pr = rng(citySeed, 'posters', 0);
const PER_EDGE = 2; const PER_EDGE = 2;

View File

@ -7,7 +7,11 @@
// (Originated as a Lane F integration shim because the shell imports ./js/citygen/index.js while the // (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.) // 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 { shopName, townName, bandName } from './names.js';
export { generatePlanOSM, osmTownKeys, registerTownCache, getTownCache, validateTownCache, MIN_TOWN_SHOPS, export { generatePlanOSM, osmTownKeys, registerTownCache, getTownCache, validateTownCache, MIN_TOWN_SHOPS,
medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js';

View File

@ -408,10 +408,19 @@ export function generatePlan(citySeed) {
} }
// ── chunk index: chunk key "cx,cz" → { lots, shops, edges } touching that 64m cell ────── // ── 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 CHUNK = 64;
export const chunkKey = (cx, cz) => `${cx},${cz}`; 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 cell = v => Math.floor(v / chunkSize);
const chunks = {}; const chunks = {};
const bucket = k => (chunks[k] ||= { lots: [], shops: [], edges: [] }); const bucket = k => (chunks[k] ||= { lots: [], shops: [], edges: [] });
@ -464,3 +473,5 @@ export function chunkIndex(plan, chunkSize = CHUNK) {
} }
return { chunkSize, chunks }; return { chunkSize, chunks };
} }
// Compat alias (pre-R40 name) — same function, question-free name. Prefer chunkIndexByCoverage.
export const chunkIndex = chunkIndexByCoverage;

View File

@ -7,7 +7,7 @@
import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; 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, import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS,
medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js'; medianShopSpacing, MAX_MEDIAN_SPACING_M } from './plan_osm.js';
import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.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})` : '')); ok(!crossBlock, `${label}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
const idx = chunkIndex(plan); const idx = chunkIndexByCoverage(plan);
const covered = new Set(); const covered = new Set();
for (const cellB of Object.values(idx.chunks)) { for (const cellB of Object.values(idx.chunks)) {
for (const id of cellB.lots) covered.add(id); 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(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); const cell = v => Math.floor(v / CHUNK);
let edgeGap = null; let edgeGap = null;
@ -153,7 +153,7 @@ function structuralSuite(plan, label) {
} }
if (edgeGap) break; 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`); 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) 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; 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); const segs = edgeSegments(plan);
let onRoad = null, offFootpath = null; let onRoad = null, offFootpath = null;
for (const p of plan.posters) { for (const p of plan.posters) {
const frontage = onFacade(p); 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) { for (const [e, ax, az, bx, bz] of segs) {
const [nx, nz] = nearestOnSeg(p.x, p.z, ax, az, bx, bz); const [nx, nz] = nearestOnSeg(p.x, p.z, ax, az, bx, bz);
const d = Math.hypot(p.x - nx, p.z - nz); const d = Math.hypot(p.x - nx, p.z - nz);
if (d < best) { best = d; bestEdge = e; } if (d < best) { best = d; bestEdge = e; }
const [inner, outer] = vergeBand(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 (outer > inner && d >= inner - 1e-6 && d <= outer + 1e-6) onSomeFootpath = true;
} }
if (!bestEdge) continue; 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; 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)]; 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)` : '')); (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)` + 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)` : '')); (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. // 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 // 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. // 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 = { const REAL_TOWN_GIG_GOLDENS = {
adelaide_real: 0xda729bae, adelaide_real: 0x1a00e20f, // R40 D4 re-pin (was 0xda729bae)
ballarat_real: 0xf097c331, ballarat_real: 0xf097c331,
bendigo_real: 0xe3e8d892, bendigo_real: 0x9f82e059, // R40 D4 re-pin (was 0xe3e8d892)
bowral_real: 0x3f37bbcd, bowral_real: 0x3f37bbcd,
braddon_real: 0xbc51aed2, braddon_real: 0xbc51aed2,
brunswick_real: 0xc0a90188, brunswick_real: 0xc0a90188,
castlemaine_real: 0x3c3cf400, castlemaine_real: 0x3c3cf400,
darwin_real: 0xd37b37cd, darwin_real: 0xd37b37cd,
daylesford_real: 0x7b24c5c5, daylesford_real: 0x7b24c5c5,
fitzroy_real: 0xcd31b1ab, fitzroy_real: 0x60b8bba0, // R40 D4 re-pin (was 0xcd31b1ab)
fremantle_real: 0xc6449dc, fremantle_real: 0x8d7bcb63, // R40 D4 re-pin (was 0xc6449dc)
geelong_real: 0xcd0ceac4, geelong_real: 0x1f866ae7, // R40 D4 re-pin (was 0xcd0ceac4)
glebe_real: 0x8d2b12ca, glebe_real: 0x8d2b12ca,
hobart_real: 0xda3211c9, hobart_real: 0xda3211c9,
katoomba_real: 0x5eece2d4, katoomba_real: 0x5eece2d4,
launceston_real: 0xb619b65b, launceston_real: 0xb619b65b,
marrickville_real: 0x5dc00b5f, marrickville_real: 0xac4c34d0, // R40 D4 re-pin (was 0x5dc00b5f)
newcastle_real: 0xfc3cb144, newcastle_real: 0xfc3cb144,
newtown_godverse: 0x4153dee2, newtown_godverse: 0x4153dee2,
newtown_real: 0xb6a75bb1, 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. // toowoomba_real RETIRED by E in R23 — see the note on the base map above.
redhill_godverse: 0xb53f695d, redhill_godverse: 0xb53f695d,
redhill_real: 0x21db6d1, redhill_real: 0x21db6d1,