PROCITY/tools/qa/consistency_check.mjs
m3ultra 9416d0ae08 Lane F R4: wire citizens.setExposure (F1) + permittedMax storeys-checker fix (F2)
F1: pass renderer.toneMappingExposure into CitizenSim each street frame so mid-tier
impostors track the animated day/night exposure (per LANE_D_NOTES). Verified: boots
clean, 0 console errors, exposure animates night 1.18 <-> midday 0.98.

F2: scope the consistency storeys check to permittedMax = max>=2 ? min(max+1,3) : max
(Lane A's corner-anchor rule, LANE_A_NOTES R3). Consistency now 0 warnings across 6
seeds; a single-storey type at >1 still trips. qa.sh --strict GREEN 4/4.

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

144 lines
7.2 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], EXCEPT tall-capable types may reach a
// 3-storey corner anchor (CITY_SPEC). Lane A's rule (LANE_A_NOTES R3, verified across 8 seeds):
// permittedMax = registryMax>=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);