#!/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'] 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) 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()