THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean 0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20. The town was 99.3% people walking because standing still had nowhere to happen. Wiring: new postures.js + clipbank.js. idles.glb (10/10) drives a per-citizen deterministic idle on every near-tier actor plus the seeded shopkeeper. locomotion gives 33.7% of walkers a shopping bag. sitlean (8/8, lazy) puts 4 sits on Lane B's ACTUAL benches and 4 leans on shopfront walls. browse (5/8, lazy) is a real BROWSE state at C's browse points, seeded per (shopId, slot). venue (5/6, lazy) widens the gig crowd, plus a publican pouring and a record keeper in headphones. social (0/8) is never fetched — two-person conversation needs a paired state machine, filed to R42. Cost: boot = 4 requests, 1.24 MB / 16 clips resident; the rest lazy on first need; heap delta +3.34 MB; mixer median 0.1 ms both arms. ?clips=0 / ?classic=1 / ?noassets=1 fetch ZERO clips — not even clipbank.js (dynamic import). No shell edit needed. DRAWS: +0 on every bookmark (street_noon 193, crossroads 108, night_crowd 128, market_square 94, night_neon 111, interior 110 — identical both arms). Ruling 4 respected exactly. DETERMINISM: 150 citizens, two fresh contexts, byte-equal posture signature. Controls: seed+1 differs; EVERY clip GLB delayed 2 s -> identical signature (posture is a pure function of (citySeed, id), never of residency). 6 new streams collide with none of the 12 pre-R41 keys. TWO FINDINGS THAT CHANGED THE DESIGN: the idle pool was INVISIBLE — wired only to the R17/R29 node loiter, so only 0.8% of citizens were ever stopped; and the lean never fired at all (0 in a 9 s run). Both moved to the patronage stride check. Bench stations are GATED not trusted: 14/14 derived stations coincide with real instanced geometry within 2 cm, and the control (same stations offset 2 m) matches 0/14. Filed to B: one benchStops(plan) export retires the mirror, and furniture.js puts the bench's front ALONG the street rather than facing the road, contradicting its own comment. Leak: +0 geometries, +1 texture over 6 enter/exit cycles. Goldens 157,647/157,647, 0x5f76e76. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
420 lines
24 KiB
Python
420 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
"""PROCITY Lane D — R41 §41.3 runtime gate: THE MOTION LIBRARY IN THE LIVE GAME.
|
||
|
||
tools/.venv/bin/python tools/qa/r41_citizens.py [--seed N] 0 green · 1 red
|
||
|
||
Six arms. Every arm carries the control that makes it non-vacuous, and every number is measured in a
|
||
FRESH headless context against a no-store server (this project's documented ES-module cache burn).
|
||
|
||
1. BOOT LEDGER what the library actually costs: clip fetches at boot, bytes, groups resident,
|
||
and the heap delta against `?clips=0` — a control arm that turns off the motion
|
||
library and NOTHING else (`?classic=1` changes the ped pool, the fog, the game
|
||
and half the shell, so it cannot measure this).
|
||
2. LAZY LOADING the four non-boot groups are NOT fetched at boot, and browse.glb arrives on the
|
||
first interior with browsers. CONTROL: it is absent before that visit.
|
||
3. DETERMINISM two fresh contexts, same seed → byte-equal posture signature over the whole
|
||
active crowd. CONTROL: a different seed differs. Plus: the assignment is
|
||
independent of LOAD ORDER — the same run with the library forced late still
|
||
produces the same signature, which is the property lazy loading could break.
|
||
4. DRAW TABLE worst street view and worst interior, `?clips=0` vs default, over the same
|
||
bookmarks and the same seed. Ruling 4: the street law is the boot's own
|
||
declared budget and there is no room, so the delta must be ≤ 0 in effect.
|
||
5. ASSET-FREE `?noassets=1` runs (chunks build, no console errors, 0 clip fetches, 0 clipbank
|
||
module fetch) and `?classic=1` keeps its zero-fetch-delta covenant.
|
||
6. BENCH BINDING every bench station the sim derives for a LOADED chunk coincides with real
|
||
instanced geometry in Lane B's scene, position and yaw. CONTROL: the same
|
||
stations offset by 2 m must NOT match — otherwise "coincides" means nothing.
|
||
Plus a leak arm: repeated interior enter/exit leaves geometries/textures flat.
|
||
"""
|
||
import sys, os, time, json, socket, subprocess, pathlib
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
||
PORT = int(os.environ.get('PROCITY_R41_PORT', '8986'))
|
||
HOST = f'http://127.0.0.1:{PORT}'
|
||
SEED = 20261990
|
||
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
|
||
|
||
BOOT_GROUPS = {'idles.glb', 'locomotion.glb'}
|
||
LAZY_GROUPS = {'browse.glb', 'sitlean.glb', 'venue.glb', 'social.glb'}
|
||
BOOKMARKS = ['street_noon', 'crossroads_busy', 'night_crowd', 'market_square', 'night_neon']
|
||
|
||
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 head(m): print(f"\n\033[1m{m}\033[0m")
|
||
def note(m): print(f" \033[33m·\033[0m {m}")
|
||
def check(c, m): (OK if c else FAIL)(m); return c
|
||
|
||
NOSTORE = r'''
|
||
import sys, http.server, functools
|
||
class H(http.server.SimpleHTTPRequestHandler):
|
||
def end_headers(self):
|
||
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||
super().end_headers()
|
||
def log_message(self, *a): pass
|
||
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever()
|
||
'''
|
||
|
||
|
||
def port_up(p):
|
||
with socket.socket() as s:
|
||
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', p)) == 0
|
||
|
||
|
||
def serve():
|
||
pr = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
for _ in range(80):
|
||
if port_up(PORT): return pr
|
||
time.sleep(0.1)
|
||
pr.terminate(); raise SystemExit(f'could not serve on :{PORT}')
|
||
|
||
|
||
def new_page(p):
|
||
b = p.chromium.launch(args=['--js-flags=--expose-gc'])
|
||
pg = b.new_page(viewport={'width': 1280, 'height': 720})
|
||
errs, reqs = [], []
|
||
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
|
||
pg.on('pageerror', lambda e: errs.append(str(e)))
|
||
pg.on('request', lambda r: reqs.append(r.url))
|
||
return b, pg, errs, reqs
|
||
|
||
|
||
def boot(pg, q='', dbg=True, wait=True):
|
||
pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + q) if q else '') + ('&dbg=1' if dbg else ''))
|
||
if wait:
|
||
if dbg: pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
|
||
else: pg.wait_for_function('window.PROCITY && window.PROCITY.chunks && window.PROCITY.chunks.count > 0', timeout=45000)
|
||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||
|
||
|
||
def clip_reqs(reqs):
|
||
return [u.rsplit('/', 1)[-1].split('?')[0] for u in reqs
|
||
if '/models/clips/' in u or 'motion_manifest.json' in u or 'clipbank.js' in u]
|
||
|
||
|
||
HEAP = "() => { if (window.gc) window.gc(); return performance.memory ? performance.memory.usedJSHeapSize : null; }"
|
||
|
||
# max draw calls over N successive natural frames at the current pose
|
||
DRAWMAX = r"""
|
||
async (n) => {
|
||
const P = window.PROCITY;
|
||
let mx = 0, mt = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
await new Promise(r => requestAnimationFrame(() => r()));
|
||
const r = P.renderer.info.render;
|
||
if (r.calls > mx) mx = r.calls;
|
||
if (r.triangles > mt) mt = r.triangles;
|
||
}
|
||
return { draws: mx, tris: mt, budget: (P.budget || {}).draws || 300 };
|
||
}
|
||
"""
|
||
|
||
BENCHES = r"""
|
||
async (offset) => {
|
||
const P = window.PROCITY, C = P.citizens;
|
||
const mod = await import('./js/citizens/sim.js');
|
||
const M4 = P.scene.matrixWorld.constructor;
|
||
const m = new M4();
|
||
// every instanced world transform standing in the built scene right now
|
||
const inst = [];
|
||
P.scene.traverse(o => {
|
||
if (!o.isInstancedMesh) return;
|
||
o.updateWorldMatrix(true, false);
|
||
for (let i = 0; i < o.count; i++) {
|
||
o.getMatrixAt(i, m);
|
||
const w = m.clone().premultiply(o.matrixWorld), e = w.elements;
|
||
inst.push({ x: e[12], z: e[14], yaw: Math.atan2(e[8], e[10]) });
|
||
}
|
||
});
|
||
// ...against the sim's own derivation, restricted to chunks Lane B has actually BUILT (a station
|
||
// in an unstreamed chunk has no geometry to match and would be a false negative).
|
||
const cam = P.camera.position;
|
||
let tested = 0, hit = 0, yawOk = 0; const misses = [];
|
||
for (let i = 0; i < C.edges.length; i++) {
|
||
for (const st of mod.benchStationsFor(C.edges[i])) {
|
||
if (Math.hypot(st.x - cam.x, st.z - cam.z) > 90) continue; // inside the streamed window
|
||
const tx = st.x + offset, tz = st.z;
|
||
tested++;
|
||
let bd = 1e9, by = 0;
|
||
for (const p of inst) { const d = Math.hypot(p.x - tx, p.z - tz); if (d < bd) { bd = d; by = p.yaw; } }
|
||
if (bd <= 0.02) {
|
||
hit++;
|
||
let dy = Math.abs(((st.yaw - by) % (Math.PI * 2) + Math.PI * 3) % (Math.PI * 2) - Math.PI);
|
||
if (dy < 1e-3 || Math.abs(dy - Math.PI) < 1e-3) yawOk++; // exact, mod 2pi
|
||
} else if (misses.length < 5) misses.push({ x: +st.x.toFixed(2), z: +st.z.toFixed(2), nearest: +bd.toFixed(3) });
|
||
}
|
||
}
|
||
return { tested, hit, yawOk, misses, instances: inst.length };
|
||
}
|
||
"""
|
||
|
||
POSTURE_SIG = "() => window.PROCITY.citizens.postureSignature().join('\\n')"
|
||
CLIP_STATS = "() => window.PROCITY.citizens.clipStats()"
|
||
|
||
|
||
def main():
|
||
from playwright.sync_api import sync_playwright
|
||
srv = serve()
|
||
try:
|
||
with sync_playwright() as p:
|
||
# ── 1 + 2: boot ledger and lazy loading ──────────────────────────────────────────────
|
||
head('1. BOOT LEDGER — what the motion library costs at boot, measured')
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
pg.wait_for_function("() => window.PROCITY.fleet && window.PROCITY.fleet.bank && window.PROCITY.fleet.bank.manifest", timeout=20000)
|
||
pg.wait_for_timeout(1500)
|
||
boot_fetch = clip_reqs(reqs)
|
||
st_on = pg.evaluate(CLIP_STATS)
|
||
heap_on = pg.evaluate(HEAP)
|
||
glbs = [f for f in boot_fetch if f.endswith('.glb')]
|
||
check(set(glbs) == BOOT_GROUPS,
|
||
f'boot fetches exactly the 2 eager groups: {sorted(glbs)} (+ manifest + clipbank.js '
|
||
f'= {len(boot_fetch)} requests)')
|
||
check(st_on['manifest'], f"manifest parsed — catalogue {st_on['catalogue']} clips")
|
||
note(f"resident at boot: {st_on['groups']} groups / {st_on['clips']} clips / {st_on['bytes']} B "
|
||
f"({st_on['bytes'] / 1048576:.2f} MB of Lane E's 3.34 MB)")
|
||
note(f"heap after boot (library ON): {heap_on / 1048576:.1f} MB" if heap_on else 'heap unavailable')
|
||
|
||
head('2. LAZY LOADING — the other four groups are not paid for until they are wanted')
|
||
check(not (set(glbs) & LAZY_GROUPS),
|
||
f'none of {sorted(LAZY_GROUPS)} fetched at boot')
|
||
# let the street run: sitlean.glb should arrive on the first sit/lean INTENT, not before
|
||
pg.evaluate("() => window.DBG.shot('crossroads_busy')")
|
||
pg.wait_for_timeout(12000)
|
||
after_street = clip_reqs(reqs)
|
||
check('sitlean.glb' in after_street,
|
||
f'sitlean.glb arrives on the first street sit/lean intent (not at boot)')
|
||
check('browse.glb' not in after_street and 'venue.glb' not in after_street,
|
||
'browse.glb + venue.glb still unfetched — CONTROL for the interior arm below')
|
||
check('social.glb' not in after_street, 'social.glb never fetched (no two-person state yet — R42)')
|
||
# first interior with browsers pulls browse.glb
|
||
shop = pg.evaluate("""() => { const P = window.PROCITY, C = P.citizens;
|
||
const c = (P.plan.shops || []).map(s => ({ id: s.id, n: C.occupancyOf(s.id).count }))
|
||
.filter(s => s.n > 0).sort((a, b) => b.n - a.n)[0];
|
||
if (c) window.DBG.enterShop(c.id); return c || null; }""")
|
||
pg.wait_for_timeout(3000)
|
||
after_int = clip_reqs(reqs)
|
||
if shop:
|
||
check('browse.glb' in after_int, f'browse.glb arrives on the first interior with browsers (shop {shop["id"]})')
|
||
else:
|
||
note('no shop had occupants during this window — browse arm skipped')
|
||
st_int = pg.evaluate(CLIP_STATS)
|
||
note(f"resident after a street run + one interior: {st_int['groups']} groups / "
|
||
f"{st_int['clips']} clips / {st_int['bytes']} B")
|
||
check(not errs, f'0 console errors on the default boot ({len(reqs)} requests swept)')
|
||
|
||
# ── 6b: leak — repeated enter/exit with more clips resident ───────────────────────────
|
||
head('6b. LEAK — repeated interior enter/exit with the library resident')
|
||
pg.evaluate("() => window.DBG.exitShop()")
|
||
pg.wait_for_timeout(800)
|
||
# WARM FIRST, then measure. Cycle 1 legitimately grows the caches (that room's stock GLBs,
|
||
# its wallpaper texture, the browse group) — counting it as a leak measures cold-start, not
|
||
# retention. Same shop every cycle so the comparison is like for like.
|
||
leak_shop = pg.evaluate("""() => { const P = window.PROCITY, C = P.citizens;
|
||
const c = (P.plan.shops || []).map(s => ({ id: s.id, n: C.occupancyOf(s.id).count }))
|
||
.filter(s => s.n > 0).sort((a, b) => b.n - a.n)[0];
|
||
return c ? c.id : (P.plan.shops[0] || {}).id; }""")
|
||
pg.evaluate("(id) => window.DBG.enterShop(id)", leak_shop)
|
||
pg.wait_for_timeout(1400)
|
||
pg.evaluate("() => window.DBG.exitShop()")
|
||
pg.wait_for_timeout(900)
|
||
base = pg.evaluate("() => window.DBG.info()")
|
||
for _ in range(6):
|
||
pg.evaluate("(id) => window.DBG.enterShop(id)", leak_shop)
|
||
pg.wait_for_timeout(900)
|
||
pg.evaluate("() => window.DBG.exitShop()")
|
||
pg.wait_for_timeout(700)
|
||
after = pg.evaluate("() => window.DBG.info()")
|
||
st_end = pg.evaluate(CLIP_STATS)
|
||
dg = after['geometries'] - base['geometries']
|
||
dt = after['textures'] - base['textures']
|
||
check(abs(dg) <= 2 and abs(dt) <= 2,
|
||
f'shop {leak_shop}, warm baseline then 6 more enter/exit cycles: geometries '
|
||
f'{base["geometries"]}→{after["geometries"]} ({dg:+d}), textures '
|
||
f'{base["textures"]}→{after["textures"]} ({dt:+d})')
|
||
check(st_end['clips'] == st_int['clips'] and st_end['groups'] == st_int['groups'],
|
||
f'clip bank does not grow on re-entry: {st_end["groups"]} groups / {st_end["clips"]} clips '
|
||
f'(one fetch per group, promise-cached)')
|
||
b.close()
|
||
|
||
# ── 1b: the ?clips=0 control arm (heap + fetch delta) ────────────────────────────────
|
||
head('1b. CONTROL ARM ?clips=0 — the library off, and nothing else changed')
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg, 'clips=0')
|
||
pg.wait_for_timeout(3000)
|
||
off_fetch = clip_reqs(reqs)
|
||
heap_off = pg.evaluate(HEAP)
|
||
st_off = pg.evaluate(CLIP_STATS)
|
||
check(not off_fetch, f'?clips=0 fetches NOTHING clip-related — not the GLBs, not the manifest, '
|
||
f'not even clipbank.js ({len(reqs)} requests swept)')
|
||
check(st_off['groups'] == 0 and st_off['clips'] == 0, '?clips=0: no bank, 0 clips resident')
|
||
check(not errs, '?clips=0: 0 console errors')
|
||
if heap_on and heap_off:
|
||
note(f'HEAP DELTA (library ON − OFF): {(heap_on - heap_off) / 1048576:+.2f} MB '
|
||
f'(on {heap_on / 1048576:.1f} MB · off {heap_off / 1048576:.1f} MB)')
|
||
b.close()
|
||
|
||
# ── 3: determinism ───────────────────────────────────────────────────────────────────
|
||
head('3. DETERMINISM — same seed → same postures, byte-equal across fresh contexts')
|
||
sigs = []
|
||
for i in range(2):
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(2500)
|
||
sigs.append(pg.evaluate(POSTURE_SIG))
|
||
b.close()
|
||
n = len(sigs[0].splitlines())
|
||
check(sigs[0] == sigs[1] and n > 0,
|
||
f'{n} active citizens, two fresh browser contexts → byte-equal posture signature')
|
||
b, pg, errs, reqs = new_page(p)
|
||
pg.goto(f'{HOST}/index.html?seed={SEED + 1}&dbg=1')
|
||
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
|
||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(2500)
|
||
other = pg.evaluate(POSTURE_SIG)
|
||
check(other != sigs[0], f'CONTROL: seed {SEED + 1} gives a DIFFERENT signature '
|
||
f'({len(other.splitlines())} citizens) — byte-equal is not constant')
|
||
b.close()
|
||
# load-order independence: the property lazy loading could plausibly break
|
||
b, pg, errs, reqs = new_page(p)
|
||
pg.route('**/models/clips/*.glb', lambda route: (time.sleep(2.0), route.continue_())[1])
|
||
boot(pg)
|
||
pg.evaluate("() => window.DBG.shot('street_noon')")
|
||
pg.wait_for_timeout(2500)
|
||
slow = pg.evaluate(POSTURE_SIG)
|
||
check(slow == sigs[0],
|
||
'CONTROL: with every clip GLB delayed 2 s, the signature is IDENTICAL — posture is '
|
||
'assigned from (citySeed, id) alone, never from what happens to be resident')
|
||
b.close()
|
||
|
||
# ── 4: draw table ────────────────────────────────────────────────────────────────────
|
||
head('4. DRAW TABLE — worst street view and worst interior, library OFF vs ON')
|
||
table = {}
|
||
for arm, q in (('clips=0', 'clips=0'), ('default', '')):
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg, q)
|
||
pg.wait_for_timeout(6000)
|
||
row = {}
|
||
for bm in BOOKMARKS:
|
||
pg.evaluate('(n) => window.DBG.shot(n)', bm)
|
||
pg.wait_for_timeout(2500)
|
||
pg.evaluate('(n) => window.DBG.shot(n)', bm)
|
||
d = pg.evaluate(DRAWMAX, 24)
|
||
row[bm] = d
|
||
# worst interior: enter the biggest room type we can reach
|
||
pg.evaluate("() => window.DBG.enterShop('record')")
|
||
pg.wait_for_timeout(2500)
|
||
row['interior(record)'] = pg.evaluate(DRAWMAX, 24)
|
||
pg.evaluate("() => window.DBG.exitShop()")
|
||
table[arm] = row
|
||
b.close()
|
||
print(f" {'view':<20} {'clips=0':>10} {'default':>10} {'Δ':>6} budget")
|
||
worst = {'clips=0': 0, 'default': 0}
|
||
for k in list(table['clips=0']):
|
||
a0, a1 = table['clips=0'][k]['draws'], table['default'][k]['draws']
|
||
bud = table['default'][k]['budget'] if not k.startswith('interior') else 350
|
||
if not k.startswith('interior'):
|
||
worst['clips=0'] = max(worst['clips=0'], a0); worst['default'] = max(worst['default'], a1)
|
||
flag = '' if a1 <= bud else ' OVER'
|
||
print(f" {k:<20} {a0:>10} {a1:>10} {a1 - a0:>+6} <={bud}{flag}")
|
||
check(a1 <= bud, f'{k}: {a1} draws <= {bud}')
|
||
note(f"worst STREET view: clips=0 {worst['clips=0']} · default {worst['default']} "
|
||
f"(delta {worst['default'] - worst['clips=0']:+d})")
|
||
ints = (table['clips=0']['interior(record)']['draws'], table['default']['interior(record)']['draws'])
|
||
note(f'interior(record): clips=0 {ints[0]} · default {ints[1]} (delta {ints[1] - ints[0]:+d}), '
|
||
f'margin to 350 = {350 - ints[1]}')
|
||
|
||
# ── 5: asset-free + classic ──────────────────────────────────────────────────────────
|
||
head('5. ASSET-FREE — ?noassets=1 still runs, ?classic=1 keeps its zero-fetch-delta covenant')
|
||
for q, label in (('noassets=1', '?noassets=1'), ('classic=1', '?classic=1')):
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg, q)
|
||
pg.wait_for_timeout(4000)
|
||
cr = clip_reqs(reqs)
|
||
st = pg.evaluate(CLIP_STATS)
|
||
chunks = pg.evaluate("() => window.PROCITY.chunks.count")
|
||
act = pg.evaluate("() => window.PROCITY.citizens.stats.active")
|
||
check(not cr, f'{label}: 0 clip fetches, 0 manifest fetch, 0 clipbank.js fetch')
|
||
check(st['groups'] == 0 and st['clips'] == 0, f'{label}: no bank (clipStats {st["groups"]}/{st["clips"]})')
|
||
check(chunks > 0 and act > 0, f'{label}: town builds and the crowd walks ({chunks} chunks, {act} active)')
|
||
check(not errs, f'{label}: 0 console errors')
|
||
b.close()
|
||
|
||
# ── 6: bench binding ─────────────────────────────────────────────────────────────────
|
||
head('6. BENCH BINDING — the sim\'s bench stations ARE Lane B\'s benches')
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg)
|
||
pg.evaluate("() => window.DBG.shot('crossroads_busy')")
|
||
pg.wait_for_timeout(5000)
|
||
real = pg.evaluate(BENCHES, 0.0)
|
||
ctrl = pg.evaluate(BENCHES, 2.0)
|
||
check(real['tested'] >= 10 and real['hit'] == real['tested'],
|
||
f"{real['hit']}/{real['tested']} derived stations coincide with real instanced "
|
||
f"geometry within 2 cm (scene holds {real['instances']} instances)")
|
||
check(real['yawOk'] == real['hit'],
|
||
f"{real['yawOk']}/{real['hit']} also match the bench yaw exactly (mod 2pi)")
|
||
check(ctrl['hit'] == 0,
|
||
f"CONTROL: the same stations shifted 2 m match {ctrl['hit']}/{ctrl['tested']} — "
|
||
f"so 'coincide' is a real measurement, not a hit on any nearby prop")
|
||
if real['misses']: note(f"misses: {real['misses']}")
|
||
b.close()
|
||
|
||
# ── 7: the gig crowd's widened vocabulary ────────────────────────────────────────────
|
||
head("7. VENUE CLIPS — the gig crowd's vocabulary widens, the v3 dance pick survives")
|
||
b, pg, errs, reqs = new_page(p)
|
||
boot(pg, 'gigs=1')
|
||
pg.evaluate("() => window.DBG.setSegment(2)") # MIDDAY — no gig anywhere
|
||
pg.wait_for_timeout(5000)
|
||
pre = clip_reqs(reqs)
|
||
check('venue.glb' not in pre,
|
||
'CONTROL: at midday, with no gig on, venue.glb is not fetched at all')
|
||
pg.evaluate("() => window.DBG.setSegment(5)") # NIGHT — the doors open
|
||
pg.wait_for_timeout(5000)
|
||
mid = clip_reqs(reqs)
|
||
check('venue.glb' in mid,
|
||
'venue.glb arrives on GIG NIGHT, from the street — sim.setGig kicks it when F opens '
|
||
'the doors, so it is resident before the player walks in')
|
||
g = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
if (!P.gigs || !P.gigs.venueShopIds || !P.gigs.venueShopIds.length) return { ok: false, why: 'no gig venue' };
|
||
const id = P.gigs.venueShopIds[0];
|
||
D.enterShop(id);
|
||
await new Promise(r => setTimeout(r, 3500));
|
||
const crew = P.interiorMode.crew;
|
||
if (!crew) return { ok: false, why: 'no crew (gig not on)', state: P.gigs.stateOf(id) };
|
||
const mem = crew.members.filter(m => m.part === 'crowd');
|
||
return { ok: true, state: P.gigs.stateOf(id), crowd: mem.length,
|
||
swapped: mem.filter(m => m.venueId).length,
|
||
kept: mem.filter(m => !m.venueId).length,
|
||
pending: mem.filter(m => m.want).length,
|
||
clips: [...new Set(mem.map(m => m.venueId).filter(Boolean))].sort(),
|
||
dancers: mem.filter(m => m.dance).length };
|
||
}""")
|
||
print(' gig:', json.dumps(g))
|
||
if g.get('ok'):
|
||
check(g['swapped'] > 0 and g['kept'] > 0,
|
||
f"widening, not replacement: {g['swapped']}/{g['crowd']} crowd slots take a venue clip, "
|
||
f"{g['kept']} keep their R13 pick verbatim ({', '.join(g['clips'])})")
|
||
check(g['pending'] == 0, 'every swapped slot has its clip installed (self-heal drained)')
|
||
else:
|
||
note(f"gig arm skipped: {g.get('why')}")
|
||
check(not errs, '?gigs=1: 0 console errors')
|
||
b.close()
|
||
finally:
|
||
srv.terminate()
|
||
|
||
print()
|
||
if fails:
|
||
print(f"\033[31m● RED\033[0m — {len(fails)} failure(s)")
|
||
for f in fails: print(' ' + f)
|
||
return 1
|
||
print("\033[32m● PASS\033[0m — boot ledger, lazy loading, determinism, draw table, asset-free and bench binding all green")
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|