PROCITY/web/js/citygen/plan_osm.js
m3ultra f628bf581a Lane A R39 39.1: THE ADDRESS LAYER — 17,835 street names stop being dropped on the floor
The contract, published for B and C in LANE_A_NOTES §39 (first commit, per the half-day rule).

  createAddresses(plan, cache|null) → streetOf(edgeId) · localityOf(shopId) · cohort(pred)
                                     · streets() · stats()

New pure module web/js/citygen/address.js: ZERO imports, no THREE, no fetch, no DOM, no module
state, no plan mutation. Two suppliers, ONE consumer contract — a real town fills `label` from the
cache's named ways, the synthetic fills it from district.kind + block. Consumers must never branch
on town type; that constraint is the design.

MEASURED (Fable's binding facts re-derived independently, all three land exactly):
· 19,132 road ways, 17,835 named (93.2%)
· median plan-edge-sample → nearest named way 0.03–0.90 m (worst katoomba)
· 1,192 / 1,219 corpus shops resolve to a street name (97.8%); 29,865/30,986 edges named (96.4%)
· all 27 unresolved shops front a way OSM genuinely leaves unnamed (adelaide 21 arcade) — null is
  the honest answer and the module returns it rather than borrowing a neighbour's name

TWO DEPARTURES FROM THE SYNTHESIS, both with numbers:
· the shift is recovered EXACTLY (node↔waypoint lattice intersection), not voted for. The proposed
  centroid-align + nearest-waypoint vote returns the WRONG shift on 5 of 23 towns (braddon,
  fremantle, hobart, northbridge, westend) — island culling drags the plan centroid off the
  cache's. A wrong shift renames every street in the town. Exact on 23/23, nothing to tune.
· the resolver samples FIVE points along an edge, not the midpoint — a midpoint cannot tell a
  street from the street that crosses it.

THE ONE-LINE UPGRADE: TAKEN. plan_osm.js writes norm.shift = {shx,shz} into the normalization log,
reaching the caller only via the existing opts.report sink — never onto the plan. It is a
cross-check, not a dependency: selfcheck compares plan_osm's published shift against address.js's
independently recovered one and fires on a 0.01 m disagreement.

GATES (+240 checks), each proven to fire on a broken world:
· 0 wrong street names on 884 shop-bearing edges, against an INDEPENDENT way-membership resolver
  (857 agree, 0 disagree). Corpus-wide 4/30,986 disagree — all 2D-stacked ways, none carrying a
  shop — PINNED at 4, not asserted > 0.
· CONTROL: with roads[].name stripped (exactly the pre-change state) the layer resolves 0 streets.
· CONTROL: no cache ⇒ supplier 'district', visible in stats(), never a silent wrong name.
· createAddresses does not mutate the plan — asserted byte-for-byte, per town.
· tolerance 8 m, and the finding that sets it: correctness SATURATES AT 6 m (857 agreeing frontage
  names at 6, 8, 12, 16 and 24 m). Every metre past 6 buys only a name borrowed from an unnamed
  way; it can never buy a correction.

Additive: getTownCache(key) — index.html registers the cache and drops its reference, so roads[]
was unreachable downstream. Read-only, no new state, no shell change.

GOLDENS: NOTHING MOVED, ZERO RE-PINS. selfcheck 157,407 → 157,647 ALL GREEN, fingerprint 0x5f76e76.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:32:43 +10:00

546 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY CityGen — the OSM plan source (Lane A, round 6). Second producer behind the same
// CityPlan contract as plan.js, so Lanes BF consume it unchanged. Selected via ?plansrc=osm
// (default stays 'synthetic', byte-identical — see index.js `generatePlanFor`).
//
// Input: a CHECKED-IN fixture of real inner-Melbourne secondhand/charity/music/book/toy/antique
// shops (osm_fixture.js, extracted from thriftgod's Overpass cache). ZERO network at runtime.
//
// How real data drives the plan (documented simplification — the honest "cut"): a shop's real
// LATITUDE picks which eastwest avenue it lands on, and its real LONGITUDE sets its order along
// that avenue. Avenue spacing and per-lot frontage are regularized (marched) so the result is a
// clean, non-overlapping, valid CityPlan — the geography is real in row+order, stylized in metres.
// 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, 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
const r2 = v => Math.round(v * 100) / 100;
const r4 = v => Math.round(v * 10000) / 10000;
const clampHour = h => Math.max(0, Math.min(23, h | 0));
const LATE_HOUR = 22; // matches plan.js: exactly one shop closes ≥ this
const EARTH_M = 111320; // metres per degree latitude (equirectangular)
const ROWS = 8; // latitude bands → avenues
const ROW_DZ = 54; // metres between avenues (> avenue depth ⇒ blocks disjoint)
const AV_W = 12, SPINE_W = 24; // corridor widths
const SPINE_CLEAR = 16; // lots start this far east of the spine (no lot on the road)
const GAP = 2, DEPTH = 14; // lot gap + depth along/into the avenue
// Short signboard form from a real shop name: drop a leading "The", take words up to ~15 chars.
function signOf(name) {
const words = String(name).replace(/^the\s+/i, '').split(/\s+/);
let s = '';
for (const w of words) { if ((s + ' ' + w).trim().length > 15) break; s = (s + ' ' + w).trim(); }
return (s || String(name).slice(0, 15)).toUpperCase();
}
// ── the TOWN-CACHE CONTRACT (ROUND17 ledger #6, Lane A owns it) ─────────────────────────────────────
// What `plan_osm` accepts. E's pipeline (build_towns.py) produces one JSON per real town under
// web/assets/towns/<key>.json in THIS shape; plan_osm marches it exactly like the checked-in fixtures —
// the scout proves the EXISTING contract eats real data, no new plan fields, no new geometry.
//
// { schema:"procity-town-cache/1", key, town, source:"osm",
// license, attribution, // ODbL — REQUIRED on shipped real caches (+ SOURCES.md, E)
// center:{lat,lon}, // REQUIRED — equirectangular projection origin
// bbox?, counts?, fetchedAt?, // optional provenance/metadata (geometry ignores these)
// shops:[ { id, name, type, lat, lon, suburb? } ] } // REQUIRED, >= MIN_TOWN_SHOPS
//
// `type` SHOULD be a registry SHOP_TYPE; an unknown OSM kind remaps to 'opshop' (a warning, absorbed —
// same as the fixtures). A blank name defaults to the type label. `suburb` is optional.
export const TOWN_CACHE_SCHEMA = 'procity-town-cache/2'; // v2 (ROUND18) adds optional roads[]
const ACCEPTED_SCHEMAS = new Set(['procity-town-cache/1', 'procity-town-cache/2']); // v1 stays valid → marched
export const MIN_TOWN_SHOPS = 6; // functional floor: up to 4 venues + the one openLate landmark + a spare
export const MAX_TOWN_SPAN_M = 5000; // a cache should be ONE compact town, not a region (see R17 risk note)
// ── the spacing floor (ROUND23 ledger #5 — D's R22 thin-tail finding, made law) ──────────────────
// MIN_TOWN_SHOPS counts shops; what makes a town READ ALIVE is clustering. D measured both 12-shop
// towns in the pack and they disagree — that disagreement is the whole finding:
// darwin_real ALIVE — shops sit on one strip; 1455 patronage checks → 18 visits.
// toowoomba_real DEAD — shops scattered over a 4.6 km road network; 1417 checks → 0 visits, 0 finds.
// "Not a shopping town — a road network with occasional shops."
// Both pass MIN_TOWN_SHOPS. One has a high street; the other doesn't. So: WARN, never error — E curates
// (re-bbox or retire, counted either way), the validator only flags. A flagged cache still boots a valid
// district, which is why this can't be an error: toowoomba shipped green in the whole v4.0 pack.
export const MAX_MEDIAN_SPACING_M = 150;
// D's metric: for each shop, the distance to its NEAREST OTHER shop; take the median. Exported so every
// lane measures the same thing (the R13-debt-#5 pattern — publish the law as a function, not a paragraph
// each lane re-implements). Returns null for a cache with < 2 usable shops (nothing to space).
//
// SPACE WARNING — this is CACHE space (raw lat/lon), not PLAN space (post-lift metres), and the two do
// NOT agree. The lift marches shops onto real edges at a regularised cadence, so it COMPRESSES dense
// towns (darwin: 68.3 m cache → 27.2 m plan) while leaving sparse ones alone (toowoomba: 396.1 → 386.8).
// D's notes quote PLAN figures (27 / 388); this function on the same towns returns 68.3 / 396.1. Both are
// right — they measure different spaces. Cache space is the correct one HERE: the validator's job is to
// flag a bad bbox at ingest, before any lift exists, and it is faithful exactly where the warn lives
// (the sparse end: ballarat plan/cache 1.01, toowoomba 0.98). See LANE_A_NOTES §R23 for the full table.
export function medianShopSpacing(cache) {
const shops = (cache && Array.isArray(cache.shops) ? cache.shops : []).filter(s => s && isNum(s.lat) && isNum(s.lon));
if (shops.length < 2) return null;
const lat0 = (cache.center && isNum(cache.center.lat)) ? cache.center.lat : shops[0].lat;
const kx = 111320 * Math.cos(lat0 * Math.PI / 180), kz = 111320; // same equirectangular convention as the span check
const nn = shops.map(s => {
let best = Infinity;
for (const o of shops) {
if (o === s) continue;
best = Math.min(best, Math.hypot((s.lon - o.lon) * kx, (s.lat - o.lat) * kz));
}
return best;
});
nn.sort((a, b) => a - b);
const m = nn.length >> 1;
return nn.length % 2 ? nn[m] : (nn[m - 1] + nn[m]) / 2;
}
// ── the identity field (ROUND24 ledger #2, with Lane G) ──────────────────────────────────────────
// `shop.godverseShopId` — the OPTIONAL external key that says "this lot's stock lives at
// `stock_godverse/<godverseShopId>/`". Absent everywhere except godverse caches; schema stays v2
// (an optional field breaks no shipped cache, and the 22 osm caches are untouched by construction).
//
// IT IS NOT `shop.id`, AND IT IS NOT "THE CENSUS ID" — that assumption breaks on the one shop the
// v5 alpha is about. Two id spaces ride this field, and G verified they are disjoint:
// census shops → thriftgod `shop.id` (max 2992) — here godverseShopId does equal `id`
// Monster Robot → dealgod store 3962749 — NOT in thriftgod's census at all
// So it's an OPAQUE key: never derive it from `id`, never assume equality, never build a mapping
// table (the R12 gigKey lesson — one key, zero mapping; G emits it, everyone else reads it).
const GODVERSE_SOURCE = 'godverse+osm';
const isGodverseCache = cache => cache && cache.source === GODVERSE_SOURCE;
// ── roads[] (schema v2, ROUND18) ─────────────────────────────────────────────────────────────────
// OSM `highway=*` → CityPlan edge kind. The graph lift refines this with shop density (a `side` road
// dense with shops becomes the `main` spine). Anything unmapped defaults to 'side'.
export const ROAD_KIND = {
motorway: 'main', trunk: 'main', primary: 'main', secondary: 'main',
tertiary: 'side', unclassified: 'side', residential: 'side', living_street: 'side', road: 'side',
service: 'lane', track: 'lane',
pedestrian: 'arcade', footway: 'arcade', path: 'arcade', steps: 'arcade', cycleway: 'arcade',
};
export const roadEdgeKind = hwy => ROAD_KIND[hwy] || 'side';
// DouglasPeucker polyline simplification in projected metres (the road-fidelity knob, charter risk #4).
// Iterative (no recursion-depth risk on a long way). Keeps endpoints + any point > eps off the chord.
function simplifyDP(pts, eps) {
if (pts.length < 3) return pts.slice();
const keep = new Uint8Array(pts.length); keep[0] = keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
while (stack.length) {
const [i0, i1] = stack.pop();
const a = pts[i0], b = pts[i1], dx = b.x - a.x, dz = b.z - a.z, L = Math.hypot(dx, dz) || 1;
let far = -1, fd = eps;
for (let i = i0 + 1; i < i1; i++) {
const d = Math.abs((pts[i].x - a.x) * dz - (pts[i].z - a.z) * dx) / L; // perpendicular distance to the chord
if (d > fd) { fd = d; far = i; }
}
if (far >= 0) { keep[far] = 1; stack.push([i0, far], [far, i1]); }
}
return pts.filter((_, i) => keep[i]);
}
const isNum = v => typeof v === 'number' && Number.isFinite(v);
// Validate a cache against the contract. { ok, errors[], warnings[] } — errors mean plan_osm would not
// boot a valid district; warnings are absorbed (remapped type, defaulted name, missing licence).
export function validateTownCache(cache) {
const errors = [], warnings = [];
if (!cache || typeof cache !== 'object') return { ok: false, errors: ['cache is not an object'], warnings };
if (cache.schema && !ACCEPTED_SCHEMAS.has(cache.schema)) warnings.push(`unknown schema '${cache.schema}' (want one of ${[...ACCEPTED_SCHEMAS].join(', ')})`);
const c = cache.center;
if (!c || !isNum(c.lat) || !isNum(c.lon)) errors.push('center.{lat,lon} must be finite numbers');
if (!Array.isArray(cache.shops)) errors.push('shops must be an array');
else {
if (cache.shops.length < MIN_TOWN_SHOPS) errors.push(`>= ${MIN_TOWN_SHOPS} shops required (got ${cache.shops.length})`);
let badCoord = 0, blankName = 0, unknownType = 0, dupId = 0; const ids = new Set();
for (const s of cache.shops) {
if (!s || !isNum(s.lat) || !isNum(s.lon)) badCoord++;
if (!s || !(typeof s.name === 'string' && s.name.trim())) blankName++;
if (!s || !SHOP_TYPES[s.type]) unknownType++;
if (s) { if (ids.has(s.id)) dupId++; else ids.add(s.id); }
}
if (badCoord) errors.push(`${badCoord} shop(s) with non-finite lat/lon`);
if (blankName) warnings.push(`${blankName} blank shop name(s) → defaulted to type label`);
if (unknownType) warnings.push(`${unknownType} unknown shop type(s) → remapped to opshop`);
if (dupId) warnings.push(`${dupId} duplicate shop id(s)`);
// godverseShopId (ROUND24 ledger #2) — the stock identity. Split by what is actually a hazard:
// · MALFORMED → error. It names a path segment (`stock_godverse/<id>/`); a string "3962749" and
// the number 3962749 are the same folder but different JSON, and that coercion drift is exactly
// the species F caught in the atlas contract (licence/license). One truth: a positive integer.
// · DUPLICATE → error. Two shops keyed to one atlas IS mis-stocking — charter risk #3 says
// mismatches "fail-soft to tier 0, NEVER mis-stocked". This is the arm that earns its keep.
// · MISSING on a godverse cache → WARN, not error. See the note below — this is a deliberate
// departure from the round doc's "requires", and it is Fable's to overrule.
let badGid = 0, dupGid = 0, withGid = 0; const gids = new Set();
for (const s of cache.shops) {
if (!s || s.godverseShopId === undefined || s.godverseShopId === null) continue;
withGid++;
if (!Number.isSafeInteger(s.godverseShopId) || s.godverseShopId <= 0) { badGid++; continue; }
if (gids.has(s.godverseShopId)) dupGid++; else gids.add(s.godverseShopId);
}
if (badGid) errors.push(`${badGid} shop(s) with a malformed godverseShopId (want a positive integer — it is a path segment, not a label)`);
if (dupGid) errors.push(`${dupGid} duplicate godverseShopId(s) — two shops cannot share one stock atlas (mis-stock)`);
// RULING 2 (ROUND25, confirmed as law): a godverse cache is deliberately MIXED — a keyed GODVERSE
// layer (census shops + injected shops like Monster Robot) PLUS an inherited OSM texture layer that
// is legitimately unkeyed. G measured that the texture layer is load-bearing, not cosmetic:
// census-only Red Hill shipped a 306 m spacing warn and a poster inside a kerb; with the texture
// layer it passes. So "shops without an id" is the DESIGN, and my R24 warn (which counted every
// unkeyed shop as a gap) was asserting the design away — it fired 54/72 on a healthy town.
//
// What is still worth saying: a cache that CLAIMS godverse provenance but carries NO identity at all
// has no godverse layer — it is an osm cache wearing the wrong `source`. That is the only honest
// requirement left at this layer, and it's a warn for the R24 reasons that still hold (the charter's
// determinism boundary: a town must never fail to load because its STOCK identity is absent; tier 0
// is the designed ladder). Missing = tier 0 (soft), duplicate = mis-stocked (hard) — different arms.
if (isGodverseCache(cache) && withGid === 0) {
warnings.push(`'${GODVERSE_SOURCE}' cache carries no godverseShopId on any of ${cache.shops.length} shops — the GODVERSE layer is empty (every shop falls to tier 0; is this really a godverse cache?)`);
}
// wide-spread warning: an over-broad Overpass bbox marches into an unusable multi-km strip (the R17
// real-data risk). Metadata only — plan_osm still boots a valid plan; it just isn't a coherent town.
let mnLat = Infinity, mxLat = -Infinity, mnLon = Infinity, mxLon = -Infinity;
for (const s of cache.shops) if (s && isNum(s.lat) && isNum(s.lon)) {
mnLat = Math.min(mnLat, s.lat); mxLat = Math.max(mxLat, s.lat);
mnLon = Math.min(mnLon, s.lon); mxLon = Math.max(mxLon, s.lon);
}
if (mxLat > mnLat) {
const latM = (mxLat - mnLat) * 111320, lonM = (mxLon - mnLon) * 111320 * Math.cos((c && c.lat || 0) * Math.PI / 180);
if (Math.max(latM, lonM) > MAX_TOWN_SPAN_M) warnings.push(`shops span ${(Math.max(latM, lonM) / 1000).toFixed(1)} km — likely more than one town (bound the query tighter)`);
}
// thin-tail warning (ROUND23 ledger #5): the span check catches a town that's too WIDE; this catches
// one that's too SPARSE to read as a town at all — D's finding. Metadata only, like the span warn:
// plan_osm still boots a valid district, it just has no high street to walk.
const spacing = medianShopSpacing(cache);
if (spacing !== null && spacing > MAX_MEDIAN_SPACING_M) {
warnings.push(`median shop spacing ${Math.round(spacing)} m > ${MAX_MEDIAN_SPACING_M} m — shops are scattered, not a high street (re-bbox onto the retail strip, or retire the town)`);
}
}
// v2: optional roads[] — simplified OSM ways. Each is `{ kind, pts:[[lat,lon],…] }` (id/name optional);
// `kind` is the OSM highway=* class. Absent (or a v1 cache) ⇒ the marched-avenue fallback, so nothing
// shipped regresses. A road needs ≥2 finite points; <2 is dropped, an unmapped kind falls to 'side'.
if (cache.roads !== undefined) {
if (!Array.isArray(cache.roads)) errors.push('roads must be an array (when present)');
else {
let badRoad = 0, shortRoad = 0, unknownKind = 0;
for (const rd of cache.roads) {
if (!rd || typeof rd.kind !== 'string' || !Array.isArray(rd.pts)) { badRoad++; continue; }
if (rd.pts.length < 2) { shortRoad++; continue; }
if (rd.pts.some(p => !Array.isArray(p) || !isNum(p[0]) || !isNum(p[1]))) badRoad++;
if (!ROAD_KIND[rd.kind]) unknownKind++;
}
if (badRoad) errors.push(`${badRoad} malformed road(s) — need { kind:string, pts:[[lat,lon],…] }`);
if (shortRoad) warnings.push(`${shortRoad} road(s) with < 2 points → dropped`);
if (unknownKind) warnings.push(`${unknownKind} road(s) with an unmapped highway kind → 'side'`);
}
}
if (!cache.license || !cache.attribution) warnings.push('missing license/attribution (ODbL required for shipped real caches)');
return { ok: errors.length === 0, errors, warnings };
}
// A runtime registry of validated town caches (E's real caches; the selfcheck + shell register them).
// generatePlanOSM resolves a town from opts.cache, then this registry, then the checked-in fixtures.
const TOWN_CACHES = {};
export function registerTownCache(key, cache) {
const v = validateTownCache(cache);
if (!v.ok) throw new Error(`town cache '${key}' invalid: ${v.errors.join('; ')}`);
TOWN_CACHES[key] = cache;
return v;
}
// ROUND39 (39.1): read back a registered cache. Additive, read-only, no new state — the registry
// already held it. `web/index.html:129` hands the fetched JSON to `registerTownCache` and drops its own
// reference, so until now the roads[] (and their 17,835 names) were unreachable to anything downstream.
// `address.js` needs the cache alongside the plan; this is how a consumer gets it without re-fetching:
// const cache = citygen.getTownCache(TOWN); const addr = citygen.createAddresses(plan, cache);
// Returns undefined for an unknown key (and for the checked-in `osm_fixture.js` towns, which are not
// caches and carry no roads) — `createAddresses(plan, undefined)` is legal and degrades to district
// labels, so check `addr.stats().supplier === 'ways'` if you require real street names.
export function getTownCache(key) { return TOWN_CACHES[key]; }
export function generatePlanOSM(citySeed, town = DEFAULT_TOWN, opts = {}) {
citySeed = (citySeed >>> 0);
const fx = opts.cache || TOWN_CACHES[town] || OSM_TOWNS[town] || OSM_TOWNS[DEFAULT_TOWN];
// Normalization log: what the importer had to bend to fit the CityPlan contracts (real OSM data
// "doesn't bend the contract"). Surfaced via opts.report (selfcheck prints it) — never on the plan.
const norm = { town: fx.town, source: 'osm', shops: fx.shops.length, typesRemapped: 0, hoursClamped: 0, openLate: 'video' };
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
const addNode = (x, z) => { const n = { id: nid++, x: r2(x), z: r2(z) }; nodes.push(n); return n; };
const addEdge = (a, b, width, kind) => { const id = eid++; edges.push({ id, a, b, width, kind }); return id; };
const addDistrict = (kind, cx, cz) => { const id = did++; districts.push({ id, kind, cx: r2(cx), cz: r2(cz) }); return id; };
const addBlock = (district, kind, poly) => { const id = bid++; blocks.push({ id, district, kind, poly: poly.map(p => [r2(p[0]), r2(p[1])]) }); return id; };
const addLot = (block, x, z, w, d, ry, frontEdge, use) => {
const id = lid++;
lots.push({ id, block, x: r2(x), z: r2(z), w: r2(w), d: r2(d), ry: r4(ry), frontEdge, use });
return id;
};
// Create a shop record from a real fixture entry (real name; seeded facade/hours/storeys).
function addShop(lotId, fixtureShop) {
const id = sid++;
const type = SHOP_TYPES[fixtureShop.type] ? fixtureShop.type : 'opshop';
if (type !== fixtureShop.type) norm.typesRemapped++; // unknown OSM kind → opshop (normalization)
const reg = SHOP_TYPES[type];
const seed = seedFor(citySeed, 'osmshop', fixtureShop.id);
const sr = mulberry32(seed);
const facadeSkin = pick(sr, reg.facades);
const storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
let open = reg.hours.open, close = reg.hours.close;
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
if (close <= open) close = clampHour(open + 2);
if (close > LATE_HOUR - 1) norm.hoursClamped++;
close = Math.min(close, LATE_HOUR - 1); // only the openLate landmark reaches ≥ LATE_HOUR
// real OSM data has the odd unnamed shop — default a blank name to the registry label so the sign
// never renders "undefined" (build_towns.py filters most, but plan_osm must not trust its input).
const nm = (typeof fixtureShop.name === 'string' && fixtureShop.name.trim()) ? fixtureShop.name : (reg.label || type);
if (nm !== fixtureShop.name) norm.namesDefaulted = (norm.namesDefaulted || 0) + 1;
const shop = { id, lot: lotId, type, name: nm, sign: signOf(nm),
seed, facadeSkin, storeys, hours: [open, close] };
// ROUND24 ledger #2: carry the stock identity THROUGH the lift. Without this the field would be a
// cache-only decoration — the runtime reads `plan.shops[]`, never the cache, so F's per-shop `base`
// (`stock_godverse/<godverseShopId>/`) could never resolve. Conditional by design: absent ⇒ the key
// is not written ⇒ `JSON.stringify` is byte-identical ⇒ every pinned town golden holds today, and
// the field starts flowing the moment G re-emits (that re-emit IS the sanctioned golden move).
if (Number.isSafeInteger(fixtureShop.godverseShopId) && fixtureShop.godverseShopId > 0) {
shop.godverseShopId = fixtureShop.godverseShopId;
}
shops.push(shop);
return id;
}
// ── projection to local metres (equirectangular about the cache centre) ──
const cosLat = Math.cos(fx.center.lat * Math.PI / 180);
const projX = lon => (lon - fx.center.lon) * EARTH_M * cosLat;
const projZ = lat => (lat - fx.center.lat) * EARTH_M;
const footOf = t => { const fp = SHOP_TYPES[t]?.footprint || { fmin: 7, fmax: 10 }; return (fp.fmin + fp.fmax) / 2; };
const district = addDistrict('mainstreet', 0, 0);
const roads = Array.isArray(fx.roads) ? fx.roads : [];
// v2 cache with roads[] ⇒ the real street graph; else the v1 marched fallback (SAME CityPlan out).
if (roads.length) { norm.mode = 'roads'; buildRealRoads(); } else { norm.mode = 'marched'; buildMarched(); }
// ── v1 marched fallback: bin shops by latitude into synthetic avenues off one S→N spine ──
function buildMarched() {
const pts = fx.shops.map(s => ({ s, x: projX(s.lon), z: projZ(s.lat) }));
const zMin = Math.min(...pts.map(p => p.z)), zMax = Math.max(...pts.map(p => p.z));
const span = (zMax - zMin) || 1;
const rowsArr = Array.from({ length: ROWS }, () => []);
for (const p of pts) rowsArr[Math.min(ROWS - 1, Math.max(0, Math.floor((p.z - zMin) / span * ROWS)))].push(p);
const spineNodes = [];
const usedRows = rowsArr.map((r, i) => ({ r, i })).filter(o => o.r.length);
usedRows.forEach((o, k) => { o.zPos = (k - (usedRows.length - 1) / 2) * ROW_DZ; spineNodes.push(addNode(0, o.zPos)); });
for (let k = 0; k < spineNodes.length - 1; k++) addEdge(spineNodes[k].id, spineNodes[k + 1].id, SPINE_W, 'main');
usedRows.forEach((o) => {
const rowShops = o.r.slice().sort((a, b) => a.x - b.x || a.s.id - b.s.id); // real longitude order, id tiebreak
const zPos = o.zPos, fr = rowShops.map(p => footOf(p.s.type));
const avLen = SPINE_CLEAR + fr.reduce((s, f) => s + f + GAP, 0) + 6;
const avEdge = addEdge(addNode(0, zPos).id, addNode(avLen, zPos).id, AV_W, 'side');
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]]);
// 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; });
});
}
// ── v4 REAL ROADS (schema v2): the CityPlan street graph FROM real OSM ways; shops on real streets ──
// project → simplify (DouglasPeucker) → snap coincident points to shared nodes (= real intersections)
// → straight-segment edges → classify main by shop density → seat each shop by marching along its
// NEAREST real edge in real order/real side (regularised spacing + an overlap-resolve pass ⇒ the
// stringent structuralSuite still holds). Fidelity is the alpha knob (charter risk #4): real edge,
// real order, real side, regularised spacing — not pixel positions (real shops overlap).
function buildRealRoads() {
// ROUND20 capacity (the density widening load-tested the R18 overflow fence): at 36× shops the
// dominant drop cause was OVERFLOW — more shops assign to an edge-side than fit. Widened by tightening
// the run: NODE_CLEAR 6→3 and a roads-only RGAP 2→0.5 (the shared marched `GAP` is untouched, so the
// marched fixtures stay frozen). 0.5 m also reads truer — a real AU main street is continuous terrace
// 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
// accounting stays honest as real towns widen (ROUND20).
const drop = (why, n = 1) => { norm.dropped = (norm.dropped || 0) + n; norm['dropped_' + why] = (norm['dropped_' + why] || 0) + n; };
const skey = p => `${Math.round(p.x / SNAP)},${Math.round(p.z / SNAP)}`;
const rawWays = [];
for (const rd of roads) {
if (!rd || !Array.isArray(rd.pts) || rd.pts.length < 2) continue;
const pl = rd.pts.filter(p => Array.isArray(p) && isNum(p[0]) && isNum(p[1])).map(([lat, lon]) => ({ x: projX(lon), z: projZ(lat) }));
if (pl.length >= 2) rawWays.push({ kind: roadEdgeKind(rd.kind), pl });
}
// ROUND19: junctions = points shared by ≥2 ways. PROTECT them from DouglasPeucker — simplify only
// BETWEEN junctions, never through one. The collinear junction-drop (a straight way losing the point
// where a side street tees in) is what fragmented the graph into 105 components (D + B, R18).
const keyWays = new Map();
rawWays.forEach((w, wi) => { for (const p of w.pl) { const k = skey(p); (keyWays.get(k) || keyWays.set(k, new Set()).get(k)).add(wi); } });
const isJct = k => (keyWays.get(k)?.size || 0) >= 2;
const ways = [];
for (const w of rawWays) {
const brk = [0]; for (let i = 1; i < w.pl.length - 1; i++) if (isJct(skey(w.pl[i]))) brk.push(i); brk.push(w.pl.length - 1);
let out = [];
for (let b = 0; b + 1 < brk.length; b++) { const s = simplifyDP(w.pl.slice(brk[b], brk[b + 1] + 1), EPS); out = out.concat(b === 0 ? s : s.slice(1)); }
if (out.length >= 2) ways.push({ kind: w.kind, pl: out });
}
// ── build a CANDIDATE graph (local ids), resolve fragmentation, THEN commit the survivors ──────────
// ROUND19 ruling (Fable): the town is the main component + joined near-fragments; a far SHOPLESS island
// is bbox-clipped noise + dead streaming budget, so it's CULLED. A shop-bearing island is bridged so no
// shop strands; a shop we still can't reach drops with a count. (Committing after resolution is what
// lets us cull — addEdge can't un-add.)
const cnodes = [], cnodeAt = new Map();
const cnode = p => { const k = skey(p); let id = cnodeAt.get(k); if (id === undefined) { id = cnodes.length; cnodes.push({ x: p.x, z: p.z }); cnodeAt.set(k, id); } return id; };
const cedges = [], eseen = new Set();
for (const w of ways) for (let i = 0; i + 1 < w.pl.length; i++) {
const a = cnode(w.pl[i]), b = cnode(w.pl[i + 1]);
if (a === b) continue;
const ek = `${Math.min(a, b)}-${Math.max(a, b)}`; if (eseen.has(ek)) continue; eseen.add(ek);
if (Math.hypot(cnodes[a].x - cnodes[b].x, cnodes[a].z - cnodes[b].z) < 3) continue;
cedges.push({ a, b, kind: w.kind });
}
if (!cedges.length) { buildMarched(); norm.mode = 'roads→marched (no usable ways)'; return; }
const par = cnodes.map((_, i) => i); const find = x => { while (par[x] !== x) x = par[x] = par[par[x]]; return x; };
const clen = (a, b) => Math.hypot(cnodes[a].x - cnodes[b].x, cnodes[a].z - cnodes[b].z);
for (const e of cedges) par[find(e.a)] = find(e.b);
const compLen = new Map(); for (const e of cedges) { const r = find(e.a); compLen.set(r, (compLen.get(r) || 0) + clen(e.a, e.b)); }
let mainRoot = -1, mainLen = -1; for (const [r, L] of compLen) if (L > mainLen) { mainLen = L; mainRoot = r; }
const nearestC = (px, pz) => { let be = -1, bd = Infinity; for (let i = 0; i < cedges.length; i++) { const e = cedges[i], A = cnodes[e.a], B = cnodes[e.b], dx = B.x - A.x, dz = B.z - A.z, L2 = dx * dx + dz * dz || 1; let t = ((px - A.x) * dx + (pz - A.z) * dz) / L2; t = Math.max(0, Math.min(1, t)); const d = Math.hypot(px - (A.x + dx * t), pz - (A.z + dz * t)); if (d < bd) { bd = d; be = i; } } return be; };
const shopRoot = new Set();
for (const s of fx.shops) { const ei = nearestC(projX(s.lon), projZ(s.lat)); if (ei >= 0) shopRoot.add(find(cedges[ei].a)); }
const rootNodes = new Map(); cnodes.forEach((_, i) => { const r = find(i); (rootNodes.get(r) || rootNodes.set(r, []).get(r)).push(i); });
const inMain = i => find(i) === find(mainRoot);
const connectors = []; let culled = 0;
for (const r of [...compLen.keys()].filter(r => r !== mainRoot).sort((a, b) => (compLen.get(b) || 0) - (compLen.get(a) || 0))) {
if (inMain(r)) continue; // already merged by an earlier bridge
const grp = rootNodes.get(r) || [], shop = shopRoot.has(find(grp[0]));
let ga = -1, gb = -1, bd = Infinity; // nearest (fragment node, main-net node) pair
for (const i of grp) for (let j = 0; j < cnodes.length; j++) { if (!inMain(j)) continue; const d = clen(i, j); if (d < bd) { bd = d; ga = i; gb = j; } }
if (bd <= JOIN_TOL || (shop && bd <= BRIDGE_MAX)) { connectors.push([ga, gb]); par[find(ga)] = find(gb); }
else culled++; // shopless (or unreachable-shop) far island → cull
}
const nodeMap = new Map();
const putNode = ci => { let n = nodeMap.get(ci); if (!n) { n = addNode(cnodes[ci].x, cnodes[ci].z); nodeMap.set(ci, n); } return n; };
const realEdges = [];
for (const e of cedges) { if (!inMain(e.a)) continue; const na = putNode(e.a), nb = putNode(e.b); realEdges.push({ id: addEdge(na.id, nb.id, WKIND[e.kind] || AV_W, e.kind), a: na, b: nb, kind: e.kind }); }
for (const [a, b] of connectors) { const na = putNode(a), nb = putNode(b); realEdges.push({ id: addEdge(na.id, nb.id, WKIND.lane, 'lane'), a: na, b: nb, kind: 'lane' }); }
norm.components = compLen.size; norm.culledIslands = culled; norm.bridges = connectors.length;
if (!realEdges.length) { buildMarched(); norm.mode = 'roads→marched (no usable ways)'; return; }
const nearest = (px, pz) => {
let best = null, bd = Infinity;
for (const e of realEdges) {
const dx = e.b.x - e.a.x, dz = e.b.z - e.a.z, L2 = dx * dx + dz * dz || 1;
let t = ((px - e.a.x) * dx + (pz - e.a.z) * dz) / L2; t = Math.max(0, Math.min(1, t));
const d = Math.hypot(px - (e.a.x + dx * t), pz - (e.a.z + dz * t));
if (d < bd) { bd = d; best = { e, t, d, side: (dx * (pz - e.a.z) - dz * (px - e.a.x)) >= 0 ? 1 : -1 }; }
}
return best;
};
const perEdge = new Map();
for (const s of fx.shops) {
const a = nearest(projX(s.lon), projZ(s.lat));
if (!a || a.d > SEAT_MAX) { drop('stranded'); continue; } // stranded past bridging → drop + COUNT (ruling)
const key = a.e.id * 2 + (a.side > 0 ? 1 : 0);
(perEdge.get(key) || perEdge.set(key, { e: a.e, side: a.side, list: [] }).get(key)).list.push({ s, t: a.t });
}
if (!realEdges.some(e => e.kind === 'main')) { // classify main from shop density if none from OSM
const cnt = new Map(); for (const { e, list } of perEdge.values()) cnt.set(e.id, (cnt.get(e.id) || 0) + list.length);
let top = null, tc = -1; for (const e of realEdges) { const c = cnt.get(e.id) || 0; if (c > tc) { tc = c; top = e; } }
if (top) { const pe = edges.find(x => x.id === top.id); pe.kind = 'main'; pe.width = SPINE_W; }
}
const cands = [];
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;
// 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)]]);
let along = NODE_CLEAR;
for (let k = 0; k < list.length; k++) {
const s = list[k].s, f = footOf(SHOP_TYPES[s.type] ? s.type : 'opshop');
if (along + f > len - NODE_CLEAR) { drop('overflow', list.length - k); break; } // edge-side full — overflow COUNTED
cands.push({ block, x: e.a.x + ux * (along + f / 2) + nx * (off + dep / 2), z: e.a.z + uz * (along + f / 2) + nz * (off + dep / 2), w: f, d: dep, ry, frontEdge: e.id, s });
along += f + RGAP;
}
}
cands.sort((p, q) => p.s.id - q.s.id); // real streets crowd at intersections → deterministic overlap-resolve
const placed = [];
for (const c of cands) {
const corners = lotCorners(c);
if (placed.some(p => obbOverlap(p.corners, corners))) { drop('overlap'); continue; }
placed.push({ corners, c });
}
for (const { c } of placed) addShop(addLot(c.block, c.x, c.z, c.w, c.d, c.ry, c.frontEdge, 'shop'), c.s);
}
// ── exactly one late-night landmark: a video rental, closes ≥ LATE_HOUR (matches synthetic) ──
if (shops.length) {
const videos = shops.filter(s => s.type === 'video');
const pool = videos.length ? videos : shops.filter(s => s.type !== 'stall');
norm.openLate = videos.length ? 'video' : (pool.length ? pool[0].type + ' (no video in town)' : 'none');
if (pool.length) {
const lr = rng(citySeed, 'osm-openlate', 0);
const chosen = pick(lr, pool);
chosen.openLate = true;
chosen.hours = [chosen.hours[0], LATE_HOUR + ((lr() * 2) | 0)];
}
}
// Centre the imported town on the origin and report its true bounding box as `size` (rigid
// translation ⇒ all invariants preserved; gives the map a framed view and Lane B the real extent).
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
for (const l of lots) {
minx = Math.min(minx, l.x - l.w); maxx = Math.max(maxx, l.x + l.w);
minz = Math.min(minz, l.z - l.d); maxz = Math.max(maxz, l.z + l.d);
}
for (const n of nodes) { minx = Math.min(minx, n.x); maxx = Math.max(maxx, n.x); minz = Math.min(minz, n.z); maxz = Math.max(maxz, n.z); }
const shx = -r2((minx + maxx) / 2), shz = -r2((minz + maxz) / 2);
for (const n of nodes) { n.x = r2(n.x + shx); n.z = r2(n.z + shz); }
for (const l of lots) { l.x = r2(l.x + shx); l.z = r2(l.z + shz); }
for (const b of blocks) b.poly = b.poly.map(p => [r2(p[0] + shx), r2(p[1] + shz)]);
for (const d of districts) { d.cx = r2(d.cx + shx); d.cz = r2(d.cz + shz); }
const M = 40;
const size = { w: Math.max(256, Math.ceil((maxx - minx) + 2 * M)), d: Math.max(256, Math.ceil((maxz - minz) + 2 * M)) };
// ROUND39 (39.1, THE ADDRESS LAYER): publish the centring translation into the NORMALIZATION LOG.
// `address.js` needs it to map a plan node back onto the cache's projected way points (plan space =
// projected space + shift). It goes on `norm` — i.e. into the CALLER-SUPPLIED `opts.report` sink at
// the line below — and NEVER onto the plan object, so `JSON.stringify(plan)` is byte-identical and
// every pinned golden (synthetic, osm, 23 real towns, gig) is frozen by construction. Recovery works
// without it (address.js votes for the shift from the node↔waypoint correspondence and gets the same
// number on all 23 caches), so this is an accelerator and a cross-check, never a dependency.
norm.shift = { shx, shz };
if (opts.report) Object.assign(opts.report, norm); // caller-supplied log sink (selfcheck prints it)
return {
version: 1,
citySeed,
source: 'osm', // town key stays OFF the plan (keeps the Melbourne golden frozen); the
name: fx.town || 'Melbourne', // display name + the selector arg convey which town this is.
size,
districts,
streets: { nodes, edges },
blocks,
lots,
shops,
};
}
// The catalogue of available OSM town keys (for the shell/map selector + selfcheck iteration).
export function osmTownKeys() { return [...new Set([...Object.keys(OSM_TOWNS), ...Object.keys(TOWN_CACHES)])]; }