#!/usr/bin/env node /** * PROCITY Lane F — R42 §42.6 · THE ROSTER GATES (rulings 1 and 3), node, zero deps, ~0.1 s. * * node tools/qa/r42_roster.mjs --gate period # ruling 1 — no banned-genre body ships * node tools/qa/r42_roster.mjs --gate licence # ruling 3 — every shipped ped has a credits row * node tools/qa/r42_roster.mjs # both * * WHY THIS IS A SOURCE GATE AND NOT A DIRECTORY LISTING — Lane D's §42.4 filing, and it is right. * The five retired bodies are still ON DISK (`web/models/peds/`), and one of them — `dj_phrtt_01` — * is also still live on the shared depot (measured: 200, 712,792 B; the other four are 404). Lane E * deliberately did not delete them mid-wave because `rigs.js` loads by literal name and deleting a * file would shorten a pool, renumber every `pickRig` index and move the crowd's identity. So "what * ships" is `PED_NAMES` in `web/js/citizens/rigs.js` — the only list the game reads — and a gate * pointed at the directory would go red on files that no longer ship while staying green on a body * that quietly reappeared in the source. This file reads the source. * * ARMS (period): * 1. no banned body in PED_NAMES — ruling 1, the R42 audit's five rejects by name * 2. all five replacements present — the GREEN control: the gate can tell them apart * 3. pool shape 17/5/2 unchanged — the swap was in-place; a delete renumbers the town * 4. no banned name on a LOADABLE surface — web/js/**, web/*.html, web/assets/manifest.json * 5. quarantine: every ped GLB on disk is shipped, a base clip, or DECLARED withdrawn in credits * ARMS (licence): * 6. every shipped ped resolves to a credits.json entry with a real licence (no `unverified`) * 7. every REQUIRED entry that a ped resolves to carries a non-empty attribution string * * CONTROLS, run every time, on the same predicates — a gate that has never fired is a gate with a * typo in it. Arms 1/2/3 are re-run against a SYNTHETIC roster carrying the retired names (must go * RED on 5) and against one with a replacement removed (must go RED). Arm 4's matcher is fired at a * synthetic line. Arms 6/7 are re-run against a synthetic credits doc whose roster row has been set * back to `unverified` (must go RED on the 20 peds that row covers) and against one with the DJ row * deleted (RED on `dj_techno_01` — and arm 8 explains why only one: `models/peds/woman_*.glb` still * matches the three `woman_dj_*` bodies, which is a real defect in credits.json's globs, reported). * The controls are IN-MEMORY on purpose: the deny-list gate plants on disk because it is a * whole-tree byte scan and there is no other way to exercise it; these are pure functions of two * documents, so mutating a parsed copy runs the identical predicate with no chance of a crashed * run leaving a plant behind in a tree five lanes are working in. */ import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..'); const argv = process.argv.slice(2); const GATE = argv.includes('--gate') ? argv[argv.indexOf('--gate') + 1] : 'all'; const fails = []; const FAIL = (m) => { fails.push(m); console.log(` \x1b[31m✗ FAIL\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 note = (m) => console.log(` \x1b[33m·\x1b[0m ${m}`); // ── THE BANNED FIVE — Lane E §42.3-B/C, verbatim, with the reason each was rejected ─────────────── // Ruling 1: PROCITY is 1990s Australia. Ruling 5 of the R41 deny-list class also applies to the last // one: a name deny-list could never have caught it, because its NAME is innocent — it took a render. const BANNED = { comical_luchador_01: 'ruling 1 — a purple masked luchador, chest reads EL CHUPACABRA (Mixamo Ch43)', comical_boy_01: 'ruling 1 — a ~3-heads-tall cartoon child (Mixamo Ch09); ALSO a real contract break, ' + 'six thumb bones without the `mixamorig:` prefix, 3132/3460 bindable tracks (Lane D §42.4)', man_elder_01: 'ruling 1 — a robed wuxia/fantasy elder in purple and gold (Mixamo Ch39)', man_soldier_ww2_01: 'ruling 1 — a WW2 infantryman, helmet and webbing (Mixamo Ch49)', dj_phrtt_01: 'NOT A PERIOD MISS — an UNCLOTHED body. Its single 1024² atlas is bare skin end to ' + 'end; there is no garment on the asset. It was in the DEFAULT crowd (opts.djs defaults to dance) ' + 'and is live on the shared depot. Lane E §42.3-C.', }; // The five that replaced them, at the same index in the same pool (Lane D §42.4 swap table). const REPLACEMENTS = { man_casual_04: ['normal', 6], person_casual_01: ['normal', 9], man_smart_01: ['djs', 4], woman_smart_02: ['comical', 0], person_youth_01: ['comical', 1], }; const POOL_SHAPE = { normal: 17, djs: 5, comical: 2 }; // Motion clips live in the same directory and are not bodies. `_rotOnly`/`canonRig` load these by // literal name in rigs.js; they carry their own credits rows (mixamo-clips, rokoko-mocap). const BASE_CLIPS = new Set(['walk', 'idle', 'sit', 'look', 'dance_party', 'dance_medium', 'dance_drink', 'dance_sway']); // ── the two documents this gate is a pure function of ───────────────────────────────────────────── const RIGS = path.join(ROOT, 'web/js/citizens/rigs.js'); const CREDITS = path.join(ROOT, 'web/assets/credits.json'); const PEDDIR = path.join(ROOT, 'web/models/peds'); /** Parse `export const PED_NAMES = { … }` out of rigs.js WITHOUT importing it (rigs.js imports * three.js, which node cannot resolve from here). Comments are stripped first — every pool in that * object carries a trailing `// was …` comment naming the body it replaced, and a naive regex over * the raw text finds the retired names inside those comments and reports a false RED. Measured: * the first cut of this gate did exactly that and failed on all five. */ function readPedNames(src) { const m = src.match(/export const PED_NAMES\s*=\s*\{([\s\S]*?)\n\};/); if (!m) return null; const body = m[1].replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); const out = {}; for (const pm of body.matchAll(/(\w+)\s*:\s*\[([^\]]*)\]/g)) out[pm[1]] = [...pm[2].matchAll(/'([^']+)'|"([^"]+)"/g)].map((x) => x[1] || x[2]); return Object.keys(out).length ? out : null; } const shipped = (roster) => Object.values(roster).flat(); /** glob a credits `files` pattern (`models/peds/man_*.glb`, `vendor/addons/**`) against a repo path. */ function globMatch(pattern, p) { const rx = new RegExp('^' + pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*\*/g, '').replace(/\*/g, '[^/]*').replace(//g, '.*') + '$'); return rx.test(p); } /** the ruling-3 predicate, isolated so the control can run the IDENTICAL code on a mutated doc. */ function licenceFor(pedName, credits) { const rel = `models/peds/${pedName}.glb`; const hits = (credits.entries || []).filter((e) => (e.files || []).some((f) => globMatch(f, rel))); // A body may match more than one row (`woman_dj_01` matches both `woman_*.glb` and `woman_dj_*.glb`). // Prefer the most specific pattern — the longest literal prefix before the first wildcard — because // that is the row an auditor would read as "this asset's licence". Reported as `ambiguous` so the // resolution is visible rather than silent: two rows that disagree on the licence of one file is a // record-keeping defect even when the tie-break picks the right one. hits.sort((a, b) => spec(b, rel) - spec(a, rel)); const e = hits[0]; const amb = hits.length > 1 ? hits.slice(1).map((x) => x.id) : []; if (!e) return { ok: false, name: pedName, why: 'no credits.json entry lists this file' }; const lic = String(e.licence || '').trim(); const base = { name: pedName, id: e.id, ambiguous: amb }; if (!lic) return { ...base, ok: false, why: `entry '${e.id}' has an empty licence` }; if (/unverified/i.test(lic)) return { ...base, ok: false, why: `entry '${e.id}' licence is '${lic}'` }; if (e.required && !String(e.attribution || '').trim()) return { ...base, ok: false, why: `entry '${e.id}' is required:true but carries no attribution` }; return { ...base, ok: true, licence: lic, required: !!e.required, evidence: e.evidence || null }; } function spec(entry, rel) { let best = -1; for (const f of entry.files || []) if (globMatch(f, rel)) best = Math.max(best, f.indexOf('*') < 0 ? f.length : f.indexOf('*')); return best; } // ── the period predicate, isolated for the same reason ──────────────────────────────────────────── function bannedIn(roster) { const hits = []; for (const [pool, names] of Object.entries(roster)) names.forEach((n, i) => { if (BANNED[n]) hits.push({ pool, i, name: n, why: BANNED[n] }); }); return hits; } function missingReplacements(roster) { const all = new Set(shipped(roster)); return Object.entries(REPLACEMENTS).filter(([n]) => !all.has(n)).map(([n, [p, i]]) => `${n} (${p}[${i}])`); } // ══ GATE: PERIOD LAW ══════════════════════════════════════════════════════════════════════════════ function gatePeriod(roster, credits) { head('R42 PERIOD LAW (ruling 1) — no banned-genre body in the shipped ped set'); // ── arm 1: the shipped roster is clean ── const hits = bannedIn(roster); if (hits.length) for (const h of hits) FAIL(`BANNED BODY SHIPS: ${h.name} at PED_NAMES.${h.pool}[${h.i}] — ${h.why}`); else OK(`0 of ${Object.keys(BANNED).length} banned bodies in PED_NAMES (${shipped(roster).length} shipped)`); // ── arm 2: the GREEN control — the five replacements are actually there ── const miss = missingReplacements(roster); if (miss.length) FAIL(`replacement(s) absent from PED_NAMES: ${miss.join(', ')} — the swap did not land`); else OK(`all 5 replacements present at their declared slots: ${Object.entries(REPLACEMENTS) .map(([n, [p, i]]) => `${n}=${p}[${i}]`).join(' · ')}`); for (const [n, [pool, idx]] of Object.entries(REPLACEMENTS)) if ((roster[pool] || [])[idx] !== n) FAIL(`${n} is not at ${pool}[${idx}] — it is at ${(roster[pool] || []).indexOf(n)}; ` + `an in-place swap is the whole determinism argument (Lane D §42.4)`); // ── arm 3: pool shape ── for (const [pool, want] of Object.entries(POOL_SHAPE)) { const got = (roster[pool] || []).length; if (got !== want) FAIL(`PED_NAMES.${pool} holds ${got}, expected ${want} — a pool length change renumbers ` + `every pickRig index and moves the seeded crowd (the R2 fleet-order bug through a different door)`); } if (!fails.length) OK(`pool shape unchanged: normal ${roster.normal.length} · djs ${roster.djs.length} · ` + `comical ${roster.comical.length} = ${shipped(roster).length}`); // ── arm 4: nothing LOADABLE names a banned body ── // credits.json is deliberately NOT in this set: it records the withdrawal on purpose ("why is that // gone" is a question a credits panel should be able to answer — Lane E), and nothing loads a mesh // from it. Scanned instead: every module the game imports, every page, and the asset manifest. const surfaces = []; (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) { if (!['vendor', 'assets', 'models'].includes(e.name)) walk(p); } else if (/\.(js|mjs|html)$/.test(e.name)) surfaces.push(p); } })(path.join(ROOT, 'web')); surfaces.push(path.join(ROOT, 'web/assets/manifest.json')); const refs = []; for (const f of surfaces) { if (!fs.existsSync(f)) continue; const txt = fs.readFileSync(f, 'utf8').replace(/\/\/[^\n]*/g, ''); // a `// was X` note is not a load for (const n of Object.keys(BANNED)) if (txt.includes(n)) refs.push(`${path.relative(ROOT, f)} → ${n}`); } if (refs.length) for (const r of refs) FAIL(`a loadable surface still names a banned body: ${r}`); else OK(`${surfaces.length} loadable surfaces (web/**/*.{js,mjs,html} + assets/manifest.json) name 0 banned bodies`); // ── arm 5: quarantine — nothing undeclared is sitting in the ped directory ── // This is the arm that makes "it must be impossible for it to return silently" true for a FILE // rather than for a name: a body on disk is either shipped, a base clip, or explicitly recorded as // withdrawn in credits.json. A sixth un-vetted body dropped into the folder goes RED here. const creditsTxt = JSON.stringify(credits); const onDisk = fs.readdirSync(PEDDIR).filter((f) => f.endsWith('.glb')).map((f) => f.replace(/\.glb$/, '')); const shipSet = new Set(shipped(roster)); const undeclared = [], quarantined = []; for (const n of onDisk) { if (shipSet.has(n) || BASE_CLIPS.has(n)) continue; if (creditsTxt.includes(n)) quarantined.push(n); else undeclared.push(n); } if (undeclared.length) FAIL(`ped GLB(s) on disk that neither ship nor are declared withdrawn in ` + `credits.json: ${undeclared.join(', ')} — a body nobody has looked at is exactly how dj_phrtt_01 ` + `reached the default crowd (Lane E §42.3-C)`); else OK(`${onDisk.length} GLBs in web/models/peds/: ${shipSet.size} shipped · ${BASE_CLIPS.size} base clips · ` + `${quarantined.length} declared withdrawn (${quarantined.sort().join(', ')}) · 0 undeclared`); if (quarantined.length) note(`STILL ON DISK. Nothing loads them (arms 1 and 4 and D's r42_cast.py ` + `wire arm), but web/models/ is tracked and tools/deploy_digalot_procity.sh rsyncs the whole of ` + `web/ — so the bytes SHIP until someone deletes the files. Measured on the shared depot: ` + `dj_phrtt_01.glb is LIVE (200, 712,792 B) and the other four are 404, never published. The exact ` + `removal commands are in F-progress.md § Round 42 (§42.6), ask 4.`); // ── THE CONTROLS ── head(' CONTROL (period) — the same predicates, on a synthetic roster that must FAIL'); const planted = JSON.parse(JSON.stringify(roster)); planted.normal[6] = 'man_elder_01'; planted.normal[9] = 'man_soldier_ww2_01'; planted.djs[4] = 'dj_phrtt_01'; planted.comical[0] = 'comical_luchador_01'; planted.comical[1] = 'comical_boy_01'; const ch = bannedIn(planted), cm = missingReplacements(planted); if (ch.length !== 5) FAIL(`CONTROL VACUOUS: the R41 roster should trip 5 banned bodies, tripped ${ch.length}`); else OK(`R41's own roster trips the gate on all 5: ${ch.map((h) => `${h.pool}[${h.i}]=${h.name}`).join(' · ')}`); if (cm.length !== 5) FAIL(`CONTROL VACUOUS: the R41 roster should report 5 missing replacements, reported ${cm.length}`); else OK(`…and reports all 5 replacements missing from it — the gate distinguishes the two rosters`); const short = JSON.parse(JSON.stringify(roster)); short.normal.splice(6, 1); if (short.normal.length === POOL_SHAPE.normal) FAIL('CONTROL VACUOUS: a deleted name did not shorten the pool'); else OK(`a DELETED (not swapped) name shortens normal to ${short.normal.length} — which arm 3 catches`); const synth = 'const x = "dj_phrtt_01";'; if (!Object.keys(BANNED).some((n) => synth.replace(/\/\/[^\n]*/g, '').includes(n))) FAIL('CONTROL VACUOUS: arm 4\'s matcher does not fire on a synthetic banned reference'); else OK("arm 4's matcher fires on a synthetic loadable reference to dj_phrtt_01"); } // ══ GATE: ROSTER LICENCE COMPLETENESS ═════════════════════════════════════════════════════════════ function gateLicence(roster, credits) { head('R42 ROSTER LICENCE COMPLETENESS (ruling 3) — every shipped ped has a real credits.json row'); const rows = []; for (const [pool, names] of Object.entries(roster)) for (const n of names) rows.push({ pool, name: n, ...licenceFor(n, credits) }); const bad = rows.filter((r) => !r.ok); if (bad.length) for (const r of bad) FAIL(`${r.name} (${r.pool}): ${r.why}`); else { const byId = {}; for (const r of rows) (byId[r.id] ||= []).push(r.name); OK(`${rows.length}/${rows.length} shipped peds resolve to a credits entry with a real licence — ` + `0 'unverified'`); for (const [id, names] of Object.entries(byId)) { const e = credits.entries.find((x) => x.id === id); note(`${id} — ${names.length} ped(s) · ${e.licence.slice(0, 72)}${e.licence.length > 72 ? '…' : ''} · ` + `required=${!!e.required} · ${names.sort().join(', ')}`); } } // The schema half: a required entry must carry the string it obliges us to show. for (const e of credits.entries || []) if (e.required && !String(e.attribution || '').trim()) FAIL(`credits entry '${e.id}' is required:true with no attribution — the panel would print nothing`); // ── arm 8: report, do not assert, where a file matches TWO rows ────────────────────────────────── // Not a failure: the tie-break above resolves it correctly today. It is printed because the globs // `models/peds/woman_*.glb` (ped-bodies-base, Mixamo) and `models/peds/woman_dj_*.glb` // (ped-bodies-dj, TRELLIS/on-device) BOTH match the three woman_dj bodies and they state DIFFERENT // licences. Delete the narrower row and those three silently inherit "Adobe Mixamo" — which is the // wrong provenance, recorded confidently. Filed to Lane E/B: give the DJ five explicit file names. const ambiguous = rows.filter((r) => (r.ambiguous || []).length); if (ambiguous.length) note(`${ambiguous.length} ped(s) match more than one credits row — resolved by ` + `longest-literal-prefix: ${ambiguous.map((r) => `${r.name}→${r.id} (also ${r.ambiguous.join(',')})`).join(' · ')}`); else OK('every shipped ped matches exactly one credits row'); head(' CONTROL (licence) — the same predicate, on a synthetic credits doc that must FAIL'); const amber = JSON.parse(JSON.stringify(credits)); const base = amber.entries.find((e) => e.id === 'ped-bodies-base'); base.licence = 'unverified'; // exactly the state Lane B shipped in R41 const ambBad = shipped(roster).map((n) => licenceFor(n, amber)).filter((r) => !r.ok); if (!ambBad.length) FAIL('CONTROL VACUOUS: an `unverified` roster row did not turn the gate red'); else OK(`R41's amber \`unverified\` roster row turns ${ambBad.length} shipped peds RED — which is the ` + `state this ruling exists to forbid`); const gone = JSON.parse(JSON.stringify(credits)); gone.entries = gone.entries.filter((e) => e.id !== 'ped-bodies-dj'); const goneBad = shipped(roster).map((n) => licenceFor(n, gone)).filter((r) => !r.ok); if (!goneBad.length) FAIL('CONTROL VACUOUS: deleting the DJ-bodies row left every ped covered — ' + 'the `woman_*.glb` glob is swallowing them and the gate is not really per-asset'); else OK(`deleting the ped-bodies-dj row turns ${goneBad.length} RED (${goneBad.map((r) => r.name).join(', ')}) ` + `— coverage is resolved per asset. NOTE the other 3 DJ bodies do NOT go red, because ` + `'models/peds/woman_*.glb' still matches them: that is arm 8's finding, not a pass.`); const noattr = JSON.parse(JSON.stringify(credits)); const osm = noattr.entries.find((e) => e.required); if (osm) { osm.attribution = ''; } if (osm && String(osm.attribution || '').trim()) FAIL('CONTROL VACUOUS: could not clear a required attribution'); else OK(`a required:true row with its attribution cleared is what arm 7 tests for (entry '${osm ? osm.id : '—'}')`); } // ══ main ══════════════════════════════════════════════════════════════════════════════════════════ const src = fs.readFileSync(RIGS, 'utf8'); const roster = readPedNames(src); const credits = JSON.parse(fs.readFileSync(CREDITS, 'utf8')); if (!roster) { console.log('\x1b[31m✗ FAIL\x1b[0m could not parse PED_NAMES out of web/js/citizens/rigs.js'); process.exit(1); } console.log(`\x1b[1mPROCITY R42 §42.6 — roster gates\x1b[0m (gate: ${GATE})`); note(`roster read from web/js/citizens/rigs.js · credits from web/assets/credits.json ` + `(schema ${credits.schema}, ${(credits.entries || []).length} entries)`); if (GATE === 'all' || GATE === 'period') gatePeriod(roster, credits); if (GATE === 'all' || GATE === 'licence') gateLicence(roster, credits); console.log(''); if (fails.length) { console.log(`\x1b[31m● FAIL\x1b[0m — ${fails.length} problem(s)`); for (const f of fails) console.log(` · ${f}`); process.exit(1); } console.log(`\x1b[32m● PASS\x1b[0m — ${GATE === 'all' ? 'period law + roster licence' : GATE} clean, controls demonstrated`);