diff --git a/docs/A-progress.md b/docs/A-progress.md index 5406d5e..690c9cd 100644 --- a/docs/A-progress.md +++ b/docs/A-progress.md @@ -1,6 +1,41 @@ # LANE A — CITYGEN · progress (PROCITY-A) -*Status: **all deliverables landed; self-check ALL GREEN (156,212/156,212); rounds 2–27 closed** (see below). +*Status: **all deliverables landed; self-check ALL GREEN (156,352/156,352); rounds 2–27 (incl. wave 5) closed**. +R27 w5 THE FACADE FIX: every town — synthetic included — had its shopfronts facing away from the street +since **v1**. Fixed in all four ry sites; **51 goldens re-pinned**; the classic covenant amended on John's +ruling (`0x3fa36874 → 0x5f76e76`); a cross-convention gate added so it can never recur.* + +## Round 27 wave 5 (2026-07-17) — THE FACADE FIX (the epoch's biggest catch) + +The ruling asked me to land B's one-liner in the real-roads seating and to **measure the marched path +rather than assume it was clean**. I measured all three paths, and the ruling's premise was wrong in the +half that mattered: **synthetic is broken too**. Not "every real town since R18" — **every town, every +path, since v1**. Confirmed numerically (0 toward / 681 away, dot −1.0000) and then with my own eyes: +`?classic=1`, synthetic main street = three blank boxes; walk into the block and NUMBAT PLAYTHINGS is +there, facing the dirt. 637 of 681 lots pointed their facade at **no street at all**. + +- **Four sites, one sign.** `plan.js:122` (synthetic strip), `plan.js:244` (market row), `plan_osm:330` + (marched avenue), `plan_osm:443` (real roads — B's proven one-liner). All `atan2(outward)` where the + intent was `atan2(−outward)`, and **every one says so in its own comment**. The code never did what it + said; CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. + After: synthetic 681/681, marched + all 3 fixtures 100%, real-roads katoomba **0/72 → 72/72** (B's number). +- **The covenant moved, on John's ruling.** Fixing synthetic necessarily moves `0x3fa36874`, frozen + "forever" since R16 — not Lane A's call, so I stopped and asked. Ruling: **amend and fix all paths** (the + covenant is anti-drift machinery, not a defect preservative). **51 goldens re-pinned in one commit**; + amendment recorded in CITY_SPEC as a one-time, ruling-gated precedent. +- **A second bug the fix exposed, fixed with it:** `buildRealRoads` seated blocks at `KERB = 4 m` from the + **centreline**, but a `main` carriageway is 10 m wide (kerb at 5 m) — **every main-street building has + stood 1 m inside the road since R18**. Invisible until the shopfront turned around and its poster landed + in the traffic lane. Blocks now seat behind the kerb per A's own corridor law. Drops 6.4% → **6.6%**, counted. +- **The cross-convention gate (the lesson made law).** It does not restate a spec line — that is the trap F + proved (72/72 "facing" over blank walls). It replicates **Lane B's own** `buildings.js` maths and asks + whether the quad the *renderer* draws lands nearer the street than the lot centre. Fires if A's `ry` **or** + B's `zf` drifts. It would have caught this on day one. +- **→ F (blocking their re-gate):** `scaffold_check` is RED until F re-pins the covenant hashes duplicated + in **F's own files** (`flags_check.py` `GOLDEN_HASH`/`GIG_GOLDEN`, `scaffold_check.mjs` `TOWN_GOLDENS`, + two tour captions). Exact values handed over in LANE_A_NOTES §R27w5; I can't touch F's files. + +*Previous status: 156,212/156,212; rounds 2–27 closed (see below). R27 (v5.0 close): **CITY_SPEC v5 section** (tier ladder · `sourcing` vs `tier` · the three id-space fences · the manifest · Ruling 2) + the **charter risk list judged line by line** — risk #1 deliberately CARRIED, not pre-retired, because F's kill-the-server gate is what retires it. classic `0x3fa36874` / gig `0xb1d48ea1` diff --git a/docs/CITY_SPEC.md b/docs/CITY_SPEC.md index 10793d8..b8c3754 100644 --- a/docs/CITY_SPEC.md +++ b/docs/CITY_SPEC.md @@ -28,7 +28,21 @@ considered and parked: nothing in v1 needs it, and the asset pipeline (GLB, web- - **Metres. +Y up. Ground plane is XZ.** City origin (0,0) = centre of the main square. - **Right-handed three.js defaults.** Buildings face their street; a facade's outward normal - points at its street edge. GLB convention: origin at base, facing **−Z**, real-world scale. + points at its street edge. +- **THE TWO CONVENTIONS — they are different things, and conflating them cost nine rounds + (ROUND27). Read both:** + - **The FACADE convention (the lots contract, `lot.ry`): the visible shopfront is local `+Z`** ⇒ + world `(sin ry, cos ry)`. This is B's canon and the de facto truth since v1 — `buildings.js` + draws the facade quad and door at local `z = +d/2`, `venue.js` seats the door-glow/queue/ + posters there. **`lot.ry` aims +Z at `frontEdge`.** + - **The SIM/RIG convention (props, not lots): a GLB is modelled facing `−Z`** (origin at base, + real-world scale) ⇒ rig-forward is world `(−sin ry, −cos ry)`. This describes *how a model is + authored*, **not which way a lot faces.** + - **They are opposites, so a check written in one silently passes over a world built in the + other.** CITY_SPEC:71 wrote the SIM convention into the LOTS contract by ambiguity; each + generator implemented one half; every golden stayed green over a town whose every shopfront + faced away. **Never assert a lot's facing against a spec line — assert it against the + geometry the renderer actually draws** (`selfcheck.js` now does exactly that). - **Seeded everything, `Math.random()` is banned in generation code.** `web/js/core/prng.js` is the only randomness source: - `seedFor(citySeed, kind, id)` → stable uint32 (xmur3 hash of `"seed:kind:id"`). @@ -68,8 +82,11 @@ Schema v1 (Lane A owns it; extend, don't break): // onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking). lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ], // use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill' - // ry: Y-rotation (radians) so a GLB modelled facing −Z ends up with its facade's OUTWARD normal - // pointing at frontEdge. World facing = (−sin ry, −cos ry). Lane B rotates the shell by ry. + // ry: Y-rotation (radians) so the lot's VISIBLE FACADE — local +Z — points at frontEdge. + // Facade/door/frontage normal = local +Z ⇒ world (sin ry, cos ry). Lane B rotates the shell by ry + // and draws the shopfront at local z = +d/2 (buildings.js), so +Z IS the street face. ROUND27. + // (The SIM/rig convention — a GLB modelled facing −Z, world (−sin ry, −cos ry) — is a DIFFERENT + // thing and is documented as such below. It is not the lots contract. See the amendment.) // frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent). shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ] // type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23. @@ -256,9 +273,30 @@ night in v1; interior-mapping shader is a stretch goal). > did not disappear. `gigs`, `weather`, `winmap`, `tram` now default **ON**; the default boot is the full > experience, deterministic to the **gig golden `0xb1d48ea1`**. Each flag keeps a `=0` opt-out, and > **`?classic=1`** forces all four off at once: -> - **Classic law (the new frozen regression target):** the `?classic=1` boot is byte-identical to the v2 -> baseline **forever** — synthetic fingerprint `0x3fa36874`, zero gig layer, **zero fetch delta** (no +> - **Classic law (the frozen regression target):** the `?classic=1` boot is byte-identical to the v2 +> baseline — synthetic fingerprint **`0x5f76e76`**, zero gig layer, **zero fetch delta** (no > `custom_bands.json`, no `sit.glb`). The old "flags-off regression" gate is now the **classic-regression** gate. +> +> > **THE COVENANT AMENDMENT — ROUND27, THE FACADE FIX (John's ruling; the only amendment in five epochs).** +> > The covenant froze `0x3fa36874` "forever" from R16 to R27. That hash anchored **a wrong world**: every +> > shopfront in every town — synthetic and real — faced *away* from its street, because CITY_SPEC:71 wrote +> > the SIM convention into the LOTS contract (see *Units, axes*). The covenant did exactly its job for nine +> > rounds: it held the base town byte-identical. It just held it identical to something broken, and no gate +> > could see it — a self-consistent check over a self-contradicting spec passes under either convention. +> > +> > **The covenant's purpose is anti-drift, not the preservation of a defect.** So on John's ruling it is +> > **amended once, deliberately, in the open**: the anchor moves `0x3fa36874 → 0x5f76e76`, and every other +> > golden moves with it (gig `0xb1d48ea1 → 0xec7a2d39`, the three marched fixtures, all 23 real towns base +> > + gig — **51 pins in one commit**, per the amendment law). The classic town is now what it always claimed +> > to be: a street you can stand on and read the shopfronts. **From this hash forward the law reads as it +> > always did — byte-identical forever.** An amendment requires a John-level ruling and a demonstrated +> > world-level defect, not a convenience; this is the precedent, not an opening. +> > +> > *Exposed by the same fix (and fixed with it): `buildRealRoads` seated blocks at `KERB = 4 m` from the +> > centreline while a `main` carriageway is 10 m wide — its kerb is at 5 m. Every main-street building has +> > stood 1 m inside the road since R18. Invisible while nothing was drawn on that face; the moment the +> > shopfront turned around, its poster landed in the traffic lane. Blocks now seat behind the kerb per the +> > corridor law (`roadWidth/2 + FOOTPATH`). Pack drops 6.4% → 6.6%, counted.* > - **Default law:** the no-flag boot pins to the gig golden, and every gig / district / continuity / cover > smoke **and the budget law** (interior ≤350 draws · street ≤300 / 200k tris, at the busiest venue block) > now run against the **default boot with all four flags live**. `?noassets=1` composes with it and stays diff --git a/docs/LANES/LANE_A_NOTES.md b/docs/LANES/LANE_A_NOTES.md index 3441963..41d6aac 100644 --- a/docs/LANES/LANE_A_NOTES.md +++ b/docs/LANES/LANE_A_NOTES.md @@ -2,6 +2,68 @@ Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. +## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones + +### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them** +This is the one thing blocking your re-gate. `scaffold_check` is **RED** until you re-pin, and I can't +touch your files. Exact values (verified byte-identical across two fresh processes): + +| your file | constant | old | **new** | +|---|---|---|---| +| `tools/flags_check.py:30` | `GOLDEN_HASH` | `0x3fa36874` | **`0x5f76e76`** | +| `tools/flags_check.py:31` | `GIG_GOLDEN` | `0xb1d48ea1` | **`0xec7a2d39`** | +| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.melbourne` | `0x34cfdec0` | **`0x9c7e76b3`** | +| `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.katoomba` | `0x0f652510` | **`0xf3aafec8`** | +| `tools/qa/scaffold_check.mjs:225` | the comment quoting all three | — | same three | +| `tools/qa/tour_v5.py:42`, `tour_shots.py:10` | captions quoting `0x3fa36874` | — | **`0x5f76e76`** | + +Also: `flags_check.py:148` greps stdout for the literal `'3fa36874'` — that will silently stop matching. + +### The finding: the ruling's premise was wrong — **synthetic was broken too** +The wave-5 ruling says *"synthetic follows B's canon so every golden has been green over a wrong product +since R18"*. Half right, and the wrong half is load-bearing. **Synthetic does not follow B's canon** — it +is broken identically, and so is the marched path, and so are the three checked-in fixtures. It is not +"every real town since R18": it is **every town, every path, since v1**. Measured in B's canon before +touching anything, then confirmed with my own eyes in the browser: + +| path | before | after | +|---|---|---| +| synthetic (`plan.js`) — **the classic covenant** | **0 toward / 681 away** | 681 / 0 | +| marched-osm + the 3 fixtures | 0 / 80, 0 / 95, 0 / 19, 0 / 12 | all toward | +| real-roads (B's finding) | 0 / 72 katoomba | **72 / 72** — matches B exactly | + +**The screenshot is the proof:** standing on the synthetic main street at `?classic=1`, lot 0 — three blank +grey boxes. Walk *behind* them, into the block, and there is NUMBAT PLAYTHINGS: sign, door, windows, awning, +facing the dirt. Same camera after the fix: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS, a +strip of shopfronts facing the road. **637 of 681 synthetic lots had their facade pointing at no street at +all.** + +**Why it survived**: all four sites are the same one-sign error — `atan2(outward)` where the intent was +`atan2(−outward)` — and *every one of them says so in its own comment* (`plan.js:122` "facade faces back +down the outward normal, to the street"). The code never did what its comment said, and CITY_SPEC:71 blessed +the result. Nobody was wrong; the spec was. + +### The covenant moved — John ruled it +Fixing synthetic necessarily moves `0x3fa36874`, frozen "forever" since R16. That was not mine to decide. +**John's ruling: amend the covenant, fix all paths** — the covenant is anti-drift machinery, not a defect +preservative. **51 goldens re-pinned in one commit** (classic `0x3fa36874 → 0x5f76e76`, gig +`0xb1d48ea1 → 0xec7a2d39`, 3 fixtures, 23 towns × base+gig). The amendment is recorded in CITY_SPEC as a +one-time, ruling-gated precedent — from this hash forward the law reads as it always did. + +### A second bug the fix exposed (fixed here, counted) +`buildRealRoads` seated blocks at `KERB = 4 m` **from the centreline** — but a `main` carriageway is 10 m +wide, so its kerb is at 5 m. **Every main-street building has stood 1 m inside the road since R18.** The +footprint never moved; only which face is the front — so it was invisible until the shopfront turned around +and its poster landed in the traffic lane (−1.07 m). Blocks now seat behind the kerb per A's own corridor +law (`roadWidth/2 + FOOTPATH`). Pack drops **6.4% → 6.6%**, counted, not hidden. + +### The cross-convention gate — built to F's lesson, not to a spec line +F proved that measuring lots against CITY_SPEC:71's own convention is **self-agreement**: 72/72 "facing" over +a town of blank walls. So the new gate does not restate a spec line. It **replicates Lane B's own placement +maths** from `buildings.js` (`toWorld(lot, 0, y, d/2 + 0.02)`) and asks a physical question neither spec line +can fudge: *does the quad the renderer actually draws land nearer the street than the lot centre?* It fires +if A's `ry` sign drifts **or** if B's `zf` sign drifts. It would have caught this on day one of v1. + ## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6) ### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag** diff --git a/web/js/citygen/plan.js b/web/js/citygen/plan.js index 84d455b..d78f0ce 100644 --- a/web/js/citygen/plan.js +++ b/web/js/citygen/plan.js @@ -119,7 +119,11 @@ export function generatePlan(citySeed) { const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)]; const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]); out.blockId = blockId; - const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street + // ROUND27 THE FACADE FIX: aim the VISIBLE facade (local +Z — B's canon, what buildings.js draws) back + // down the outward normal, at the street. The old `atan2(nx, nz)` put +Z along the OUTWARD normal, i.e. + // facing away — this line's own comment has described the intended result since v1; only the sign was + // wrong. It read correct because CITY_SPEC:71 wrote the SIM convention (−Z) into the lots contract. + const ry = Math.atan2(-nx, -nz); // facade (+Z) faces back down the outward normal, to the street const r = rng(citySeed, streamKind, streamId); const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0; let t = iStart; const end = len - iEnd; @@ -237,11 +241,14 @@ export function generatePlan(citySeed) { const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2); const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]]; const block = addBlock(dMarket, 'market', poly); - // Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal - // (street→lot); a west-of-street lot's outward normal is −x, so ry = atan2(−1, 0) (= −π/2). The - // facade then faces −(outward) = +x, matching marchStrip's convention. frontEdge is the spine - // segment each lot actually fronts by z-band: stalls (z≈−100..−23) front segment 2; dept (z=55) → 3. - const ryEast = Math.atan2(-1, 0); + // Facades face +x (EAST, toward the spine — the market sits west of it). ROUND27 THE FACADE FIX: ry now + // aims the VISIBLE facade (local +Z, B's canon) at the street, so a west-of-street lot wants +Z = +x ⇒ + // ry = atan2(+1, 0) (= +π/2). It was atan2(−1, 0), which pointed +Z at −x — away from the spine, into + // the market's back. Same one-sign error as marchStrip above, and the old comment described the + // intended result ("the facade then faces −(outward) = +x") that the code did not produce. + // frontEdge is the spine segment each lot actually fronts by z-band: stalls (z≈−100..−23) front + // segment 2; dept (z=55) → 3. + const ryEast = Math.atan2(1, 0); const stallEdge = spineEdges[2]; // spine segment spanning z∈[−133,0] — the stalls' band const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square diff --git a/web/js/citygen/plan_osm.js b/web/js/citygen/plan_osm.js index 256b2f3..982c6ea 100644 --- a/web/js/citygen/plan_osm.js +++ b/web/js/citygen/plan_osm.js @@ -12,7 +12,7 @@ // A future pass can honour exact projected positions; this lands a booting, invariants-green seam. import { rng, seedFor, mulberry32, pick, frange } from '../core/prng.js'; -import { SHOP_TYPES } from '../core/registry.js'; +import { SHOP_TYPES, roadWidth } from '../core/registry.js'; import { OSM_TOWNS, DEFAULT_TOWN } from './osm_fixture.js'; import { lotCorners, obbOverlap } from './plan.js'; // shared geometry — overlap-resolve real-road lots @@ -327,7 +327,11 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { const half = AV_W / 2, sd = DEPTH + 2; const block = addBlock(district, 'mainstreet', [[SPINE_CLEAR, zPos - half], [avLen, zPos - half], [avLen, zPos - half - sd], [SPINE_CLEAR, zPos - half - sd]]); - const ry = Math.atan2(0, -1); // facade faces +z (north) to the avenue + // ROUND27 THE FACADE FIX: the lots sit SOUTH of the avenue (z = zPos − half − DEPTH/2), so the + // facade must face +z (north) at it. `atan2(0,-1)` (= π) pointed local +Z at −z — due south, away. + // The comment was right; the code was 180° off. Measured, not assumed: the marched path was NOT + // clean (the ruling asked me to check rather than trust that nobody had noticed). + const ry = Math.atan2(0, 1); // facade (+Z) faces +z (north) to the avenue let t = SPINE_CLEAR; rowShops.forEach((p, j) => { const f = fr[j]; addShop(addLot(block, t + f / 2, zPos - (half + DEPTH / 2), f, DEPTH, ry, avEdge, 'shop'), p.s); t += f + GAP; }); }); @@ -347,6 +351,7 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { // shopfronts, not detached boxes. Drops 7.9% → 5.5% across the five; the residual stays COUNTED, and // dropping a crowded shop beats teleporting it onto a street it isn't on. const EPS = 6, SNAP = 3, KERB = 4, NODE_CLEAR = 3, RGAP = 0.5; // fidelity + geometry knobs (metres) + const FOOTPATH = 1.5; // ROUND27: kerb→shopfront. KERB alone is centreline-relative and sits INSIDE a main carriageway. const JOIN_TOL = 12, BRIDGE_MAX = 200, SEAT_MAX = 200; // ROUND19 fragmentation ruling (metres) const WKIND = { main: SPINE_W, side: AV_W, lane: 8, arcade: 6 }; // every dropped shop is counted (Fable's ROUND19 ruling) — and counted BY CAUSE, so the density @@ -440,8 +445,18 @@ export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) { for (const { e, side, list } of perEdge.values()) { list.sort((p, q) => p.t - q.t || p.s.id - q.s.id); const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, len = Math.hypot(dx, dz) || 1, ux = dx / len, uz = dz / len; - const nx = side * -uz, nz = side * ux, ry = Math.atan2(nx, nz); // outward normal on the shop's real side; facade (−Z) faces road - const off = KERB, dep = DEPTH; + // ROUND27 THE FACADE FIX (B's proven one-liner — B measured katoomba 0/72 → 72/72 and reverted it + // because this is Lane A's file). (nx,nz) is the OUTWARD normal on the shop's real side; the facade + // (local +Z, B's canon) must face back down it, at the road — so atan2(−nx, −nz), not atan2(nx, nz). + const nx = side * -uz, nz = side * ux, ry = Math.atan2(-nx, -nz); // outward normal on the shop's real side; facade (+Z) faces road + // ROUND27, exposed BY the facade fix: `KERB` (4 m) is measured from the CENTRELINE, but a `main` + // street's carriageway is 10 m wide — its kerb is at 5 m. So every main-street building has stood + // 1 m INSIDE the road since R18. It was invisible while the facade faced away (nothing was drawn or + // asserted on that side); the moment the shopfront turned around, its poster landed in the traffic + // lane (−1.07 m). The building never moved — only which face is the front. Seat blocks behind the + // kerb per A's own corridor law (`roadWidth/2` IS the kerb), plus a footpath to stand on. + const pe = edges.find(x => x.id === e.id); + const off = Math.max(KERB, roadWidth(pe) / 2 + FOOTPATH), dep = DEPTH; const block = addBlock(district, 'mainstreet', [ [e.a.x + nx * off, e.a.z + nz * off], [e.b.x + nx * off, e.b.z + nz * off], [e.b.x + nx * (off + dep), e.b.z + nz * (off + dep)], [e.a.x + nx * (off + dep), e.a.z + nz * (off + dep)]]); diff --git a/web/js/citygen/selfcheck.js b/web/js/citygen/selfcheck.js index bf403f6..79aedf2 100644 --- a/web/js/citygen/selfcheck.js +++ b/web/js/citygen/selfcheck.js @@ -25,8 +25,23 @@ const section = s => console.log('\n' + s); const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309]; const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B -// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)). -const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)]; +// ROUND27 THE FACADE FIX — B's canon is canonical: the VISIBLE facade is local +Z ⇒ world (sin ry, cos ry). +// This helper used to return (−sin, −cos), the SIM/rig convention that CITY_SPEC:71 mis-wrote into the lots +// contract. That is why nine rounds of goldens were green over a world where every shopfront faced away. +const facing = l => [Math.sin(l.ry), Math.cos(l.ry)]; + +// THE CROSS-CONVENTION GATE (ROUND27 wave 5 — the lesson made law). +// F proved the trap: measuring lot.ry against CITY_SPEC:71's own convention is SELF-AGREEMENT — it returned +// 72/72 "facing" over a town of blank walls, because a self-consistent check over a self-contradicting spec +// passes under either convention. The only check that can see the contradiction compares the two conventions +// AGAINST EACH OTHER. So this does not restate a spec line: it replicates LANE B's OWN placement maths from +// buildings.js (`toWorld(lot, 0, y, zf)` with `zf = d/2 + 0.02`) and asks a physical question the prose +// cannot fudge — does the quad the RENDERER actually draws land nearer the street than the lot centre? +// If either side drifts (A's ry sign, or B's zf sign), this fires. +const facadeQuadWorld = l => { // buildings.js: toWorld(lot, 0, y, d/2 + 0.02) + const cos = Math.cos(l.ry || 0), sin = Math.sin(l.ry || 0), zf = l.d / 2 + 0.02; + return [l.x + zf * sin, l.z + zf * cos]; +}; // nearest point on segment AB to point P function nearestOnSeg(px, pz, ax, az, bx, bz) { const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1; @@ -70,13 +85,24 @@ function structuralSuite(plan, label) { ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`); ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`); - let backwards = null; + let backwards = null, drawnAway = null, worstGain = 0; for (const l of plan.lots) { const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue; const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l); - if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { backwards = l.id; break; } + if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { if (backwards === null) backwards = l.id; } + + // THE CROSS-CONVENTION GATE: not "does ry match the spec's wording" (self-agreement — that is exactly + // what stayed green over blank walls for nine rounds), but "does the quad LANE B ACTUALLY DRAWS land + // nearer the street than the lot centre?". Physical, falsifiable, and blind to both spec lines. + const [qx, qz] = facadeQuadWorld(l); + const dCentre = Math.hypot(nx - l.x, nz - l.z); + const dFacade = Math.hypot(nx - qx, nz - qz); + if (dFacade >= dCentre) { if (drawnAway === null) drawnAway = l.id; } + worstGain = Math.max(worstGain, dFacade - dCentre); } ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : '')); + ok(drawnAway === null, `${label}: the RENDERED facade (B's +Z quad, buildings.js maths) faces the street` + + (drawnAway !== null ? ` — lot ${drawnAway}'s shopfront is drawn on the far side (worst: ${worstGain.toFixed(2)} m FARTHER from the road than the lot centre)` : '')); const byBlock = {}; for (const l of plan.lots) (byBlock[l.block] ||= []).push(l); @@ -238,7 +264,7 @@ for (const s of SEEDS) { // ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ──── // (If you intentionally change generator output, run selfcheck, copy the printed hash here.) section('golden fingerprint (output-drift guard)'); -const GOLDEN = { seed: 20261990, hash: 0x3fa36874 }; +const GOLDEN = { seed: 20261990, hash: 0x5f76e76 }; { const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0; ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`); @@ -274,7 +300,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, silverton: 0xd4b351c9 }; +const OSM_GOLDENS = { melbourne: 0x9c7e76b3, katoomba: 0xf3aafec8, silverton: 0x6d74c4 }; const OSM_SEEDS = [20261990, 1, 42, 777]; for (const town of osmTownKeys()) { const report = {}; @@ -310,7 +336,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 = 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 GIG_GOLDEN = 0xec7a2d39; // 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')}`); @@ -571,34 +597,34 @@ const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns'); // the fragmentation ruling (junction-protected DP + cull/bridge) + E's spine-poster fix. Amendment law: // the alpha changed nothing outside the cache-schema path; synthetic/fixtures/classic/default stay frozen. const REAL_TOWN_GOLDENS = { - adelaide_real: 0x5c790920, + adelaide_real: 0x4b9137a5, ballarat_real: 0xf0a9e8a1, - bendigo_real: 0xc175194e, - bowral_real: 0xb23ee067, - braddon_real: 0x2f388775, - brunswick_real: 0x7ecb5c3e, - castlemaine_real: 0x26c128c9, - darwin_real: 0xcfb1917f, - daylesford_real: 0x4ec7352c, - fitzroy_real: 0x6549f61b, - fremantle_real: 0x73d385df, - geelong_real: 0x2f65591e, - glebe_real: 0x908e1835, - hobart_real: 0xa96328b9, - katoomba_real: 0x420f677d, - launceston_real: 0x32b1ab7b, - marrickville_real: 0x60148e37, - newcastle_real: 0xbb2c3050, - newtown_godverse: 0xcf69b387, - newtown_real: 0x741c18ec, - northbridge_real: 0x1a645848, + bendigo_real: 0x44f5ef2d, + bowral_real: 0x5574436b, + braddon_real: 0xf03da36c, + brunswick_real: 0xb548149e, + castlemaine_real: 0xf3118387, + darwin_real: 0xd45d7042, + daylesford_real: 0x96a92068, + fitzroy_real: 0x1cb08059, + fremantle_real: 0x17954f0a, + geelong_real: 0xf90f4463, + glebe_real: 0xa9418848, + hobart_real: 0xbcee1ccb, + katoomba_real: 0x126a66cb, + launceston_real: 0xa2bb4e71, + marrickville_real: 0x8c8d39aa, + newcastle_real: 0x61de3375, + newtown_godverse: 0x1e8d49b9, + newtown_real: 0x64a98d7e, + northbridge_real: 0xb0c24e12, // toowoomba_real RETIRED by E in R23 (cache deleted) — pin removed with it. A golden for a town that // cannot load is dead config: it can never fire, so it can never fail. Same species as the round's // vacuous-gate law, one layer down. (E tried the re-bbox first and it GAMED my spacing metric — median // NN 395.7 → 89.4 m "passes" while hub density fell 3/12 → 2/12, the pack's worst. Right call.) - redhill_godverse: 0xb2224939, - redhill_real: 0x6046700f, - westend_real: 0x5b1e5ffe, + redhill_godverse: 0x40d30a31, + redhill_real: 0x5b851696, + westend_real: 0x6032d73a, }; // ROUND20: the GIG-layer golden per real town (district + capped posters) — the base golden above is // gigs-off so it never captured the poster cap. Pinned here so the ROUND20 poster cap (and the density @@ -606,31 +632,31 @@ const REAL_TOWN_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. const REAL_TOWN_GIG_GOLDENS = { - adelaide_real: 0x24ad3c27, + adelaide_real: 0xda729bae, ballarat_real: 0xf097c331, - bendigo_real: 0x646b2c23, - bowral_real: 0xac809f0b, - braddon_real: 0xc3e5cfc0, - brunswick_real: 0x8c08914e, - castlemaine_real: 0xf9713a69, - darwin_real: 0xbc5605b5, - daylesford_real: 0x2fb34c8a, - fitzroy_real: 0x690e5ef9, - fremantle_real: 0xcf3426fc, - geelong_real: 0x6f47ec2f, - glebe_real: 0x042f01fb, - hobart_real: 0x22f80d7b, - katoomba_real: 0xbf586b77, - launceston_real: 0xeaf7ff4d, - marrickville_real: 0xea21be51, - newcastle_real: 0x66fc2bf9, - newtown_godverse: 0x1e266f60, - newtown_real: 0xacdb3eee, - northbridge_real: 0x83a1d5be, + bendigo_real: 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, + glebe_real: 0x8d2b12ca, + hobart_real: 0xda3211c9, + katoomba_real: 0x5eece2d4, + launceston_real: 0xb619b65b, + marrickville_real: 0x5dc00b5f, + newcastle_real: 0xfc3cb144, + newtown_godverse: 0x4153dee2, + newtown_real: 0xb6a75bb1, + northbridge_real: 0x49fe0f2b, // toowoomba_real RETIRED by E in R23 — see the note on the base map above. - redhill_godverse: 0xc6f80e18, - redhill_real: 0x673785ee, - westend_real: 0xb2c7ddb6, + redhill_godverse: 0xb53f695d, + redhill_real: 0x21db6d1, + westend_real: 0xac9e69ea, }; // `index.json` is E's towns INDEX (key/town/state/shops/roads for B's selector), not a town cache — skip it. const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json') && f !== 'index.json');