#!/usr/bin/env node // PROCITY Lane F — cross-lane consistency gate (plan ↔ registry ↔ assets ↔ manifest). // Plain node, zero deps. Run: node tools/qa/consistency_check.mjs [seed ...] // // This is the seam Lane A's own selfcheck can't cover: does the generated PLAN agree with the // shared REGISTRY, the asset SET, and (when present) Lane E's MANIFEST? These are the mismatches // that make a shop render wrong or a door open onto the wrong theme. Runs against real generated // output for several seeds. Skips cleanly (exit 0) until Lane A's plan.js + registry.js exist. import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..', '..'); const R = (...p) => join(ROOT, ...p); const PLAN = R('web/js/citygen/plan.js'); const REG = R('web/js/core/registry.js'); if (!existsSync(PLAN) || !existsSync(REG)) { console.log('consistency_check: plan.js and/or registry.js not landed yet — skipping (Lane A).'); process.exit(0); } let fails = 0, warns = 0; const fail = (m) => { fails++; console.log(` \x1b[31m✗\x1b[0m ${m}`); }; const warn = (m) => { warns++; console.log(` \x1b[33m!\x1b[0m ${m}`); }; const ok = (m) => console.log(` \x1b[32m✓\x1b[0m ${m}`); const head = (m) => console.log(`\n\x1b[1m${m}\x1b[0m`); const { generatePlan, chunkIndex } = await import(PLAN); const reg = await import(REG); const SHOP_TYPES = reg.SHOP_TYPES; const genDir = R('web/assets/gen'); const genFiles = new Set(existsSync(genDir) ? readdirSync(genDir) : []); // Optional Lane E manifest. let manifest = null; if (existsSync(R('web/assets/manifest.json'))) { try { manifest = JSON.parse(await import('node:fs').then(fs => fs.readFileSync(R('web/assets/manifest.json'), 'utf8'))); } catch (e) { warn(`manifest.json present but unparseable: ${e.message}`); } } const seeds = process.argv.slice(2).map(Number).filter(Number.isFinite); if (!seeds.length) seeds.push(1234, 20261990, 7, 99, 424242); // Every type in a facade pool must map to a real file. Check the registry once. head('REGISTRY ↔ ASSETS'); { const skins = new Set(); for (const [type, def] of Object.entries(SHOP_TYPES)) { const pool = def.facades || def.facade || []; if (!pool.length) warn(`registry type "${type}" has no facade pool`); for (const f of pool) { skins.add(f); if (!genFiles.has(f)) fail(`registry ${type} facade "${f}" missing from web/assets/gen/`); } } if (fails === 0) ok(`all ${skins.size} registry facade skins exist in web/assets/gen/`); } // Per-seed plan integrity + agreement with registry. const typeKeys = new Set(Object.keys(SHOP_TYPES)); for (const seed of seeds) { head(`PLAN(${seed})`); const plan = generatePlan(seed); const lotById = new Map(plan.lots.map(l => [l.id, l])); const edgeIds = new Set((plan.streets?.edges || []).map(e => e.id)); let localFail = fails; // shops → valid type, valid lot, skin in pool + on disk, sane hours, storeys in registry range let lateCount = 0, skinOffPool = 0, dblApostrophe = 0, storeysOff = 0; for (const s of plan.shops) { if (!typeKeys.has(s.type)) fail(`shop ${s.id}: type "${s.type}" not in registry SHOP_TYPES`); if (!lotById.has(s.lot)) fail(`shop ${s.id}: lot ${s.lot} does not exist`); if (s.facadeSkin && !genFiles.has(s.facadeSkin)) fail(`shop ${s.id}: facadeSkin "${s.facadeSkin}" not in web/assets/gen/`); const def = SHOP_TYPES[s.type] || {}; const pool = def.facades || []; if (s.facadeSkin && pool.length && !pool.includes(s.facadeSkin)) skinOffPool++; const h = s.hours || []; if (h.length === 2) { if (h[0] < 0 || h[0] > 24 || h[1] < 0 || h[1] > 24) fail(`shop ${s.id}: hours ${JSON.stringify(h)} out of range`); if (h[1] >= 22) lateCount++; // Lane A encodes hours as simple 24h ints (open=2 ? min(registryMax+1, 3) : registryMax. Anything outside // [registryMin, permittedMax] is REAL drift — a single-storey type at >1 still trips. const sr = def.storeys; if (Array.isArray(sr) && sr.length === 2) { const permittedMax = sr[1] >= 2 ? Math.min(sr[1] + 1, 3) : sr[1]; if (s.storeys < sr[0] || s.storeys > permittedMax) storeysOff++; } if (/['’]s['’]s\b/.test(s.name || '')) dblApostrophe++; } if (skinOffPool) warn(`${skinOffPool} shop(s) use a facadeSkin outside their registry type pool ` + `(ok if Lane A widened pools intentionally; else a plan↔registry drift)`); if (storeysOff) warn(`${storeysOff} shop(s) have storeys outside [registryMin, permittedMax] ` + `(corner-anchor 3-storey is allowed for tall types; this is REAL plan↔registry drift — Lane A)`); if (dblApostrophe) warn(`${dblApostrophe} shop name(s) have a doubled possessive ("X's's") — Lane A names.js`); if (lateCount === 0) warn(`no open-late shop (close ≥22:00) — LANE_F §3.5 wants at least one`); else ok(`${lateCount} open-late shop(s) (close ≥22:00) — §3.5 satisfied`); // lots → valid frontEdge, valid block let badEdge = 0; for (const l of plan.lots) if (l.frontEdge != null && edgeIds.size && !edgeIds.has(l.frontEdge)) badEdge++; if (badEdge) fail(`${badEdge} lot(s) reference a frontEdge id that is not in streets.edges`); // chunkIndex covers every lot. Real API (Lane A): chunkIndex(plan) -> {chunkSize, chunks}, // chunks["cx,cz"] = {lots:[id…], shops:[…], edges:[…]}; a lot may bucket into several chunks. if (typeof chunkIndex === 'function') { const idx = chunkIndex(plan); const chunks = idx?.chunks || idx; // tolerate either {chunks} wrapper or a bare map const covered = new Set(); for (const cell of Object.values(chunks)) { if (cell && typeof cell === 'object') for (const cl of (cell.lots || [])) covered.add(cl?.id ?? cl); } const missing = plan.lots.filter(l => !covered.has(l.id)).length; if (missing === 0) ok(`chunkIndex covers all ${plan.lots.length} lots (chunkSize=${idx.chunkSize ?? '?'})`); else fail(`chunkIndex misses ${missing}/${plan.lots.length} lots`); } if (fails === localFail) ok(`${plan.shops.length} shops, ${plan.lots.length} lots — integrity clean`); } // Manifest agreement (when Lane E lands): registry skins should be catalogued. if (manifest) { head('MANIFEST ↔ REGISTRY'); const facadeSkins = manifest.skins?.facade || {}; const cataloged = new Set(Object.values(facadeSkins).map(v => (v.file || '').replace(/^gen\//, ''))); let missing = 0; for (const def of Object.values(SHOP_TYPES)) for (const f of (def.facades || [])) if (!cataloged.has(f)) missing++; if (missing) warn(`${missing} registry facade skin(s) not catalogued in manifest.skins.facade`); else ok('every registry facade skin is catalogued in the manifest'); } head('VERDICT'); if (fails === 0) { console.log(`\x1b[32m● consistency GREEN\x1b[0m — ${warns} warning(s).\n`); process.exit(0); } console.log(`\x1b[31m● consistency RED\x1b[0m — ${fails} failure(s), ${warns} warning(s).\n`); process.exit(1);