#!/usr/bin/env python3 """PROCITY Lane F — soak / budget / asset-free gate (QA gates 2, 3, 4). Drives a scripted walk across the town, entering shops, and asserts the CITY_SPEC contract: • ≥30 chunks crossed, ≥15 shops entered • renderer.info.memory geometries + textures return to baseline after each interior (no leak) • JS heap stable (no monotonic climb) • draw calls ≤ 300 and tris ≤ 200k at the busiest sample (budget) • ZERO console errors for the whole run Run: cd web && python3 -m http.server 8130 # other terminal tools/.venv/bin/python tools/soak.py [--seed N] [--minutes M] [--noassets] Setup: same venv as tools/shots.py (playwright + chromium). Needs Lane B's window.DBG hook (see docs/LANES/LANE_F_NOTES.md §4): DBG.ready, DBG.info() -> {drawCalls,tris,fps,heapMB,geometries,textures,chunk,mode}, DBG.teleport(x,z,ry), DBG.enterShop(o), DBG.exitShop(), DBG.setSegment(seg), DBG.plan -> the active CityPlan (for choosing a waypoint path + shop ids). --noassets appends ?noassets=1 (gate 4: primitive-fallback town, must not crash). Exits non-zero if any assertion fails, so tools/qa.sh --strict can chain it once B lands. """ import sys, json, time, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent HOST = 'http://127.0.0.1:8130' BUDGET_DRAWS, BUDGET_TRIS = 300, 200_000 MIN_CHUNKS, MIN_SHOPS = 30, 15 def flag(name, default=None): if name in sys.argv: i = sys.argv.index(name) return sys.argv[i + 1] if i + 1 < len(sys.argv) and not sys.argv[i+1].startswith('--') else True return default def main(): try: from playwright.sync_api import sync_playwright except ImportError: sys.exit("playwright not installed — see setup header in this file.") seed = flag('--seed') minutes = float(flag('--minutes', 10)) noassets = '--noassets' in sys.argv qs = '?dbg=1' + (f'&seed={seed}' if seed else '') + ('&noassets=1' if noassets else '') url = HOST + '/' + qs fails, warns, chunks_seen, shops_entered, peak_draws, peak_tris = [], [], set(), 0, 0, 0 heap_series = [] with sync_playwright() as p: b = p.chromium.launch(args=['--js-flags=--expose-gc']) pg = b.new_page(viewport={'width': 1280, 'height': 720}) cerr = [] pg.on('console', lambda m: cerr.append(m.text) if m.type == 'error' else None) pg.on('pageerror', lambda e: cerr.append(str(e))) pg.goto(url) # DBG installs asynchronously (main() → dynamic import of dbg.js) — poll rather than read the # instant goto's load event fires (that races the async boot and false-reports absent). try: pg.wait_for_function("() => !!window.DBG", timeout=15000) except Exception: sys.exit("⚠ window.DBG absent after 15s — boot the game with ?dbg=1 (soak.py adds it). " "(Gate is defined and ready.)") pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000) # Pin midday (setSegment pauses the day clock). §3.5 locks closed shops, so without this a long # soak drifts into night where only the openLate shop opens — starving the ≥MIN_SHOPS entry gate. pg.evaluate("() => window.DBG.setSegment(2)") # The CityPlan drives the walk. DBG doesn't expose it; the shell's window.PROCITY does. plan = pg.evaluate("() => (window.PROCITY && window.PROCITY.plan) || window.DBG.plan || null") if not plan: sys.exit("⚠ plan not exposed on window.PROCITY/DBG — need the active CityPlan to script a walk.") shops = plan.get('shops', []) lots = {l['id']: l for l in plan.get('lots', [])} # shop.lot is a lot ID, not a lot object base = pg.evaluate("() => { const i = window.DBG.info(); return {g:i.geometries, t:i.textures}; }") # Waypoint path: sample shop lots as targets so we cross many chunks and pass many doors. # (Duration ≈ len(targets) × per-step waits; a strict --minutes loop is a TODO to tune # against Lane B's real DBG API — the chunk/shop minimums are what actually gate here.) targets = shops[:: max(1, len(shops) // 60)] if shops else [] # denser sampling → cross ≥MIN_CHUNKS distinct chunks # Pre-warm the shared GLB cache before the leak-checked walk. The FIRST render of each fitting # GLB (and the rig keeper) uploads its persistent, SHARED geometry once — reused by every later # room. That one-time upload would otherwise read as a per-interior "leak"; warming here (one # shop per type, waiting for the depot GLB to load+render) makes the walk's leak check honest. # No-op under ?noassets (no GLBs load). if not noassets: warmed = set() for shop in shops: t = shop.get('type') if t in warmed: continue warmed.add(t) pg.evaluate("(id) => window.DBG.enterShop(id)", shop.get('id')) if pg.evaluate("() => window.DBG.info().mode === 'interior'"): pg.wait_for_timeout(2500) # depot fitting GLB load + first render → GPU upload → cache pg.evaluate("() => window.DBG.exitShop()") pg.wait_for_timeout(300) def sample(): nonlocal peak_draws, peak_tris i = pg.evaluate("() => window.DBG.info()") peak_draws = max(peak_draws, i.get('drawCalls', 0)) peak_tris = max(peak_tris, i.get('tris', 0)) heap_series.append(i.get('heapMB', 0)) if i.get('chunk') is not None: chunks_seen.add(str(i['chunk'])) return i # Walk the waypoint circuit, repeating until the --minutes budget is spent (with a safety # cap), but always at least once. The chunk/shop minimums are the real gate. deadline = time.monotonic() + minutes * 60 MEM = "() => { const i=window.DBG.info(); return {g:i.geometries,t:i.textures}; }" circuit = 0 while True: for idx, shop in enumerate(targets): lot = lots.get(shop.get('lot')) # resolve the lot ID → its world position x, z = (lot['x'], lot['z']) if lot else (0, 0) pg.evaluate("(p) => window.DBG.teleport(p.x, p.z, 0)", {'x': x, 'z': z}) pg.wait_for_timeout(1000) sample() # Enter shops we pass, counting ONLY verified interior loads. A closed shop # legitimately shows a locked toast and stays in street mode — that is not a # failure, it just doesn't count. A no-op enterShop therefore never reaches the # ≥MIN_SHOPS gate, which is the correct way to fail it. if idx % 2 == 0 and shops_entered < MIN_SHOPS + 5: before = pg.evaluate(MEM) pg.evaluate("(id) => window.DBG.enterShop(id)", shop.get('id')) # DBG.enterShop wants a bare shop id pg.wait_for_timeout(700); sample() if pg.evaluate("() => window.DBG.info().mode === 'interior'"): shops_entered += 1 pg.evaluate("() => window.DBG.exitShop()") pg.wait_for_timeout(500) after = pg.evaluate(MEM) # Leak check: geometries/textures return to (roughly) the pre-enter baseline. if after['g'] > before['g'] + 2 or after['t'] > before['t'] + 2: fails.append(f"interior leak on shop {shop.get('id')}: " f"geo {before['g']}→{after['g']}, tex {before['t']}→{after['t']}") if time.monotonic() > deadline and len(chunks_seen) >= MIN_CHUNKS \ and shops_entered >= MIN_SHOPS: break circuit += 1 if time.monotonic() > deadline or circuit >= 4 or not targets: break b.close() # ---- assertions ---- if len(chunks_seen) < MIN_CHUNKS: fails.append(f"crossed only {len(chunks_seen)} chunks (need ≥{MIN_CHUNKS})") if shops_entered < MIN_SHOPS: fails.append(f"entered only {shops_entered} shops (need ≥{MIN_SHOPS})") # Budget is INFORMATIONAL here, not a hard gate: DBG.info() renders via composer.render() and can be # sampled mid chunk-stream / interior-mode transition, spiking the peak to impossible values (10k+ # draws). The authoritative ≤300-draws / ≤200k-tris check is QA gate 3 — a clean single-pass # renderer.render(scene,camera) at the busiest intersection (see LANE_F_NOTES §4/§7). if peak_draws > BUDGET_DRAWS: warns.append(f"peak draws {peak_draws} > {BUDGET_DRAWS} (DBG.info sample — verify with gate 3's clean pass)") if peak_tris > BUDGET_TRIS: warns.append(f"peak tris {peak_tris} > {BUDGET_TRIS} (rig-fleet models; gate 3 authoritative — decimate note for D/E)") # JS heap: only meaningful if DBG.info() actually reports a nonzero heapMB (needs # performance.memory / --expose-gc). If it doesn't, say so — don't silently "pass". nonzero_heap = [h for h in heap_series if h] if len(nonzero_heap) >= 6: head = sum(nonzero_heap[:3]) / 3; tail = sum(nonzero_heap[-3:]) / 3 if tail - head > 100 and tail > head * 1.3: fails.append(f"JS heap climbed {head:.0f}→{tail:.0f} MB across the walk (possible leak)") else: warns.append("JS heap not reported by DBG.info().heapMB — heap-leak check skipped " "(expose it via performance.memory to enable gate 2's heap arm)") if cerr: fails.append(f"{len(cerr)} console error(s); first: {cerr[0][:160]}") report = { 'seed': seed, 'noassets': noassets, 'minutes': minutes, 'chunks': len(chunks_seen), 'shops_verified': shops_entered, 'peak_draws': peak_draws, 'peak_tris': peak_tris, 'heap_MB': [round(h) for h in (nonzero_heap[:1] + nonzero_heap[-1:])] if nonzero_heap else [], 'console_errors': len(cerr), 'warnings': warns, 'pass': not fails, } print(json.dumps(report, indent=2)) for w in warns: print(" ! ", w) if fails: print("\n✗ SOAK FAILED:") for f in fails: print(" -", f) sys.exit(1) print("\n✓ SOAK PASSED") if __name__ == '__main__': main()