PROCITY/web/js/citygen/selfcheck.js
m3ultra e5d4679471 Lane A round 9: freeze CityPlan v2 contract in CITY_SPEC + closing-time occupant ruling
Ship round. Docs + one selfcheck log-line only; no generator change ⇒ all three goldens
unchanged (synthetic 0x3fa36874, osm/melbourne 0x34cfdec0, osm/katoomba 0x0f652510).

1. CITY_SPEC v2 contract FROZEN — new "CityPlan v2 — frozen producer contract" section: producer
   API, goldens table (keyed seed/plansrc/town) + how to pin a new one, town-key mechanism, osm
   normalization rules, hours/openLate/storeys laws. Matches code as shipped (F diffs claims vs
   code). Refreshed the stale round-6 Plan-sources blockquote to multi-town reality. Layer-1
   changes now require a CITY_SPEC amendment + golden re-pin in the same commit.
2. Recipe sanity — DRY-RAN end-to-end: appended a throwaway Newtown (42 shops), selfcheck auto-ran
   the full parity suite (1931/1932, only golden unpinned) and printed a paste-ready
   `newtown: 0x9045e577,`. Reverted; no 3rd town shipped. Added the selfcheck `⚠ UNPINNED — add to
   OSM_GOLDENS` line so the recipe is mechanical; fixed the recipe wording to match.
3. Closing-time occupant ruling (D's on-call Q) — ratifies + freezes what D's sim.js already does:
   half-open hours, no new entry when closed, occupants DRAIN (finish bounded dwell) never popped,
   player not force-ejected, openLate video is the night crowd. In CITY_SPEC + LANE_A_NOTES. No
   code change asked of D.

qa.sh --strict GREEN; selfcheck 1727/1727.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:56:19 +10:00

220 lines
14 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, osmTownKeys } 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];
}
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);
const ge22 = plan.shops.filter(sh => sh.hours[1] >= 22);
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)`);
}
// ── 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 };
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}}`);
}
// ── 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);