PROCITY/web/js/citygen/plan_osm.js
m3ultra a493c0a13c Lane A round 25 (v5.0-alpha): the sweep reconciled with Ruling 2 + the six pins (ledger #1)
All 8 tree reds cleared. selfcheck ALL GREEN 156,211/156,211; scaffold + consistency green; classic
0x3fa36874 and gig 0xb1d48ea1 frozen. F: the tag is unblocked from my side, nothing of mine is pending.

SIX OF THE EIGHT REDS WERE MY OWN GATE ASSERTING RULING 2 AWAY. I built the R24 identity arm on an
assumption that was true when I wrote it and false the moment G shipped: that a godverse cache is ALL
census shops. Ruling 2 makes it MIXED -- a keyed GODVERSE layer plus an inherited OSM texture layer that
G measured to be load-bearing, not cosmetic (census-only Red Hill: 306 m spacing warn + a poster inside a
kerb). So newtown_godverse (18 keyed + 54 texture) and redhill_godverse (10 + 27) were HEALTHY TOWNS MY
GATE CALLED BROKEN -- it fired 54/72 on a good town. Keyed asserts now run PER-LAYER over the keyed subset
only; unkeyed texture shops raise nothing. The validator's "N/M missing" warn dies with it. The one honest
requirement left at that layer: a `godverse+osm` cache must have a NON-EMPTY godverse layer (else it's an
osm cache wearing the wrong `source`) -- still a warn, for the R24 charter reasons that still hold.

THE UNIQUENESS CHECK WAS BROKEN IN BOTH DIRECTIONS. `new Set(all ids).size === shops.length` collapses
every `undefined` into ONE Set entry, so it (a) failed structurally on any mixed cache -- 54 texture shops
became one entry -- and (b) could HIDE A REAL DUPLICATE behind that same collapse. Now over DEFINED ids
only. And per the vacuous-gate law I made it prove it bites rather than asserting that it does:
validateTownCache rejects duplicates at the front door, so a dup can never reach the plan that way -- the
test feeds one STRAIGHT TO THE LIFT (generatePlanOSM does not validate) and asserts the predicate catches
it, with a healthy mixed cache passing the same predicate. A check nobody can watch fail is not a check.

NEW -- THE ORPHANED-ATLAS GATE (my own R24 finding, now enforced instead of filed). The lift legitimately
drops shops (overlap-resolve, counted since R19). Dropping a shop that HAS a committed atlas means real
stock keyed to a shop that isn't in the town -- exactly the failure I filed in R24, and one F's #7b could
only catch in a browser at the end of a round. Now a node gate: for each godverse town, every
stock_godverse/<id>/ on disk belonging to that cache MUST be seated, else FAIL. Subject-aware -- it SKIPs
by name when a town has no atlas, rather than passing quietly.

It already found one, benign today: redhill's lift drops keyed shop 2286 "Empire Revival" (10 keyed in
cache -> 9 seated). No atlas, so nothing is orphaned and the gate passes -- but it prints, because a drop
must never be silent (the R19/R20 ruling). G: stocking Empire Revival at the beta would orphan it. Monster
Robot Party is seated (lot 8), which is the one that matters this round.

THE SIX PINS, cross-checked two ways before landing -- byte-identical across two fresh processes, AND
matching the three values G stated independently in 86e2985:
  newtown_godverse  base 0xcf69b387  gig 0x1e266f60   (moved by G's keyed re-emit)
  redhill_real      base 0x6046700f  gig 0x673785ee
  redhill_godverse  base 0xb2224939  gig 0xc6f80e18
No other golden moved -- the diff is exactly these six lines.

Two lines still print; both deliberate, neither pending:
  ⊘ SKIP newtown_godverse orphaned-atlas -- correct, the only committed atlas (3962749) keys to Red Hill.
  ⚠ redhill_godverse: 1 keyed shop dropped -- the honest disclosure above.

Committed strictly by pathspec: F's six staged files sit untouched, exactly as its session left them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:23:57 +10:00

513 lines
36 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 } 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;
}
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]]);
const ry = Math.atan2(0, -1); // facade 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 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;
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;
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)) };
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)])]; }