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>
144 lines
7.2 KiB
JavaScript
144 lines
7.2 KiB
JavaScript
#!/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);
|