PROCITY/tools/soak.py
m3ultra 53f472d0d7 Lane F R3: hours/closed-shops + manifest/rig-fleet/?noassets + --depot + QA harness fixes
§3.5 hours: isOpen() gates enterShop — closed shops locked; at night only the openLate video
rental opens. §3.4: ?noassets (zero-network primitive path, verified via Performance API),
interior fitting GLB upgrades from the 3GOD depot (useGLB), and the rig-fleet upgrade
(models/peds/*.glb) so peds + keepers become shared GLB rigs, leak-free (shared loadGLB cache
proven, not a leak). qa.sh manifest gate now --depot (16 GLBs live) — GREEN 4/4.

Browser QA harness fixes so shots.py/soak.py actually run: DBG async-poll (was racing the async
install), plan via window.PROCITY (DBG.plan unexposed), enterShop bare-id, shop.lot is a lot id,
midday pin (§3.5 else closes shops mid-walk), GLB-cache warmup, budget peaks -> warnings (gate 3
clean pass is authoritative). Gate 2 soak PASS both asset modes; gate 4 asset-free PASS;
docs/shots/v1_tour/ (10 shots) + contact sheet captured.

Findings (LANE_F_NOTES §9): D/E decimate tri-heavy ped GLBs (gate 3 tris ~279k > 200k budget;
draws fine ~200/300). B: add 3 shots bookmark poses, fix headless composer letterbox in
DBG.shot(), render the §3.5 closed-facade visual (predicate window.PROCITY.isOpen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:19:11 +10:00

193 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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