PROCITY/web/js/citygen/selfcheck.js
m3ultra 52eb1090ef Lane A round 6: second plan source ?plansrc=osm (real-data OSM import) — full seam
The carried-over OSM plan source (no-showed R4–R5) lands complete, invariants green — a
second CityPlan producer behind the same contract, so B–F consume it unchanged.

- osm_fixture.js: 95 REAL inner-Melbourne secondhand/charity/music/book/toy/antique shops,
  extracted deterministically from thriftgod city_source.json (Overpass cache), imported as a
  module ⇒ ZERO network.
- plan_osm.js: generatePlanOSM(seed) — project lat/lon→metres, bin shops into latitude-band
  avenues off a spine (real row + real E–W order preserved), march into a valid non-overlapping
  CityPlan, centre on origin, report true 362×486 bbox, real shop names, one openLate video.
- index.js: generatePlanFor(seed, source) selector — default 'synthetic' BYTE-IDENTICAL
  (golden 0x3fa36874 untouched); 'osm' → the importer. generatePlanOSM exported for F.
- selfcheck.js: parameterized by source — OSM runs the full structural invariant suite
  (frontEdge/facing/no-overlap/chunk-coverage/finite/JSON/determinism/openLate) + its own
  golden 0x34cfdec0; synthetic-only brief checks skipped. ALL GREEN 1362/1362.
- map.html: ?plansrc=osm renders it (reference wiring for F). Screenshot in docs/shots/laneA/.
- CITY_SPEC: plan-source note; LANE_A_NOTES R6: seam + goldens + shape-diffs for F's gate (F2).

Honest cut: street geometry regularized (real row+order drive it, exact metres do not);
name-parody deferred. qa.sh --strict GREEN.

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

260 lines
17 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 } 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 } from './plan_osm.js';
import { allFacadeSkins, SHOP_TYPES } 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];
}
// ── 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 over every seed ────────────────────────────────────────
section('structural invariants');
const facadeSet = new Set();
const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v);
for (const s of SEEDS) {
const plan = generatePlan(s);
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));
// every number finite (JSON would silently turn NaN/Infinity into null)
ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `seed ${s}: node coords finite`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `seed ${s}: lot numbers finite`);
ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `seed ${s}: block poly finite`);
ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `seed ${s}: every edge references real nodes`);
ok(plan.blocks.every(b => districtIds.has(b.district)), `seed ${s}: every block in a real district`);
ok(plan.lots.every(l => blockIds.has(l.block)), `seed ${s}: every lot in a real block`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `seed ${s}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `seed ${s}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot)), `seed ${s}: every shop has a real lot`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `seed ${s}: every shop has a known type`);
ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `seed ${s}: 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]), `seed ${s}: every shop has sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign), `seed ${s}: every shop is named`);
ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `seed ${s}: no unresolved tokens / undefined in names`);
// STOREYS strictly within the registry range, corner-boost included (Lane F finding #5 class): the
// occasional 3-storey corner anchor adds ≤1 storey capped at 3, and ONLY for tall-capable types
// (registry max ≥ 2) — so single-storey types (video/milkbar/stall, [1,1]) must NEVER exceed 1.
// (The old `<= Math.max(mx,3)` was too lax — it would have passed a video at 3 storeys.)
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;
}), `seed ${s}: storeys within registry range (corner-boost ≤ min(max+1,3); single-storey types never boosted)`);
// one lot ↔ at most one shop
const lotShopCounts = {};
for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1;
ok(Object.values(lotShopCounts).every(c => c === 1), `seed ${s}: no lot has two shops`);
// a shop only ever sits on a solid lot use
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `seed ${s}: every shop sits on a solid lot`);
// FACING: every lot's facade normal points at (not away from) its frontEdge (the review's D1 class)
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 tox = nx - l.x, toz = nz - l.z; // vector from lot to its street
const [fx, fz] = facing(l);
if (tox * fx + toz * fz < -0.01) { backwards = l.id; break; } // facing away from the street it fronts
}
ok(backwards === null, `seed ${s}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
// OVERLAP within a block (all lots) — strips are sequential ⇒ disjoint by construction
const byBlock = {};
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
let inBlock = null;
for (const arr of Object.values(byBlock)) {
const cs = arr.map(lotCorners);
for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++)
if (obbOverlap(cs[i], cs[j])) { inBlock = [arr[i].id, arr[j].id]; break; }
if (inBlock) break;
}
ok(!inBlock, `seed ${s}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : ''));
// OVERLAP across blocks (building lots) — the corner-deconfliction guarantee (review D2)
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; // AABB reject
if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; }
}
ok(!crossBlock, `seed ${s}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
// CHUNKINDEX: covers every lot; buckets reference real ids
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)),
`seed ${s}: chunk buckets reference real ids`);
}
ok(plan.lots.every(l => covered.has(l.id)), `seed ${s}: chunkIndex covers every lot`);
// CHUNKINDEX corridor coverage: every chunk the road CORRIDOR passes through lists the edge — not
// just the centreline. Samples densely along the edge AND across the ±width/2 verge band (incl.
// offsets BETWEEN the chunkIndex rails, so this genuinely tests band interior, not just the rails).
// Guards the Lane B furniture-drop class: verge props must never fall in a chunk that omits their edge.
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)); // 2m dense along-edge samples
for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) { // across the corridor band
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, `seed ${s}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : ''));
// JSON round-trip is lossless (and carries no stray non-schema keys)
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `seed ${s}: JSON round-trip lossless`);
// design brief presence
ok(plan.streets.edges.some(e => e.kind === 'main'), `seed ${s}: has a main-street spine`);
ok(plan.streets.edges.some(e => e.kind === 'arcade'), `seed ${s}: has an arcade`);
ok(plan.districts.some(d => d.kind === 'market'), `seed ${s}: has a market district`);
ok(plan.shops.some(sh => sh.type === 'stall'), `seed ${s}: market has stalls`);
ok(plan.shops.some(sh => sh.type === 'dept'), `seed ${s}: has a department-store anchor`);
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 24 corner milk bars (${milkbars})`);
// exactly one deterministic late-night landmark, and the `openLate` field ⟺ `hours[1] >= 22`
// (Lane F's night gate may key off EITHER — they identify the same single shop).
const late = plan.shops.filter(sh => sh.openLate);
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
ok(late.length === 1, `seed ${s}: exactly one open-late shop (${late.length})`);
ok(ge22.length === 1, `seed ${s}: exactly one shop closes ≥22:00 (${ge22.length})`);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0], `seed ${s}: openLate field ⟺ hours[1]≥22 (same shop)`);
ok(late.length === 1 && late[0].hours[1] >= 22 && late[0].type !== 'stall', `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`);
ok(late.length === 1 && (late[0].type === 'video' || !plan.shops.some(x => x.type === 'video')), `seed ${s}: the open-late shop is the video rental`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
// ── 3b. OSM plan source (?plansrc=osm): same structural invariants + its own golden ────
// The synthetic golden (0x3fa36874) is untouched above; this parameterizes the harness by source.
// Brief checks (market/arcade/dept/milkbar) are synthetic-only and deliberately NOT asserted here —
// a real-data town has no synthetic market square. Structural invariants must hold identically.
section('OSM plan source (fixture-driven, zero-network)');
const OSM_GOLDEN = { seed: 20261990, hash: 0x34cfdec0 };
const OSM_SEEDS = [20261990, 1, 42, 777];
for (const s of OSM_SEEDS) {
const plan = generatePlanOSM(s);
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
const lotIds = new Set(plan.lots.map(l => l.id));
ok(plan.source === 'osm', `osm ${s}: source tagged 'osm'`);
ok(JSON.stringify(generatePlanOSM(s)) === JSON.stringify(plan), `osm ${s}: deterministic (same fixture+seed)`);
ok(plan.lots.length > 0 && plan.shops.length > 0, `osm ${s}: non-empty (${plan.shops.length} real shops)`);
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `osm ${s}: lot numbers finite`);
ok(plan.streets.edges.every(e => nodeById.has(e.a) && nodeById.has(e.b)), `osm ${s}: edges reference real nodes`);
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `osm ${s}: every lot has a valid frontEdge`);
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `osm ${s}: every lot has positive size`);
ok(plan.shops.every(sh => lotIds.has(sh.lot) && SHOP_TYPES[sh.type] && SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `osm ${s}: shops valid (lot + type + facade-in-pool)`);
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `osm ${s}: sane hours`);
ok(plan.shops.every(sh => sh.name && sh.sign && !/[{}]|undefined/.test(sh.name + sh.sign)), `osm ${s}: every shop named (real OSM names)`);
let back = 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);
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) { back = l.id; break; }
}
ok(back === null, `osm ${s}: every lot faces its frontEdge` + (back !== null ? ` (lot ${back})` : ''));
const cs = plan.lots.map(lotCorners); let ov = null;
for (let i = 0; i < plan.lots.length && !ov; i++) for (let j = i + 1; j < plan.lots.length; j++)
if (obbOverlap(cs[i], cs[j])) { ov = [plan.lots[i].id, plan.lots[j].id]; break; }
ok(!ov, `osm ${s}: no overlapping lots (within or across blocks)` + (ov ? ` (${ov})` : ''));
const idx = chunkIndex(plan); const cov = new Set();
for (const c of Object.values(idx.chunks)) for (const id of c.lots) cov.add(id);
ok(plan.lots.every(l => cov.has(l.id)), `osm ${s}: chunkIndex covers every lot`);
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `osm ${s}: JSON round-trip lossless`);
const late = plan.shops.filter(sh => sh.openLate), ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
ok(late.length === 1 && ge22.length === 1 && late[0] === ge22[0] && late[0].type === 'video', `osm ${s}: exactly one open-late video`);
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
}
{
const hash = xmur3(JSON.stringify(generatePlanOSM(OSM_GOLDEN.seed)))() >>> 0;
ok(hash === OSM_GOLDEN.hash, `osm ${OSM_GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(OSM_GOLDEN.hash >>> 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);