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>
256 lines
14 KiB
JavaScript
Executable File
256 lines
14 KiB
JavaScript
Executable File
#!/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 A–E 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 A–E 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); }
|