PROCITY/web/js/citygen/plan_osm.js
m3ultra 1eb0b45bd0 Lane A round 24 (v5.0-alpha): the identity field — godverseShopId, published first (ledger #2)
The optional shop key that resolves `stock_godverse/<godverseShopId>/`. Schema STAYS v2 (optional field ⇒
every shipped cache still valid, the 21 osm caches untouched by construction). Published to
web/assets/towns/README.md — the doc G and E build to — plus CITY_SPEC. selfcheck ALL GREEN (+15 checks),
synthetic 0x3fa36874 frozen, NO golden moved.

THE BRIEF'S "THE POS SHOP ID IS THE ID — NO MAPPING TABLE" IS FALSE FOR THE ONE SHOP THIS ALPHA IS ABOUT.
G's own docstring says it: Monster Robot Party is NOT in thriftgod's census, so its id is its DEALGOD STORE
ID 3962749, while census shops use thriftgod ids (max 2992) -- two disjoint spaces, deliberately kept clear
of each other. Had I coded "godverseShopId == shop.id" or derived it from the census, the headline shop
could never be keyed. So it is an OPAQUE key: no derivation from `id`, no equality assert, no mapping table
(the R12 gigKey law -- one key, zero mapping; G emits, everyone reads).

THE FIELD HAD TO SURVIVE THE LIFT OR IT WAS DECORATION. plan_osm builds a FRESH shop object and copies
nothing unknown, and the runtime reads plan.shops[], never the cache -- so a cache-only field could never
reach F's per-shop base. It's now carried through, CONDITIONALLY: absent ⇒ the key isn't written ⇒
JSON.stringify byte-identical ⇒ every pinned golden holds until G re-emits (that re-emit IS the sanctioned
move). Proven end-to-end: a keyed cache puts `stock_godverse/3962749/` on the plan with census ids
(101..107) coexisting alongside it.

ARMS SPLIT BY HAZARD, NOT TIDINESS:
- malformed  -> ERROR. It names a path segment; "3962749" vs 3962749 is the same folder but different JSON,
  and that coercion drift is the exact species that broke C's atlas contract (licence/license).
- duplicate  -> ERROR. Two shops keyed to one atlas IS mis-stocking -- charter risk #3 forbids it by name.
- missing on a godverse cache -> WARN + fail-soft to tier 0.

RECORDED DEPARTURE (Fable's to overrule): ledger #2 says validateTownCache "requires" the field; I made
MISSING a warn. Three reasons. (1) The charter's determinism boundary is "world = seeded, frozen, gated;
stock = data tiers" -- an error inverts it, failing to load a TOWN because its STOCK identity is absent;
tier 0 must always boot. (2) Charter risk #3 names the remedy for identity gaps: "fail-soft to tier 0,
never mis-stocked" -- missing IS the soft case, duplicate is the hard one; they are different failures and
now get different arms. (3) Sequencing: newtown_godverse carries 0 ids today, it is G's to re-emit in wave
2 (their namespace, no census DB on m3), so a hard error would red the tree for C and E through all of wave
1 to say what the warn already says.
The requirement keeps its teeth where R23 taught us to put them -- the VACUOUS-GATE LAW: selfcheck FAILS on
partial keying (a half-keyed town silently mis-stocks the rest) and prints an explicit
"⊘ SKIP ... 0/18 shops carry godverseShopId. Subject absent ... binds the moment one id lands" at zero. It
names its subject, proves it touched it with a count, and cannot pass vacuously.

FILED FOR G BEFORE THEY START (measured in wave 1, so it costs them minutes not a wave): redhill_godverse
cannot contain Monster Robot from EITHER source. godverse_town.py takes roads from the OSM donor and shops
from the census, and the shop is in neither -- not in the census (G's R23 finding), and NOT in E's
redhill_real, which I checked the moment it landed: 36 shops, ZERO of type `record`, nothing matching
/monster|robot/. As written the adapter yields a Red Hill of census charity shops while the atlas keys to a
shop that isn't in the town -- F's #7b id-equality gate would fail at the end of the round. It must be
injected explicitly; the opaque-key design exists precisely to serve that case. Second trap on the same
path: redhill_godverse must still clear MIN_TOWN_SHOPS (6) from the census bbox or the cache is rejected
outright (hard error). E's donor is otherwise good: valid, no warns, 73.3 m median spacing.

HOUSEKEEPING: removed toowoomba's two stale pins -- E retired the cache in R23, and a golden for a town
that cannot load can never fire (the vacuous-gate law, one layer down). E's attempt also proved my R23
spacing metric is GAMEABLE: the re-bbox fixed the median (395.7 -> 89.4 m, "passes") while hub density fell
3/12 -> 2/12, the pack's worst. It is necessary, not sufficient -- D's hub test is the complement, and E
used both and refused to game it. Ballarat (255 m) is still flagged and still E's call; I pin what moves.

PINS NOT TAKEN YET, DELIBERATELY: redhill_real is untracked/in-flight (E still editing build_towns.py), and
redhill_godverse + the re-keyed newtown_godverse don't exist until wave 2. Pinning a moving file buys a
false green or a spurious red. The round's law says A pins these BATCHED -- that batch lands when E commits
and G's caches are in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:45:45 +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)`);
// WHY MISSING IS A WARN AND NOT AN ERROR (ROUND24 ledger #2 says "requires"; recorded as a
// departure, with reasons, per measurement-over-brief):
// 1. The charter's determinism boundary: "world = seeded, frozen, gated. Stock = data tiers."
// An error here inverts it — the TOWN would fail to load because its STOCK identity is absent.
// Tier 0 must always boot; a shop with no id is a parody shop, which is the designed ladder.
// 2. Charter risk #3 names the remedy for identity gaps: "fail-soft to tier 0, never mis-stocked."
// Missing = tier 0 (soft). Duplicate = mis-stocked (hard). They are not the same failure.
// 3. Sequencing: `newtown_godverse` carries 0 ids today and is G's to re-emit in wave 2 (their
// namespace, and the census DB isn't on this machine). An error would red the tree for C and E
// through all of wave 1 to convey what this warn already says.
// The teeth live in selfcheck: PARTIAL coverage fails, and zero coverage prints an explicit SKIP
// naming the town and the count (the vacuous-gate law) — so this cannot pass unnoticed.
if (isGodverseCache(cache) && withGid < cache.shops.length) {
warnings.push(`${cache.shops.length - withGid}/${cache.shops.length} shop(s) missing godverseShopId on a '${GODVERSE_SOURCE}' cache → those shops fall soft to tier 0 (parody); G re-emits`);
}
// 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)])]; }