// 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 { 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: 0x098eec2b }; { 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`); ok(plan.shops.every(sh => { const [mn, mx] = SHOP_TYPES[sh.type].storeys; return sh.storeys >= mn && sh.storeys <= Math.max(mx, 3); }), `seed ${s}: storeys within range (corner ≤3)`); // 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}: 2–4 corner milk bars (${milkbars})`); // exactly one deterministic late-night landmark (Lane F's night gate keys off `openLate`, not a threshold) const late = plan.shops.filter(sh => sh.openLate); ok(late.length === 1, `seed ${s}: exactly one open-late shop (${late.length})`); ok(late.every(sh => sh.hours[1] >= 22 && sh.type !== 'stall'), `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`); for (const sh of plan.shops) facadeSet.add(sh.facadeSkin); } // ── 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);