PROCITY/tools/qa/r42_roster.mjs
m3ultra 293a82c5e8 R42 THE LOOK: five lane-runs — the pub is dressed, the decks read, and the town is cast
John looked at R41's proof shots and named three faults. All three are fixed, at zero draw
cost, and every lane corrected somebody's numbers on the way — including its own.

E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief,
quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour
was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library
called colour was baked AO. So the fix was a palette imposed on colour already present, in
place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the
concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints
and origins provably did not move. (A first-pass bbox check ignored node rotations and
"found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s
office teal and went burgundy; the beer umbrella was white and went brewery green.
THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench =
1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a
turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl,
red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43,
not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage).

E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask
reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon
child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only
texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the
R41 deny-list's failure class through a different door: an innocent name from another
pipeline. Only rendering all 24 and LOOKING found it.
Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO
of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements
came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran
65..99 bones; named characters carry facial/hair bones).
THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo,
Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real.

B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then
measured: the atlas canvases are already byte-identical across boots, the game ships NO
webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The
real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600,
ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks
2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default).
THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max
over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached
0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed
otherwise — the side term was already right, which is why it survived 41 rounds of reading.
Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house
law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth.

C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are
post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's
harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control.
E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was
already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured
pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench
rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at
the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a
fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every
booth part sat 25mm sunk into its own worktop.

D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex
is in the identity signature and the impostor atlas; removal would renumber the town).
Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not
move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a
fresh default boot fetches 32 ped GLBs, none retired.
THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only
3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently
dropping six tracks per clip since it shipped. Testing the thing being thrown away found it.
Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical
across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s
race against a live sim), not deterministically red.

F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law
(0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified;
CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict.
R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a
hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second
freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel
hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there —
the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with
everything else frozen was the last thing moving at 53-62 px.
?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed.

Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to
the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647,
0x5f76e76 unmoved. No tag — John's playtest rules the epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:49:49 +10:00

314 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 — 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`);