PROCITY/tools/soak.py
m3ultra aa4ed4fdca Lane F: integration — interiors + keepers + street peds + QA harness
interior_mode.js bridges Lane B's enterShop seam to Lane C buildInterior and Lane D
keepers (spawn at counter keeperStand, greet, leak-free over 5 cycles). CitizenSim
wired into the street loop (pop 140, ~96 active midday, worst ~191 draws). tools/:
qa.sh gate runner (all 4 landed gates GREEN --strict), scaffold/consistency checks,
shots.py + soak.py browser harnesses. docs/shots/ reference tree, LANE_F_NOTES
runbook, V2_IDEAS parking lot.

(F's inline seam edits to index.html/skins.js landed with the Lane B commit.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:29:03 +10:00

162 lines
7.9 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)
if not pg.evaluate("() => !!window.DBG"):
sys.exit("⚠ window.DBG absent — Lane B shell/hook not landed; soak cannot run yet. "
"(This is expected pre-Lane-B; the gate is defined and ready.)")
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
plan = pg.evaluate("() => window.DBG.plan || null")
if not plan:
sys.exit("⚠ DBG.plan not exposed — need the active CityPlan to script a walk.")
shops = plan.get('shops', [])
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) // 40)] if shops else []
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 = shop.get('lot') or shop
x, z = lot.get('x', 0), lot.get('z', 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({shopId:id})", shop.get('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})")
if peak_draws > BUDGET_DRAWS:
fails.append(f"draw calls peaked at {peak_draws} (budget {BUDGET_DRAWS})")
if peak_tris > BUDGET_TRIS:
fails.append(f"tris peaked at {peak_tris} (budget {BUDGET_TRIS})")
# 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()