From c1e862cf7c8bc49cd6d683444a2018b691864596 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 16:28:20 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20A=20R27=20wave=205:=20THE=20FACADE=20FIX?= =?UTF-8?q?=20=E2=80=94=20it=20was=20every=20town,=20not=20just=20the=20re?= =?UTF-8?q?al=20ones=20(the=20covenant=20amended)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selfcheck ALL GREEN 156,352/156,352; consistency green. 51 goldens re-pinned in this one commit per the amendment law. scaffold_check is RED and CANNOT be green from here — the covenant hashes are duplicated in LANE F's files; exact values handed to F in LANE_A_NOTES §R27w5 (I don't edit other lanes' files). THE RULING'S PREMISE WAS WRONG, AND THE WRONG HALF WAS LOAD-BEARING. Wave 5 says "synthetic follows B's canon so every golden has been green over a wrong product since R18". Half right. Synthetic does NOT follow B's canon -- it is broken identically, and so is the marched path, and so are all three checked-in fixtures. It is not "every real town since R18". It is EVERY TOWN, EVERY PATH, SINCE v1. The ruling told me to measure the marched path rather than assume it was clean "because that's how this one survived nine rounds" -- so I measured all three, and the same discipline caught the premise itself. MEASURED FIRST, IN B'S CANON (+Z = the facade), BEFORE TOUCHING ANYTHING: synthetic seed 20261990 0 toward / 681 away (dot = -1.0000, exactly anti-parallel) marched katoomba/fremantle/godverse, fixtures melbourne/katoomba/silverton 0 toward, all away real-roads katoomba 0/72 <- B's number, reproduced 637 of 681 synthetic lots had their facade pointing at NO STREET AT ALL (44 hit a different street by corner coincidence). Then I confirmed it with my own eyes rather than trust the arithmetic: booted ?classic=1, stood on the synthetic main street at lot 0 -> three blank grey boxes. Walked BEHIND them into the block -> NUMBAT PLAYTHINGS, sign/door/windows/awning, facing the dirt. The classic covenant town has been a row of back walls since v1. After the fix, same camera, same seed, same ?classic=1: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS -- a strip of shopfronts facing the road. FOUR SITES, ONE SIGN, AND EVERY ONE OF THEM SAID SO IN ITS OWN COMMENT: plan.js:122 synthetic strip "facade faces back down the outward normal, to the street" <- it didn't plan.js:244 market row "the facade then faces -(outward) = +x" <- it didn't plan_osm:330 marched avenue "facade faces +z (north) to the avenue" <- due south plan_osm:443 real roads B's proven one-liner (B measured it, then reverted it -- A's file) All four were atan2(outward) where the intent was atan2(-outward). The code never did what its comment said, and CITY_SPEC:71 blessed the result by writing the SIM convention into the LOTS contract. Nobody was wrong; the spec was. After: synthetic 681/681, marched + fixtures 100%, real-roads katoomba 72/72. THE COVENANT MOVED -- JOHN RULED IT, I DIDN'T. Fixing synthetic necessarily moves 0x3fa36874, frozen "forever" since R16. That is not Lane A's call, so I stopped and asked with the evidence. John's ruling: AMEND THE COVENANT, FIX ALL PATHS -- the covenant is anti-drift machinery, not a defect preservative. 51 pins in this commit: classic/synthetic 0x3fa36874 -> 0x5f76e76 gig 0xb1d48ea1 -> 0xec7a2d39 fixtures melbourne 0x9c7e76b3 · katoomba 0xf3aafec8 · silverton 0x6d74c4 23 real towns base + gig (46) Verified byte-identical across two fresh processes; pack-xor 0xa5058322. CITY_SPEC records the amendment 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 -- 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 clearance, 6 real failures). Blocks now seat behind the kerb per A's own published 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 this gate restates nothing. It replicates LANE B'S OWN 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. CITY_SPEC amendments: the lots contract now states facade/door/frontage = local +Z; the SIM/rig convention is documented separately as the different thing it is, with the explicit rule -- never assert a lot's facing against a spec line, assert it against the geometry the renderer draws. Co-Authored-By: Claude Opus 4.8 --- docs/A-progress.md | 37 +++++++++- docs/CITY_SPEC.md | 48 +++++++++++-- docs/LANES/LANE_A_NOTES.md | 62 +++++++++++++++++ web/js/citygen/plan.js | 19 ++++-- web/js/citygen/plan_osm.js | 23 +++++-- web/js/citygen/selfcheck.js | 132 +++++++++++++++++++++--------------- 6 files changed, 252 insertions(+), 69 deletions(-) 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');