PROCITY/tools/qa/consistency_check.mjs
m3ultra aa4ed4fdca Lane F: integration — interiors + keepers + street peds + QA harness
interior_mode.js bridges Lane B's enterShop seam to Lane C buildInterior and Lane D
keepers (spawn at counter keeperStand, greet, leak-free over 5 cycles). CitizenSim
wired into the street loop (pop 140, ~96 active midday, worst ~191 draws). tools/:
qa.sh gate runner (all 4 landed gates GREEN --strict), scaffold/consistency checks,
shots.py + soak.py browser harnesses. docs/shots/ reference tree, LANE_F_NOTES
runbook, V2_IDEAS parking lot.

(F's inline seam edits to index.html/skins.js landed with the Lane B commit.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:29:03 +10:00

138 lines
6.9 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.

#!/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<close, no wrap)
}
// storeys must sit inside the type's registry [min,max] — unless it's a taller corner anchor.
const sr = def.storeys;
if (Array.isArray(sr) && sr.length === 2 && (s.storeys < sr[0] || s.storeys > sr[1])) 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 their registry type range `
+ `(ok if these are the CITY_SPEC "occasional 3-storey corner anchor"; else a plan↔registry drift — ask 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);