PROCITY/tools/qa/scaffold_check.mjs
m3ultra 8c275c711b Lane F R11: wire interior audio beds + audio smokes + gate scope
Interior audio (interior_mode): enter -> PROCITY.audio.playInterior(room.audio) with Lane C's
{musicKey,toneKey} (fade in), exit -> stopInterior() (0.7s fade). Guarded/no-op when
muted/noassets/pre-gesture -- silent-and-happy.

New smoke_audio gate (flags harness, fresh Chromium per the R10 standing law): pre-gesture silence
(ctx not unlocked), 0 boot errors, ?mute=1 muted, ?noassets=1 zero audio fetches, enter/exit
AudioNode leak-free (12 cycles, 8 shops with a music bed, live sources bounded at 5). Gesture via
keyboard -- a mouse-click gesture tripped PointerLockControls in headless.

scaffold_check: scoped the Math.random generation-ban to exempt runtime FX PLAYBACK
(web/js/world/audio.js) -- Lane B's footstep SFX variant/gain jitter is transient, not world
generation (which shop plays which bed is seeded upstream in interiors.js). Noted to B.

Soak with audio on PASS (heap 48MB stable, 0 errors, no drift). qa.sh --strict GREEN 6/6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:13:19 +10:00

256 lines
14 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) {
// [Lane F R11] The ban protects world-GENERATION determinism (same seed → same town). Runtime FX
// PLAYBACK that is transient by nature is exempt — the seeded CHOICE lives upstream in prng-driven
// generators, the playback file only renders it. web/js/world/audio.js (Lane B): footstep SFX variant
// + gain jitter (audio.js:143,145) are per-step cosmetics; which shop plays which bed is seeded in
// interiors.js, not here. (Noted to B — they may instead add a `// cosmetic` marker on those lines.)
const RUNTIME_FX_EXEMPT = new Set(['web/js/world/audio.js']);
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;
if (RUNTIME_FX_EXEMPT.has(p.replace(ROOT + '/', ''))) continue; // runtime FX playback, not gen code
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/R8] plan-source selector — pin goldens keyed by (seed, plansrc, TOWN).
// Lane A contract: synthetic 0x3fa36874 (selfcheck) · osm/melbourne 0x34cfdec0 · osm/katoomba 0x0f652510.
// 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 TOWN_GOLDENS = { melbourne: '0x34cfdec0', katoomba: '0x0f652510' };
for (const [town, golden] of Object.entries(TOWN_GOLDENS)) {
const a = bar.generatePlanFor(20261990, 'osm', { town }), b2 = bar.generatePlanFor(20261990, 'osm', { town });
assert(JSON.stringify(a) === JSON.stringify(b2),
`osm/${town} plan is deterministic across two runs`, `osm/${town} plan NON-DETERMINISTIC`);
const h = (xmur3(JSON.stringify(a))() >>> 0).toString(16).padStart(8, '0'); // pad: goldens are 0x + 8 hex
assert(`0x${h}` === golden,
`osm/${town} fingerprint == golden ${golden} ("${a.name}", ${a.shops?.length} shops)`,
`osm/${town} fingerprint DRIFTED: 0x${h} (expect ${golden})`);
}
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); }