PROCITY/tools/qa/scaffold_check.mjs
m3ultra 781baea4e2 Lane F R6 F2: wire ?plansrc=osm shell selector + pin osm golden + dig nearest-bin fallback
A landed the plansrc seam (52eb109); F owns the shell bootstrap. index.html: ?plansrc=osm
-> citygen.generatePlanFor(seed,'osm') (real-data 'Melbourne' 95 shops); default synthetic
'Boolarra Heads' unchanged (prime law). scaffold_check.mjs: pin BOTH goldens keyed by
(seed,plansrc) — synthetic 0x3fa36874, osm 0x34cfdec0 (deterministic) — + assert the selector
routes synthetic|osm. flags_check auto-detects + smokes plansrc + the all-on combo (4 flags).

interior_mode.js: restored binUnderAim's nearest-bin fallback (Lane C test-page parity) so E
riffles a nearby bin without perfect aim / before the bin GLB async-loads — fixes the dig
smoke's raycast-miss under fast headless AND improves real UX.

qa.sh --strict GREEN; flags_check GREEN (all 4 flags + all-on combo, 0 console errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:59:45 +10:00

246 lines
13 KiB
JavaScript
Executable File
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 — scaffold + determinism + lane-readiness check.
// Plain node, zero deps. Run: node tools/qa/scaffold_check.mjs
//
// What it does, and why F owns it:
// 1. Verifies the frozen scaffold everyone builds on (vendored three, core modules, skins).
// 2. Locks the PRNG determinism law (CITY_SPEC: same citySeed => byte-identical city) against
// golden values — a regression in prng.js is the one thing that silently corrupts every lane.
// 3. Prints a LANE READINESS MATRIX: which AE deliverables have actually landed. This is the
// "state of the integration" dashboard fable/Lane F reads before attempting to wire anything.
// 4. When Lane A's citygen lands, additionally asserts plan determinism + JSON round-trip.
//
// Exit code: 0 if everything PRESENT passes. Non-zero if a present artifact fails a check.
// Absent lane deliverables are reported as PENDING, not failures — F runs before AE and grows
// teeth as they land.
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
import { isDeepStrictEqual } from 'node:util';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..', '..'); // repo root (tools/qa/ -> ../../)
const R = (...p) => join(ROOT, ...p);
let failures = 0;
const fail = (msg) => { failures++; console.log(` \x1b[31m✗ FAIL\x1b[0m ${msg}`); };
const ok = (msg) => console.log(` \x1b[32m✓ ok\x1b[0m ${msg}`);
const info = (msg) => console.log(` \x1b[36m·\x1b[0m ${msg}`);
const head = (msg) => console.log(`\n\x1b[1m${msg}\x1b[0m`);
function assert(cond, okMsg, failMsg) { cond ? ok(okMsg) : fail(failMsg || okMsg); return cond; }
// ---------------------------------------------------------------- SCAFFOLD
head('SCAFFOLD (frozen — everyone builds on this)');
for (const f of ['web/vendor/three.module.js', 'web/vendor/three.core.js']) {
const p = R(f);
assert(existsSync(p) && statSync(p).size > 100_000,
`vendored ${f} present (${existsSync(p) ? (statSync(p).size / 1024 | 0) + 'k' : 'MISSING'})`,
`vendored ${f} missing or truncated`);
}
for (const f of ['web/js/core/prng.js', 'web/js/core/loaders.js', 'web/js/core/canvas.js']) {
assert(existsSync(R(f)), `core module ${f} present`, `core module ${f} MISSING`);
}
// Syntax-check every hand-written JS file (vendor/ excluded — third party).
head('SYNTAX (node --check on all web/js + tools)');
function collectJS(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) { if (name !== 'vendor' && name !== 'node_modules') out.push(...collectJS(p)); }
else if (p.endsWith('.js') || p.endsWith('.mjs')) out.push(p);
}
return out;
}
const jsFiles = [...collectJS(R('web/js')), ...collectJS(R('tools'))];
let syntaxBad = 0;
for (const f of jsFiles) {
try { execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' }); }
catch (e) { syntaxBad++; fail(`syntax error in ${f.replace(ROOT + '/', '')}: ${String(e.stderr || e).split('\n')[0]}`); }
}
if (syntaxBad === 0) ok(`${jsFiles.length} JS/MJS files parse clean`);
// ---------------------------------------------------------------- DETERMINISM (PRNG law)
head('DETERMINISM — PRNG law (CITY_SPEC: same seed ⇒ identical city)');
{
const prng = await import(R('web/js/core/prng.js'));
const { rng, seedFor, shuffle, pick, irange } = prng;
// Golden values — captured from the frozen prng.js. If these drift, every lane's world drifts.
const g = {
rng_1234_shop_7: [0.8933273730799556, 0.7678728918544948, 0.5031793543603271,
0.10452594514936209, 0.6337878312915564],
seedFor_1234_shop_7: 3826086676,
seedFor_20261990_district_0: 604920024,
shuffle_42: [5, 2, 6, 0, 1, 3, 8, 9, 4, 7],
irange_7: [2, 2, 5, 3, 1, 4],
};
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const r = rng(1234, 'shop', 7);
const first5 = [r(), r(), r(), r(), r()];
assert(eq(first5, g.rng_1234_shop_7), 'rng(1234,"shop",7) stream matches golden',
`rng stream DRIFTED: got ${JSON.stringify(first5)}`);
assert(seedFor(1234, 'shop', 7) === g.seedFor_1234_shop_7,
'seedFor(1234,"shop",7) matches golden', 'seedFor DRIFTED');
assert(seedFor(20261990, 'district', 0) === g.seedFor_20261990_district_0,
'seedFor(20261990,"district",0) matches golden', 'seedFor DRIFTED');
// Two independent streams from the same key must be byte-identical.
const a = rng(777, 'x', 1), b = rng(777, 'x', 1);
assert(eq(Array.from({ length: 64 }, () => a()), Array.from({ length: 64 }, () => b())),
'two independent rng(777,"x",1) streams are identical (64 draws)',
'independent streams DIVERGED — determinism broken');
assert(eq(shuffle(rng(42, 'shuffle', 0), [0,1,2,3,4,5,6,7,8,9]), g.shuffle_42),
'shuffle is deterministic + matches golden', 'shuffle DRIFTED');
// Draw six values from ONE stream (a fresh rng() per draw would just repeat the first value).
{ const ir = rng(7, 'irange', 0);
assert(eq(Array.from({ length: 6 }, () => irange(ir, 1, 6)), g.irange_7),
'irange is deterministic + matches golden', 'irange DRIFTED'); }
// Math.random must NOT appear in generation code (the house ban). grep web/js excluding core FX.
const offenders = grepMathRandom(R('web/js'));
if (offenders.length === 0) ok('no Math.random() in web/js (generation-code ban holds)');
else for (const o of offenders) fail(`Math.random() found (banned in gen code): ${o}`);
}
function grepMathRandom(dir) {
const hits = [];
(function walk(d) {
if (!existsSync(d)) return;
for (const name of readdirSync(d)) {
const p = join(d, name);
if (statSync(p).isDirectory()) { if (name !== 'vendor') walk(p); continue; }
if (!p.endsWith('.js') && !p.endsWith('.mjs')) continue;
const txt = readFileSync(p, 'utf8');
txt.split('\n').forEach((ln, i) => {
// Exemptions must live inside a // comment — anchor the keywords to the comment, otherwise
// a bare "cosmetic"/"banned" anywhere on a real offending line would falsely exempt it.
if (/Math\.random\s*\(/.test(ln) && !/\/\/.*(Math\.random|cosmetic|banned)/.test(ln))
hits.push(`${p.replace(ROOT + '/', '')}:${i + 1}`);
});
}
})(dir);
return hits;
}
// ---------------------------------------------------------------- ASSETS
head('ASSETS');
{
const gen = R('web/assets/gen');
const jpgs = existsSync(gen) ? readdirSync(gen).filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f)) : [];
assert(jpgs.length >= 69, `${jpgs.length} texture skins in web/assets/gen (expect ≥69)`,
`only ${jpgs.length} skins in web/assets/gen (expect ≥69)`);
const peds = R('web/models/peds');
if (existsSync(peds)) {
const glbs = readdirSync(peds).filter(f => f.endsWith('.glb'));
info(`web/models/peds staged: ${glbs.length} GLBs (Lane D fleet — code not required for this check)`);
}
}
// ---------------------------------------------------------------- LANE READINESS MATRIX
head('LANE READINESS MATRIX (what has actually landed)');
const targets = [
['A', 'CityPlan generator', 'web/js/citygen/plan.js'],
['A', 'shop-type registry', 'web/js/core/registry.js'],
['A', 'names generator', 'web/js/citygen/names.js'],
['A', 'selfcheck', 'web/js/citygen/selfcheck.js'],
['A', '2D debug map', 'web/map.html'],
['B', 'game shell', 'web/index.html'],
['B', 'chunk streamer', 'web/js/world/chunks.js'],
['B', 'facade kit', 'web/js/world/buildings.js'],
['B', 'fixture plan', 'web/js/world/fixture_plan.js'],
['C', 'interiors API', 'web/js/interiors/interiors.js'],
['C', 'interior test page', 'web/interior_test.html'],
['D', 'citizen rigs', 'web/js/citizens/rigs.js'],
['D', 'citizen sim', 'web/js/citizens/sim.js'],
['D', 'citizens test page', 'web/citizens_test.html'],
['E', 'asset manifest', 'web/assets/manifest.json'],
['E', 'manifest validator', 'pipeline/validate_manifest.py'],
['E', 'audit', 'pipeline/AUDIT.md'],
];
const byLane = {};
for (const [lane, label, path] of targets) {
const there = existsSync(R(path));
(byLane[lane] ||= { n: 0, have: 0 }); byLane[lane].n++; if (there) byLane[lane].have++;
console.log(` [${lane}] ${there ? '\x1b[32mLANDED \x1b[0m' : '\x1b[33mpending\x1b[0m'} ${label.padEnd(20)} ${path}`);
}
head('READINESS SUMMARY');
let allLanded = true;
for (const lane of ['A', 'B', 'C', 'D', 'E']) {
const s = byLane[lane];
const done = s.have === s.n;
allLanded &&= done;
info(`Lane ${lane}: ${s.have}/${s.n} key deliverables ${done ? '\x1b[32m(complete)\x1b[0m' : ''}`);
}
info(allLanded
? '\x1b[32mAll lanes landed — Lane F integration + full QA gates can run.\x1b[0m'
: '\x1b[33mLanes still pending — F integration is BLOCKED on the above. This check re-runs as they land.\x1b[0m');
// ---------------------------------------------------------------- PLAN DETERMINISM (activates when Lane A lands)
if (existsSync(R('web/js/citygen/plan.js'))) {
head('PLAN DETERMINISM (Lane A present — running deep checks)');
try {
const mod = await import(R('web/js/citygen/plan.js'));
if (typeof mod.generatePlan !== 'function') {
fail('web/js/citygen/plan.js does not export generatePlan(citySeed)');
} else {
const t0 = performance.now();
const p1 = mod.generatePlan(1234);
const genMs = performance.now() - t0;
const p2 = mod.generatePlan(1234);
assert(JSON.stringify(p1) === JSON.stringify(p2),
'generatePlan(1234) is deterministic across two runs',
'generatePlan(1234) NON-DETERMINISTIC — two runs differ');
assert(genMs < 100, `generatePlan under 100ms (${genMs.toFixed(1)}ms)`,
`generatePlan too slow: ${genMs.toFixed(1)}ms (budget 100ms)`);
// Deep-compare the PARSED round-trip against the LIVE object (normalizing the benign -0→0
// that JSON does losslessly but SameValue treats as distinct). stringify-vs-stringify would
// be a tautology — it can't catch a plan carrying Map/Set/undefined/function/Date/NaN.
const norm = v => JSON.parse(JSON.stringify(v, (_, x) => Object.is(x, -0) ? 0 : x));
const round = JSON.parse(JSON.stringify(p1));
assert(isDeepStrictEqual(round, norm(p1)),
'plan survives JSON round-trip (no lossy Map/Set/undefined/fn fields)',
'plan carries non-JSON data — Map/Set/undefined/function/Date/NaN lost on round-trip');
if (typeof mod.chunkIndex === 'function') ok('chunkIndex(plan) exported');
else fail('chunkIndex(plan) not exported from plan.js');
info(`plan(1234): ${p1?.shops?.length ?? '?'} shops, ${p1?.lots?.length ?? '?'} lots, ` +
`${p1?.streets?.edges?.length ?? '?'} edges, name "${p1?.name ?? '?'}"`);
// [F2] plan-source selector (?plansrc=osm) — pin BOTH goldens keyed by (seed, plansrc).
// Lane A contract (LANE_A_NOTES F2): synthetic 0x3fa36874 (selfcheck), osm 0x34cfdec0.
// Guarded: no-op until A ships generatePlanFor + generatePlanOSM.
try {
const bar = await import(R('web/js/citygen/index.js'));
if (typeof bar.generatePlanFor === 'function' && typeof bar.generatePlanOSM === 'function') {
const { xmur3 } = await import(R('web/js/core/prng.js'));
const oa = bar.generatePlanOSM(20261990), ob = bar.generatePlanOSM(20261990);
assert(JSON.stringify(oa) === JSON.stringify(ob),
'generatePlanOSM(20261990) is deterministic across two runs', 'OSM plan NON-DETERMINISTIC');
const oh = (xmur3(JSON.stringify(oa))() >>> 0).toString(16);
assert(`0x${oh}` === '0x34cfdec0',
`osm plan fingerprint == golden 0x34cfdec0 ("${oa.name}", ${oa.shops?.length} shops)`,
`osm plan fingerprint DRIFTED: 0x${oh} (expect 0x34cfdec0)`);
assert(bar.generatePlanFor(20261990, 'osm').source === 'osm'
&& (bar.generatePlanFor(20261990, 'synthetic').source || 'synthetic') === 'synthetic',
'generatePlanFor routes synthetic|osm correctly', 'generatePlanFor selector mis-routes');
}
} catch (e) { fail(`osm plan-source check threw: ${e.message}`); }
}
} catch (e) {
fail(`importing/running plan.js threw: ${e.message}`);
}
}
// ---------------------------------------------------------------- VERDICT
head('VERDICT');
if (failures === 0) { console.log('\x1b[32m● scaffold_check GREEN\x1b[0m — every present artifact passed.\n'); process.exit(0); }
else { console.log(`\x1b[31m● scaffold_check RED\x1b[0m — ${failures} failure(s) above.\n`); process.exit(1); }