PROCITY/web/js/citygen/selfcheck.js
m3ultra 8b7ca9aae4 Lane A (CityGen): deterministic town generator, registry, names, 2D map, self-check
Layer-1 CityPlan generator per CITY_SPEC schema v1 — pure data, no THREE, all randomness
through core/prng, deterministic + JSON-serializable + <1ms.

- web/js/citygen/plan.js: generatePlan(citySeed) → CityPlan (spine, cross-streets, market
  square + dept anchor, arcade, warehouse fringe, residential collar w/ corner milkbars,
  laneways) + chunkIndex (Amanatides–Woo edge supercover) + corner overlap-resolution pass.
- web/js/core/registry.js: SHOP_TYPES (all 9) + district weights + enums (shared vocabulary).
- web/js/citygen/names.js + wordlists.js: seeded 90s-AU shop/town names, 50+ patterns.
- web/js/citygen/index.js: canonical barrel entry (adopts Lane F's integration shim).
- web/map.html: standalone Canvas-2D plan debugger (pan/zoom/hover, layers, export JSON).
- web/js/citygen/selfcheck.js: node acceptance harness — determinism, golden fingerprint,
  facing, within- AND cross-block no-overlap, chunk+edge coverage, finiteness, assets. ALL GREEN
  (1271/1271) over 6 seeds. web/package.json ({"type":"module"}) lets node run it.
- docs/shots/laneA/: 5 seeds screenshotted, each reads as a town.

Fixes from an adversarial multi-agent review (15 confirmed defects): market facades faced the
wrong way; cross-block corner overlaps; chunkIndex skipped chunks incl. the origin/spine;
stall frontEdge z-band; doubled-possessive names; unparseable map road colour; +others.

CITY_SPEC amendment (treaty, same commit): shop-types table aligned to the registry lanes
import; documented lot.ry/frontEdge, block.kind/district-id, and web/package.json.

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

187 lines
11 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 { 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: 0xb5d5cc13 };
{
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 edge coverage: every chunk the centreline densely passes through is listed (review D3)
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 len = Math.hypot(b.x - a.x, b.z - a.z), steps = Math.max(1, Math.ceil(len / 2)); // 2m dense samples
for (let k = 0; k <= steps && !edgeGap; k++) {
const x = a.x + (b.x - a.x) * k / steps, z = a.z + (b.z - a.z) * k / steps;
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, cell(x), cell(z)];
}
if (edgeGap) break;
}
ok(!edgeGap, `seed ${s}: chunkIndex lists every edge in every chunk its centreline crosses` + (edgeGap ? ` (edge ${edgeGap[0]} missing @ ${edgeGap[1]},${edgeGap[2]})` : ''));
// 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})`);
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);