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>
187 lines
11 KiB
JavaScript
187 lines
11 KiB
JavaScript
// 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}: 2–4 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);
|