PROCITY/web/js/citygen/selfcheck.js
m3ultra 6fffd19043 Lane A round 17: v3.2 the town-cache contract (published first) + plan_osm hardened for real data
The real-map scout (ledger #6), A's half. Scope fence held: NO new plan features, NO golden moved
(classic 0x3fa36874, gig 0xb1d48ea1, all three osm fixtures frozen). The scout proves the EXISTING
?plansrc=osm contract eats real geography.

- The town-cache contract (published FIRST — E builds build_towns.py to it). plan_osm.js gains
  validateTownCache() (authoritative {ok,errors,warnings}), registerTownCache(), a generatePlanOSM(..,
  {cache}) seam, and TOWN_CACHE_SCHEMA/MIN_TOWN_SHOPS(6)/MAX_TOWN_SPAN_M(5km). Shape: {schema,key,town,
  source,license,attribution,center{lat,lon},shops[{id,name,type,lat,lon,suburb?}]}. Home declared:
  web/assets/towns/<key>.json (+ README.md full spec). Documented in CITY_SPEC v3.2 + LANE_A_NOTES R17.
- plan_osm hardened: blank name -> registry label; the validator gates finite center/coords + a >=6-shop
  floor and warns on unknown-type(->opshop)/dup-id/span>5km. Proven vs thriftgod's real 2,828-shop
  city_source.json: boots a valid plan, 0 bad coords, 0 'undefined' names.
- selfcheck: a committed synthetic-adversarial contract suite (valid-messy caches boot + pass structural/
  gig; invalid reject; wide-spread warns; registry seam) + an auto-loader for web/assets/towns/*.json
  (validate -> suites -> pin per-town golden), empty and green until E lands caches. ALL GREEN 15277/15277.
- v4 charter risk list (LANE_A_NOTES R17): the MEGA-STRIP (unbounded bbox -> 11.6 km strip; town selection
  is E's real lever, warned >5km); sparse compact towns (2 km inner-Canberra held 6 shops); osm facades
  clear (rows 54 m apart) so R16's bias is a no-op on real towns.

E->A handshake: E drops a cache in web/assets/towns/, the selfcheck prints its golden, I pin it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:32:14 +10:00

447 lines
30 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 self-check — `node web/js/citygen/selfcheck.js`. Plain node, zero deps.
// Asserts the Lane A acceptance contract. Prints all-green or dies loud with the first failure.
//
// The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and
// the generator can never disagree about what "overlap" or "facing" means.
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
import { generatePlanOSM, osmTownKeys, validateTownCache, registerTownCache, MIN_TOWN_SHOPS } from './plan_osm.js';
import { generatePlanFor, gigKeyFor, POSTER_CLEAR } from './index.js';
import { allFacadeSkins, SHOP_TYPES, VENUE_KINDS, genreForVenueKind, roadWidth } from '../core/registry.js';
import { xmur3 } from '../core/prng.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const ASSETS = join(HERE, '..', '..', 'assets', 'gen');
let failures = 0, checks = 0;
const ok = (cond, msg) => { checks++; if (!cond) { failures++; console.error(' ✗ ' + msg); } };
const section = s => console.log('\n' + s);
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)).
const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)];
// nearest point on segment AB to point P
function nearestOnSeg(px, pz, ax, az, bx, bz) {
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t));
return [ax + dx * t, az + dz * t];
}
const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v);
// The FULL structural invariant suite for ANY CityPlan — synthetic OR osm (round-8 parity: both
// sources get identical coverage). `label` prefixes each check. Synthetic-only "brief" checks
// (market/arcade/dept/milkbar) live at the call site, guarded, since a real town has none.
function structuralSuite(plan, label) {
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const nodeIds = new Set(plan.streets.nodes.map(n => n.id));
const blockIds = new Set(plan.blocks.map(b => b.id));
const lotIds = new Set(plan.lots.map(l => l.id));
const districtIds = new Set(plan.districts.map(d => d.id));
ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `${label}: node coords finite`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `${label}: lot numbers finite`);
ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `${label}: block poly finite`);
ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `${label}: every edge references real nodes`);
ok(plan.blocks.every(b => districtIds.has(b.district)), `${label}: every block in a real district`);
ok(plan.lots.every(l => blockIds.has(l.block)), `${label}: every lot in a real block`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `${label}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `${label}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot)), `${label}: every shop has a real lot`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `${label}: every shop has a known type`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `${label}: every facade is in its type's pool`);
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `${label}: every shop has sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign), `${label}: every shop is named`);
ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `${label}: no unresolved tokens / undefined in names`);
ok(plan.shops.every(sh => {
const [mn, mx] = SHOP_TYPES[sh.type].storeys; const pmax = mx >= 2 ? Math.min(3, mx + 1) : mx;
return sh.storeys >= mn && sh.storeys <= pmax;
}), `${label}: storeys within registry range (corner-boost ≤ min(max+1,3); single-storey never boosted)`);
const lotShopCounts = {};
for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1;
ok(Object.values(lotShopCounts).every(c => c === 1), `${label}: no lot has two shops`);
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `${label}: every shop sits on a solid lot`);
let backwards = null;
for (const l of plan.lots) {
const e = plan.streets.edges.find(x => x.id === l.frontEdge); const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z); const [fx, fz] = facing(l);
if ((nx - l.x) * fx + (nz - l.z) * fz < -0.01) { backwards = l.id; break; }
}
ok(backwards === null, `${label}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
const byBlock = {};
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
let inBlock = null;
for (const arr of Object.values(byBlock)) {
const cq = arr.map(lotCorners);
for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++)
if (obbOverlap(cq[i], cq[j])) { inBlock = [arr[i].id, arr[j].id]; break; }
if (inBlock) break;
}
ok(!inBlock, `${label}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : ''));
const solids = plan.lots.filter(l => SOLID.has(l.use));
const cs = solids.map(lotCorners);
const aabb = cs.map(q => { let a = [1e9, 1e9, -1e9, -1e9]; for (const [x, z] of q) { a[0] = Math.min(a[0], x); a[1] = Math.min(a[1], z); a[2] = Math.max(a[2], x); a[3] = Math.max(a[3], z); } return a; });
let crossBlock = null;
for (let i = 0; i < solids.length && !crossBlock; i++) for (let j = i + 1; j < solids.length; j++) {
if (solids[i].block === solids[j].block) continue;
const A = aabb[i], B = aabb[j];
if (A[2] < B[0] || B[2] < A[0] || A[3] < B[1] || B[3] < A[1]) continue;
if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; }
}
ok(!crossBlock, `${label}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
const idx = chunkIndex(plan);
const covered = new Set();
for (const cellB of Object.values(idx.chunks)) {
for (const id of cellB.lots) covered.add(id);
ok(cellB.lots.every(id => lotIds.has(id)) && cellB.shops.every(id => plan.shops.some(sh => sh.id === id)) && cellB.edges.every(id => edgeIds.has(id)), `${label}: chunk buckets reference real ids`);
}
ok(plan.lots.every(l => covered.has(l.id)), `${label}: chunkIndex covers every lot`);
const cell = v => Math.floor(v / CHUNK);
let edgeGap = null;
for (const e of plan.streets.edges) {
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw;
const steps = Math.max(1, Math.ceil(len / 2));
for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) {
for (let k = 0; k <= steps && !edgeGap; k++) {
const x = a.x + dx * k / steps + px * f, z = a.z + dz * k / steps + pz * f;
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, f, cell(x), cell(z)];
}
if (edgeGap) break;
}
if (edgeGap) break;
}
ok(!edgeGap, `${label}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : ''));
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `${label}: JSON round-trip lossless`);
// exactly one late-night landmark; openLate field ⟺ hours[1]≥22 (same shop); it's the video (or,
// for a town with no video at all, the fallback landmark). Holds for synthetic AND osm.
const late = plan.shops.filter(sh => sh.openLate);
// The v3 gig venue (`sh.venue`) is a night place that also runs ≥22:00 by design, so it's exempt
// from the single-late-shop rule. Base plans have no venue ⇒ this filter is a no-op there (goldens
// unaffected); gig plans exclude the pub so the openLate video is still the lone landmark.
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22 && !sh.venue);
ok(late.length === 1, `${label}: exactly one open-late shop (${late.length})`);
ok(ge22.length === 1, `${label}: exactly one shop closes ≥22:00 (${ge22.length})`);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `${label}: openLate field ⟺ hours[1]≥22 (same shop)`);
ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `${label}: open-late shop closes ≥22:00 and isn't a stall`);
ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `${label}: the open-late shop is the video rental (or fallback if no video)`);
}
// The v3 GIG layer (?gigs=1). Runs the full structuralSuite (venue-aware) plus gig-specific
// invariants. Validates the DISTRICT interface every downstream lane (C/D/B/F) hangs off this round.
function gigSuite(plan, label) {
structuralSuite(plan, label); // full structural coverage; venues exempt from late-shop rule
districtInvariants(plan, label); // + the v3 district contract (also run standalone by the ROUND14 sweep)
}
// The v3 DISTRICT contract, factored out of gigSuite (ROUND14) so the 400-seed sweep below can assert it
// at SCALE without re-running the heavy structuralSuite (already covered on the 6 SEEDS + osm parity).
function districtInvariants(plan, label) {
const venues = plan.shops.filter(s => s.venue);
const byId = new Map(venues.map(v => [v.id, v]));
// ── the district: 24 venues, kinds from the registry, never the openLate landmark ──
ok(venues.length >= 2 && venues.length <= 4, `${label}: 24 venues (${venues.length})`);
ok(venues.every(v => VENUE_KINDS.includes(v.venueKind)), `${label}: every venue has a known venueKind`);
ok(venues.every(v => v.type === v.venueKind), `${label}: venue type ≡ venueKind (C keys its recipe off type)`);
ok(venues.some(v => v.venueKind === 'pub'), `${label}: the town has a pub`);
ok(venues.every(v => !v.openLate), `${label}: no venue displaced the openLate landmark`);
ok(venues.every(v => (v.hours || [0, 0])[1] >= 22), `${label}: every venue is open into night so its gig can run`);
ok(new Set(venues.map(v => v.lot)).size === venues.length, `${label}: venues occupy distinct lots`);
ok(venues.every(v => v.genreKey === genreForVenueKind(v.venueKind)), `${label}: genreKey matches the registry kind→genre map`);
// debt #1: the manifest key IS the gigKey. One key, zero mapping.
ok(venues.every(v => gigKeyFor(v.genreKey) === `gig-${v.genreKey}`), `${label}: gigKey ≡ 'gig-'+genreKey`);
// ── the week ──
ok(Array.isArray(plan.gigs) && plan.gigs.length >= 1, `${label}: has a gig schedule`);
ok(plan.gigs.every(g => byId.has(g.venueShopId)), `${label}: every gig plays a real venue`);
ok(plan.gigs.every(g => Number.isInteger(g.cover) && (g.cover === 0 || (g.cover >= 2 && g.cover <= 10))), `${label}: cover ∈ {0}[2,10]`);
ok(plan.gigs.every(g => typeof g.bandName === 'string' && g.bandName && !/[{}]|undefined/.test(g.bandName)), `${label}: band names resolved`);
ok(plan.gigs.every(g => Number.isInteger(g.startSeg) && Number.isInteger(g.endSeg) && g.startSeg >= 0 && g.endSeg <= 5 && g.startSeg <= g.endSeg), `${label}: gig segment window sane`);
ok(plan.gigs.every(g => Number.isInteger(g.night) && g.night >= 0 && g.night < 7), `${label}: night index in [0,7)`);
ok(plan.gigs.every(g => g.genreKey === byId.get(g.venueShopId).genreKey), `${label}: gig genre ≡ its venue's genre`);
ok(new Set(plan.gigs.map(g => g.gigId)).size === plan.gigs.length, `${label}: gigIds unique across town`);
// ~46 gigs per venue per week, and never two on the same night at one venue
for (const v of venues) {
const mine = plan.gigs.filter(g => g.venueShopId === v.id);
ok(mine.length >= 4 && mine.length <= 6, `${label}: venue ${v.id} (${v.venueKind}) plays 46 nights (${mine.length})`);
ok(new Set(mine.map(g => g.night)).size === mine.length, `${label}: venue ${v.id} plays ≤1 gig per night`);
}
// no band plays two venues at once (≤1 slot per name per night across town)
let clash = null;
for (let n = 0; n < 7 && !clash; n++) {
const names = plan.gigs.filter(g => g.night === n).map(g => g.bandName);
if (new Set(names).size !== names.length) clash = n;
}
ok(clash === null, `${label}: no band plays two venues on one night` + (clash !== null ? ` (night ${clash})` : ''));
// ── posters: town-wide, never on the bitumen — with a real FRONTAGE floor (ROUND16 ledger #6) ──
const gigIds = new Set(plan.gigs.map(g => g.gigId));
ok(Array.isArray(plan.posters) && plan.posters.every(p => gigIds.has(p.gigId) && isFiniteNum(p.x) && isFiniteNum(p.z) && isFiniteNum(p.ry)), `${label}: posters reference a real gig with finite coords`);
ok(plan.posters.every(p => plan.gigs.find(g => g.gigId === p.gigId).night === 0), `${label}: posters advertise tonight's gigs`);
// Two poster classes, two floors. A FRONTAGE poster sits on its venue's +Z street facade (B's canon:
// buildings.js facade + venue.js door/queue/camera are +Z); ROUND16 pickVenues now PREFERS a band_room/
// rsl lot whose facade clears every carriageway, so — no more R15 blanket exemption — a frontage poster
// must clear its nearest kerb by ≥ POSTER_CLEAR. A SPINE poster is free-standing on the verge and keeps
// the plain no-bitumen floor (≥ 0, the R12 regression guard). 0.02 absorbs r2 poster-coord rounding vs
// pickVenues' un-rounded clearance test (worst observed frontage clearance is ~0.51 m).
const lotByIdD = new Map(plan.lots.map(l => [l.id, l]));
const onFacade = p => venues.some(v => {
const lot = lotByIdD.get(v.lot); if (!lot) return false;
const off = lot.d / 2 + 0.06; // gigs.js buildPosters item 1 (FACADE_PROUD)
return Math.hypot(p.x - (lot.x + Math.sin(lot.ry) * off), p.z - (lot.z + Math.cos(lot.ry) * off)) < 0.5;
});
let onRoad = null;
for (const p of plan.posters) {
let best = Infinity, bestEdge = null;
for (const e of plan.streets.edges) {
const a = nodeById2(plan, e.a), b = nodeById2(plan, e.b);
if (!a || !b) continue;
const [nx, nz] = nearestOnSeg(p.x, p.z, a.x, a.z, b.x, b.z);
const d = Math.hypot(p.x - nx, p.z - nz);
if (d < best) { best = d; bestEdge = e; }
}
if (!bestEdge) continue;
const frontage = onFacade(p);
const clearance = best - roadWidth(bestEdge) / 2;
const floor = frontage ? POSTER_CLEAR - 0.02 : -1e-6;
if (clearance < floor) { onRoad = [p.id, bestEdge.id, +clearance.toFixed(2), frontage ? 'frontage' : 'spine']; break; }
}
ok(onRoad === null, `${label}: every poster clears its kerb (frontage ≥ ${POSTER_CLEAR} m, spine ≥ 0)` + (onRoad ? ` (${onRoad[3]} poster ${onRoad[0]} clears edge ${onRoad[1]} by only ${onRoad[2]} m)` : ''));
}
const nodeById2 = (plan, id) => plan.streets.nodes.find(n => n.id === id);
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
section('determinism (deep-equal across two runs)');
for (const s of SEEDS) {
const a = JSON.stringify(generatePlan(s)), b = JSON.stringify(generatePlan(s));
ok(a === b, `seed ${s}: two runs identical`);
}
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
section('golden fingerprint (output-drift guard)');
const GOLDEN = { seed: 20261990, hash: 0x3fa36874 };
{
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
}
// ── 2. performance: generate under 100ms ────────────────────────────────────────────
section('performance (<100ms per plan)');
for (const s of SEEDS) {
const t0 = process.hrtime.bigint();
generatePlan(s);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
ok(ms < 100, `seed ${s}: generated in ${ms.toFixed(1)}ms`);
}
// ── 3. structural invariants — SYNTHETIC source (full suite) + synthetic-only brief checks ──
section('structural invariants — synthetic');
const facadeSet = new Set();
for (const s of SEEDS) {
const plan = generatePlan(s);
structuralSuite(plan, `syn ${s}`);
// synthetic-only brief presence (a real OSM town has none of these)
ok(plan.streets.edges.some(e => e.kind === 'main'), `syn ${s}: has a main-street spine`);
ok(plan.streets.edges.some(e => e.kind === 'arcade'), `syn ${s}: has an arcade`);
ok(plan.districts.some(d => d.kind === 'market'), `syn ${s}: has a market district`);
ok(plan.shops.some(sh => sh.type === 'stall'), `syn ${s}: market has stalls`);
ok(plan.shops.some(sh => sh.type === 'dept'), `syn ${s}: has a department-store anchor`);
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
ok(milkbars >= 2 && milkbars <= 4, `syn ${s}: 24 corner milk bars (${milkbars})`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
// ── 3b. OSM plan-source PARITY: every town runs the identical structuralSuite + its own golden ──
// (round 8) Real-data towns get the same coverage as synthetic; only the synthetic-brief checks are
// skipped. Goldens are pinned per town; the synthetic golden above stays 0x3fa36874.
section('OSM plan source parity (fixture-driven, zero-network)');
const OSM_GOLDENS = { melbourne: 0x34cfdec0, katoomba: 0x0f652510, silverton: 0xd4b351c9 };
const OSM_SEEDS = [20261990, 1, 42, 777];
for (const town of osmTownKeys()) {
const report = {};
for (const s of OSM_SEEDS) {
const plan = generatePlanOSM(s, town, s === 20261990 ? { report } : {});
ok(plan.source === 'osm', `osm/${town} ${s}: source tagged 'osm'`);
ok(JSON.stringify(generatePlanOSM(s, town)) === JSON.stringify(plan), `osm/${town} ${s}: deterministic (same fixture+seed)`);
structuralSuite(plan, `osm/${town} ${s}`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, town)))() >>> 0;
const want = OSM_GOLDENS[town];
// Unpinned town (just added via the "add a town" recipe) → tell the human the exact line to paste.
if (want === undefined) console.log(` ⚠ osm/${town}: UNPINNED — add to OSM_GOLDENS → ${town}: 0x${hash.toString(16).padStart(8, '0')},`);
ok(want !== undefined && hash === (want >>> 0), `osm/${town}: golden 0x${hash.toString(16)} matches pinned 0x${((want ?? 0) >>> 0).toString(16)}`);
console.log(` ${town}: ${report.shops} shops · normalized {typesRemapped:${report.typesRemapped}, hoursClamped:${report.hoursClamped}, openLate:${report.openLate}}`);
}
// ── 3c. v3 GIG layer (?gigs=1): flags-off identity + gig invariants (synthetic + osm) ──
section('v3 gig layer (?gigs=1) — flags-off identity + gig invariants');
// PRIME FLAG LAW: ?gigs off must be byte-identical to the base generator (goldens can't move).
for (const s of SEEDS) ok(JSON.stringify(generatePlanFor(s)) === JSON.stringify(generatePlan(s)), `seed ${s}: ?gigs off ≡ base (byte-identical)`);
const GIG_SEEDS = [20261990, 1, 42, 777];
for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'synthetic', { gigs: true }), `gig syn ${s}`);
for (const town of osmTownKeys()) for (const s of GIG_SEEDS) gigSuite(generatePlanFor(s, 'osm', { gigs: true, town }), `gig osm/${town} ${s}`);
// determinism of the gig layer (same seed ⇒ byte-identical gig plan)
ok(JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })) === JSON.stringify(generatePlanFor(42, 'synthetic', { gigs: true })), `gig layer deterministic`);
// custom-band drop-in: passed names surface with priority, deterministically given (seed, list)
{
const custom = ['AAA Custom One', 'BBB Custom Two', 'CCC Custom Three'];
const pc = generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom });
ok(pc.gigs.map(g => g.bandName).filter(b => custom.includes(b)).length === custom.length, `custom bands: all ${custom.length} passed names surface`);
ok(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true, customBands: custom })) === JSON.stringify(pc), `custom bands: deterministic given (seed, list)`);
}
// gig-layer golden (guards gig-layer output drift; pure-generator path, no custom file)
const GIG_GOLDEN = 0xb1d48ea1; // ROUND16 re-pin: pickVenues prefers band_room/rsl lots whose +Z facade clears crossing kerbs (ledger #6). Was 0x4f4a549d (R15 +Z flip), 0x1f636349 (R13 district), 0xa6ae5a5e (R12 alpha).
{
const hash = xmur3(JSON.stringify(generatePlanFor(20261990, 'synthetic', { gigs: true })))() >>> 0;
if (GIG_GOLDEN === 0) console.log(` ⚠ gig golden UNPINNED → set GIG_GOLDEN = 0x${hash.toString(16).padStart(8, '0')}`);
ok(GIG_GOLDEN !== 0 && hash === (GIG_GOLDEN >>> 0), `gig layer golden 0x${hash.toString(16)} matches pinned 0x${(GIG_GOLDEN >>> 0).toString(16)}`);
}
// ── 3d. district invariant sweep (ROUND14): 400 synthetic seeds + osm graceful placement ───────────
// Release-round Lane A gate: the district contract must hold at SCALE, not just the 4 hero seeds. Runs
// districtInvariants ONLY (structuralSuite already covers the 6 SEEDS + osm parity) so 400 full gig-on
// generations stay well under a second. Pure validation — NO new rng draws, no golden moves.
section('district invariant sweep (ROUND14: 400 synthetic seeds + osm graceful placement)');
const SWEEP_N = 400;
const sweepVenueCounts = new Set();
for (let s = 1; s <= SWEEP_N; s++) {
const plan = generatePlanFor(s, 'synthetic', { gigs: true });
districtInvariants(plan, `sweep syn ${s}`);
sweepVenueCounts.add(plan.shops.filter(v => v.venue).length);
}
// also cover the large/edge uint32 seeds (2e9, 8675309 …) — where rng edge cases hide — that fall
// outside 1..400 and otherwise only get the flags-off byte-identity check, not the district contract.
for (const s of SEEDS) if (s > SWEEP_N) districtInvariants(generatePlanFor(s, 'synthetic', { gigs: true }), `sweep syn ${s}`);
// the seeded 24 count is genuinely exercised across the range, not pinned to one value
ok([2, 3, 4].every(n => sweepVenueCounts.has(n)),
`sweep: venue count spans {2,3,4} across ${SWEEP_N} seeds (saw {${[...sweepVenueCounts].sort((a, b) => a - b).join(',')}})`);
// osm graceful placement — two branches of pickVenues' degradation, each proven by a fixture that
// genuinely lacks the structure the synthetic town has:
// • katoomba (19 shops): NO warehouse district ⇒ band_room/rsl hit their off-spine fallback.
// • silverton (single-row): NO main spine at all ⇒ pub's onMain filter is empty and buildPosters'
// spine run falls back to every edge (ROUND16 ledger #7 — closes the R14 gap where both osm
// fixtures had a spine). Both still land 24 valid venues incl. a pub.
for (const seed of GIG_SEEDS) {
const kt = generatePlanFor(seed, 'osm', { gigs: true, town: 'katoomba' });
ok(kt.blocks.every(b => b.kind !== 'warehouse'),
`sweep osm/katoomba ${seed}: fixture has no warehouse district (graceful-placement precondition)`);
districtInvariants(kt, `sweep osm/katoomba ${seed}`);
const sv = generatePlanFor(seed, 'osm', { gigs: true, town: 'silverton' });
ok(!sv.streets.edges.some(e => e.kind === 'main'),
`sweep osm/silverton ${seed}: fixture has no main spine (no-spine placement precondition)`);
districtInvariants(sv, `sweep osm/silverton ${seed}`);
}
// ── 3e. town-cache contract (ROUND17 ledger #6): validator + real-data hardening + registry seam ──
// A publishes the contract E's build_towns.py builds to; plan_osm must eat real data (dead ends, no
// spine, blank names, unknown types, odd densities) without new plan features. These are synthetic
// adversarial caches — the committed proof of the hardening (real caches load in §3f below).
section('town-cache contract (ROUND17: real data through plan_osm)');
const mkCache = (shops, extra = {}) => ({
schema: 'procity-town-cache/1', key: 'stress', town: 'Stress', source: 'osm',
license: 'ODbL 1.0', attribution: '© OpenStreetMap contributors', center: { lat: -33.7, lon: 150.3 }, shops, ...extra,
});
const cluster = (n, opt = {}) => Array.from({ length: n }, (_, i) => ({
id: 500 + i,
name: opt.blank && i % 3 === 0 ? '' : `Real Shop ${i}`,
type: opt.unknown && i % 4 === 0 ? 'op_shop_weird' : ['book', 'record', 'opshop', 'toy', 'pawn', 'video', 'milkbar'][i % 7],
lat: -33.7 + (opt.samePoint ? 0 : (i % 5) * 0.0008),
lon: 150.3 + (opt.samePoint ? 0 : Math.floor(i / 5) * 0.0008),
suburb: 'Testville',
}));
// (a) VALID-but-messy caches validate ok AND boot a full valid district (structural + gig invariants)
for (const [label, cache] of [
['compact 14-shop town', mkCache(cluster(14))],
['blank names → defaulted', mkCache(cluster(14, { blank: true }))],
['unknown types → opshop', mkCache(cluster(14, { unknown: true }))],
['all shops at one point', mkCache(cluster(14, { samePoint: true }))],
['minimum shop count', mkCache(cluster(MIN_TOWN_SHOPS))],
]) {
const v = validateTownCache(cache);
ok(v.ok, `cache "${label}": validates ok` + (v.ok ? '' : `${v.errors.join('; ')}`));
if (!v.ok) continue;
for (const s of [1, 20261990]) structuralSuite(generatePlanOSM(s, 'stress', { cache }), `stress/${label} ${s}`);
districtInvariants(generatePlanFor(20261990, 'osm', { gigs: true, town: 'stress', cache }), `stress/${label}`);
}
// (b) INVALID caches are REJECTED (they would not boot a valid district)
for (const [label, cache, needle] of [
['too few shops', mkCache(cluster(MIN_TOWN_SHOPS - 1)), 'shops required'],
['non-finite coords', mkCache(cluster(12).map((s, i) => i === 2 ? { ...s, lat: NaN } : s)), 'non-finite'],
['missing center', { schema: 'procity-town-cache/1', shops: cluster(12) }, 'center'],
['shops not an array', { ...mkCache(cluster(12)), shops: 'nope' }, 'array'],
['not an object', 'nope', 'not an object'],
]) {
const v = validateTownCache(cache);
ok(!v.ok && v.errors.some(e => e.includes(needle)), `cache "${label}": rejected` + (v.ok ? ' (WRONGLY ACCEPTED)' : ` — "${v.errors[0]}"`));
}
// (c) a region-wide cache still boots but WARNS (the R17 mega-strip risk: bound the query to one town)
{
const wide = mkCache(cluster(20).map((s, i) => ({ ...s, lat: -33.7 + i * 0.05 })));
const v = validateTownCache(wide);
ok(v.ok && v.warnings.some(w => /span|town/.test(w)), `wide-spread cache: valid but warns (${v.warnings.find(w => /span/.test(w)) || 'NO WARN'})`);
}
// (d) the registry seam: register → osmTownKeys() sees it → generatePlanOSM resolves it
{
registerTownCache('regtest', mkCache(cluster(14)));
ok(osmTownKeys().includes('regtest'), `registerTownCache: osmTownKeys() includes 'regtest'`);
ok(generatePlanOSM(1, 'regtest').shops.length === 14, `registerTownCache: generatePlanOSM resolves the registered cache`);
}
// ── 3f. real town caches — E's build_towns.py output under web/assets/towns/ (ROUND17 ledger #6) ──
// Empty until E's caches land; each is validated, run through the full structural + gig suites, and
// pinned. Guarded so the gate is green before E's first cache — A pins goldens as caches arrive.
section('real town caches (web/assets/towns/*.json)');
const TOWNS_DIR = join(HERE, '..', '..', 'assets', 'towns');
const REAL_TOWN_GOLDENS = { /* E's caches pinned here as they land: <key>: 0x… */ };
const townFiles = (existsSync(TOWNS_DIR) ? readdirSync(TOWNS_DIR) : []).filter(f => f.endsWith('.json'));
if (!townFiles.length) console.log(" (none yet — the contract + hardening are live, ready for E's build_towns.py)");
for (const f of townFiles) {
const key = f.replace(/\.json$/, '');
let cache; try { cache = JSON.parse(readFileSync(join(TOWNS_DIR, f), 'utf8')); } catch (e) { ok(false, `real/${key}: parses as JSON — ${e.message}`); continue; }
const v = validateTownCache(cache);
ok(v.ok, `real/${key}: valid cache` + (v.ok ? '' : `${v.errors.join('; ')}`));
if (v.warnings.length) console.log(` ⚠ real/${key}: ${v.warnings.join(' · ')}`);
if (!v.ok) continue;
for (const s of OSM_SEEDS) structuralSuite(generatePlanOSM(s, key, { cache }), `real/${key} ${s}`);
for (const s of GIG_SEEDS) districtInvariants(generatePlanFor(s, 'osm', { gigs: true, town: key, cache }), `real/${key} ${s}`);
const hash = xmur3(JSON.stringify(generatePlanOSM(20261990, key, { cache })))() >>> 0;
const want = REAL_TOWN_GOLDENS[key];
if (want === undefined) console.log(` ⚠ real/${key}: UNPINNED — add REAL_TOWN_GOLDENS['${key}'] = 0x${hash.toString(16).padStart(8, '0')}`);
else ok(hash === (want >>> 0), `real/${key}: golden 0x${hash.toString(16)} matches pinned 0x${(want >>> 0).toString(16)}`);
}
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────
section('assets on disk');
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);
for (const f of facadeSet) ok(existsSync(join(ASSETS, f)), `used facade exists: ${f}`);
// ── report ──────────────────────────────────────────────────────────────────────────
console.log(`\n${failures ? '✗ FAIL' : '✓ ALL GREEN'}${checks - failures}/${checks} checks passed`);
const sample = generatePlan(20261990);
console.log(` sample seed 20261990 → "${sample.name}": ` +
`${sample.streets.nodes.length} nodes, ${sample.streets.edges.length} edges, ` +
`${sample.blocks.length} blocks, ${sample.lots.length} lots, ${sample.shops.length} shops`);
console.log(` fingerprint hash: 0x${(xmur3(JSON.stringify(sample))() >>> 0).toString(16)} (paste into GOLDEN.hash)`);
process.exit(failures ? 1 : 0);