interior_scale_check.py grows the DJ-body leg (E flag a): the 5 trellis bodies
(b339402) spawned BY NAME through the real browse path — bind skeletons measure
0.984-0.989 UNITS vs the legacy roster's ~173 (cm convention), rigs.js:163
span-normalise absorbs it: statures 1.64/1.71/1.81/1.87/1.89 m, all [1.4,2.0],
feet planted, rig-kind verified. The old sample's (i*7)%19 could never reach
fleet indices 17-21 — the subject is now provably exercised.
flags_check.py grows STRICT smoke_djdance (E flag b: gate on binds + NaN, never
node counts): 4 Rokoko clips bind 64/65 tracks (sole unbound mixamorigReference
— E's census reproduced at runtime); gig dancers clip-driven (identity by
duration, mixer advancing, position.y FLAT); ?classic ZERO dance-clip fetches
(positive control: default fetched 4); ?noassets procedural sway ALIVE (amp
0.067 m vs clip-driven 0.00 — the inverse pair proves the branch).
ONE WARN, deliberate (the held finding, machine-recorded): ?classic fetches the
5 DJ body GLBs — unconditional PED_NAMES.normal members (D's file), which also
widened classic's crowd pool 17->22 ungated. Held for Fable per the five-holds
law; F did not improvise in D's file during the close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
12 KiB
Python
213 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""PROCITY Lane D — interior figure-scale gate (Round 10 regression guard).
|
||
|
||
Guards the R9 blocker: interior rig figures came out ~2x too tall (keeper head at 3.83 m in a
|
||
3.4 m room) because rigs.js `buildFigure` normalised by the head bone's world Y in BIND POSE —
|
||
which is head-above-hips (the skeleton origin), ~half the standing height — instead of the
|
||
feet->crown span. Fixed in R10 (normalise by `headY - minY`).
|
||
|
||
This gate boots the real game, enters a sample of open shops, spawns browser rigs at every browse
|
||
point (occupancy is 0 right after boot, so we inject it to exercise the browse spawn path
|
||
deterministically), and asserts for EVERY spawned interior figure:
|
||
1.4 m <= STATURE (crown - foot) <= 2.0 m (human-sized — platform-independent)
|
||
crown(head-bone world Y) < room dims.H (never clips the ceiling — its own line)
|
||
|feet(min-bone world Y)| < 0.05 m (planted on the floor)
|
||
Exits non-zero if any figure fails, so tools/qa.sh --strict fails a giant before it can ship.
|
||
|
||
[Lane F R13 — debt #6] The human-size check measures STATURE (feet→crown span), not the raw world
|
||
crown, so it stays correct the day a figure stands on a platform (a keeper on a 0.3 m riser is not a
|
||
giant). This is the same measure smoke_gigs uses for the band on Lane C's stage deck, now ported into
|
||
the shared gate; the world-crown "never clips the ceiling" assert is kept as its own separate line
|
||
(that is the real R10 failure mode). For floor figures foot≈0 ⇒ stature≈crown ⇒ fully backward-compatible.
|
||
|
||
Run: tools/.venv/bin/python tools/qa/interior_scale_check.py [--seed N]
|
||
Setup: the same Playwright venv as tools/soak.py / tools/flags_check.py.
|
||
Needs Lane B's window.DBG hook (?dbg=1) + window.PROCITY.interiorMode (Lane C/F).
|
||
"""
|
||
import sys, os, time, socket, subprocess, pathlib
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||
PORT = int(os.environ.get('PROCITY_QA_PORT', '8130')) # [R31] port-isolated suite runs (see flags_check.py)
|
||
HOST = f'http://127.0.0.1:{PORT}'
|
||
SEED = 20261990
|
||
if '--seed' in sys.argv:
|
||
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
|
||
|
||
LO, HI = 1.4, 2.0 # human crown band (Round-10 gate)
|
||
SEAT_LO, SEAT_HI = 0.9, 1.5 # [R16 ledger #3] seated figs (the drummer): a seated human's feet→crown span, still < ceiling, feet planted
|
||
FOOT_EPS = 0.05 # feet must plant at floor y=0
|
||
SAMPLE_TYPES = ['opshop', 'toy', 'video', 'record', 'book', 'milkbar', 'clothes', 'hardware']
|
||
# [R36 w2 — E's flag (a)] the five trellis DJ bodies (b339402, swept R36 w1). Their bind skeletons
|
||
# measure ~0.95–0.99 UNITS tall vs the legacy roster's ~173–180 (the cm convention) — absorbed by
|
||
# design at rigs.js:163 (span-normalise at spawn), but they joined PED_NAMES ungated and the browse
|
||
# sample above only ever draws pedIndex (i*7)%19, which can never reach fleet indices 17–21. This leg
|
||
# spawns each of the five BY NAME and asserts the spawned stature lands human — the number E flagged,
|
||
# proven at the spawn path, not inferred from the file.
|
||
DJ_BODIES = ['woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01']
|
||
|
||
fails = []
|
||
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
|
||
def OK(m): print(f" \033[32m✓\033[0m {m}")
|
||
|
||
def port_up(port):
|
||
with socket.socket() as s:
|
||
s.settimeout(0.4)
|
||
return s.connect_ex(('127.0.0.1', port)) == 0
|
||
|
||
def ensure_server():
|
||
if port_up(PORT):
|
||
return None
|
||
proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)],
|
||
cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
for _ in range(50):
|
||
if port_up(PORT): return proc
|
||
time.sleep(0.1)
|
||
proc.terminate(); sys.exit(f"could not start http.server on :{PORT}")
|
||
|
||
# JS: enter is done via DBG; this measures every interior fig after injecting browsers at browse points.
|
||
MEASURE_JS = r"""
|
||
(shopId) => {
|
||
const P = window.PROCITY, THREE = P.THREE, im = P.interiorMode, v = new THREE.Vector3();
|
||
const cur = im.current;
|
||
if (!cur || P.mode !== 'interior') return { err: 'not in interior' };
|
||
// occupancy is 0 right after boot; inject a browser rig at each browse point to exercise the
|
||
// browse spawn path (same spawnRig->buildFigure the shell uses when patronage fills a shop).
|
||
const bps = cur.browsePoints || [];
|
||
bps.slice(0, 3).forEach((pt, i) => {
|
||
try { im.keepers.spawn(cur.group, { x: pt.x, z: pt.z, ry: pt.ry, shopId: shopId, browse: true,
|
||
seedKey: 'qa#' + i, pedIndex: (i * 7) % 19 }); } catch (e) {}
|
||
});
|
||
const H = +cur.dims.H;
|
||
const figs = im.keepers.keepers.map(k => {
|
||
const a = k.actor; let crown = 0, foot = Infinity;
|
||
a.fig.updateWorldMatrix(true, true);
|
||
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
|
||
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
|
||
return { kind: k.kind, browse: !!k.browse, crown: +crown.toFixed(3), foot: +foot.toFixed(3),
|
||
stature: +(crown - foot).toFixed(3), // [R13 debt #6] feet→crown span (platform-independent)
|
||
seated: !!(a.fig && a.fig.userData && a.fig.userData.procitySeated) }; // [R16 ledger #3] seated-fig tag
|
||
});
|
||
return { H: +H.toFixed(2), figs };
|
||
}
|
||
"""
|
||
|
||
def main():
|
||
try:
|
||
from playwright.sync_api import sync_playwright
|
||
except ImportError:
|
||
sys.exit("playwright not installed — python3 -m venv tools/.venv && tools/.venv/bin/pip install playwright && tools/.venv/bin/playwright install chromium")
|
||
|
||
print(f"\033[1mINTERIOR FIGURE-SCALE GATE\033[0m (seed {SEED}, band [{LO},{HI}] m, no giants)")
|
||
srv = ensure_server()
|
||
total_figs = 0
|
||
shops_checked = 0
|
||
try:
|
||
with sync_playwright() as p:
|
||
b = p.chromium.launch()
|
||
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
||
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1')
|
||
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=25000)
|
||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||
try:
|
||
pg.wait_for_function("() => window.PROCITY && window.PROCITY.fleet && window.PROCITY.fleet.ready", timeout=15000)
|
||
except Exception:
|
||
sys.exit("fleet never became ready — cannot test rig scale")
|
||
pg.evaluate("() => window.DBG.setSegment(2)") # midday: shops open
|
||
|
||
for t in SAMPLE_TYPES:
|
||
sid = pg.evaluate("""(t) => {
|
||
const P=window.PROCITY;
|
||
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x));
|
||
if(!s) return null; window.DBG.enterShop(s.id); return {id:s.id, name:s.name};
|
||
}""", t)
|
||
if not sid:
|
||
continue
|
||
pg.wait_for_timeout(400) # interior build + keeper/browser rigs settle
|
||
res = pg.evaluate(MEASURE_JS, sid['id'])
|
||
if isinstance(res, dict) and res.get('err'):
|
||
FAIL(f"{sid['name']} ({t}): {res['err']}")
|
||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()"); pg.wait_for_timeout(150)
|
||
continue
|
||
H, figs = res['H'], res['figs']
|
||
shops_checked += 1
|
||
bad = []
|
||
for f in figs:
|
||
total_figs += 1
|
||
lo, hi = (SEAT_LO, SEAT_HI) if f.get('seated') else (LO, HI) # [R16 #3] seated exemption
|
||
if not (lo <= f['stature'] <= hi): bad.append(f"stature {f['stature']}m out of [{lo},{hi}]{' seated' if f.get('seated') else ''}")
|
||
elif not (f['crown'] < H): bad.append(f"crown {f['crown']}m >= ceiling {H}m")
|
||
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"feet at {f['foot']}m (not planted)")
|
||
if bad:
|
||
FAIL(f"{sid['name']} ({t}, H={H}m): " + "; ".join(bad))
|
||
else:
|
||
stats = ", ".join(f"{f['stature']:.2f}" for f in figs)
|
||
OK(f"{sid['name']} ({t}, H={H}m): {len(figs)} figs statures=[{stats}] all human-sized")
|
||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||
pg.wait_for_timeout(150)
|
||
|
||
# ── [R36 w2] the five DJ bodies, spawned by name (E's flag (a)) ──────────
|
||
dj = pg.evaluate(r"""(DJ) => {
|
||
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
|
||
const s = (P.plan.shops || []).find(x => x.type === 'record' && P.isOpen(x));
|
||
if (!s) return { err: 'no open record shop' };
|
||
D.enterShop(s.id);
|
||
const im = P.interiorMode, cur = im.current;
|
||
if (!cur) return { err: 'interior did not build' };
|
||
const bps = cur.browsePoints || [];
|
||
const H = +cur.dims.H, fleet = P.fleet, figs = [];
|
||
DJ.forEach((name, i) => {
|
||
const idx = fleet.all.findIndex(r => r.pedName === name);
|
||
if (idx < 0) { figs.push({ name, missing: true }); return; }
|
||
// native bind span — the number E flagged (~0.95–0.99 units vs legacy ~173–180)
|
||
const sc = fleet.all[idx].scene; sc.updateWorldMatrix(true, true);
|
||
let bh = 0, bm = Infinity;
|
||
sc.traverse(o => { if (o.isBone) { o.getWorldPosition(v);
|
||
if (/head/i.test(o.name)) bh = Math.max(bh, v.y); bm = Math.min(bm, v.y); } });
|
||
const pt = bps[i % Math.max(1, bps.length)] || { x: 0, z: 0, ry: 0 };
|
||
const k = im.keepers.spawn(cur.group, { x: pt.x, z: pt.z, ry: pt.ry, shopId: s.id,
|
||
browse: true, seedKey: 'qa-dj#' + i, pedIndex: idx });
|
||
const a = k.actor; a.fig.updateWorldMatrix(true, true);
|
||
let crown = 0, foot = Infinity;
|
||
(a.inner || a.fig).traverse(o => { if (o.isBone) { o.getWorldPosition(v);
|
||
if (/head/i.test(o.name)) crown = Math.max(crown, v.y); foot = Math.min(foot, v.y); } });
|
||
figs.push({ name, idx, kind: k.kind, usedPed: fleet.all[idx].pedName,
|
||
nativeSpan: +(bh - bm).toFixed(3), stature: +(crown - foot).toFixed(3),
|
||
foot: +foot.toFixed(3), crown: +crown.toFixed(3) });
|
||
im.keepers.remove(k);
|
||
});
|
||
D.exitShop();
|
||
return { shop: s.name, H, figs };
|
||
}""", DJ_BODIES)
|
||
if dj.get('err'):
|
||
FAIL(f"DJ-body leg (R36): {dj['err']} — the five trellis bodies went unmeasured")
|
||
else:
|
||
bad = []
|
||
for f in dj['figs']:
|
||
if f.get('missing'): bad.append(f"{f['name']} NOT IN FLEET"); continue
|
||
total_figs += 1
|
||
if f['kind'] != 'rig' or f['usedPed'] != f['name']:
|
||
bad.append(f"{f['name']} spawned as {f['kind']}/{f.get('usedPed')} — subject not exercised")
|
||
elif not (LO <= f['stature'] <= HI): bad.append(f"{f['name']} stature {f['stature']}m out of [{LO},{HI}] (bind span {f['nativeSpan']}u)")
|
||
elif not (f['crown'] < dj['H']): bad.append(f"{f['name']} crown {f['crown']}m >= ceiling {dj['H']}m")
|
||
elif abs(f['foot']) >= FOOT_EPS: bad.append(f"{f['name']} feet at {f['foot']}m (not planted)")
|
||
if bad:
|
||
FAIL(f"DJ bodies (R36, {dj['shop']}): " + "; ".join(bad))
|
||
else:
|
||
det = ", ".join(f"{f['name']} {f['nativeSpan']}u→{f['stature']:.2f}m" for f in dj['figs'])
|
||
OK(f"DJ bodies (R36, {dj['shop']}): all 5 spawned as rigs, human-sized — rigs.js span-normalise absorbs the ~1-unit bind [{det}]")
|
||
b.close()
|
||
finally:
|
||
if srv: srv.terminate()
|
||
|
||
print()
|
||
if shops_checked == 0:
|
||
sys.exit("\033[31m✗ no shops could be entered — gate inconclusive\033[0m")
|
||
if fails:
|
||
print(f"\033[31m● {len(fails)} FAIL\033[0m — interior giant(s) detected across {shops_checked} shop(s), {total_figs} figs.")
|
||
sys.exit(1)
|
||
print(f"\033[32m● PASS\033[0m — {total_figs} interior figs across {shops_checked} shops all human-sized [{LO},{HI}] m and under-ceiling.")
|
||
sys.exit(0)
|
||
|
||
if __name__ == '__main__':
|
||
main()
|