PROCITY/tools/soak.py
m3ultra cb3aed196a Lane F R9 F3+F4: v2.0 tour (11 shots) + full-default & all-on soaks + docs freeze
F3: tools/v2_tour.py shoots 12-shot-class tour across flag-groups → docs/shots/v2_tour/ (11 shots,
all un-letterboxed 1280x720) incl. the rain-verandah money shot (night rain + winmap-lit open video
shop + CLOSED dark neighbours), browsers-in-a-shop, dig-real, katoomba, tram_stop + contact sheet.
soak.py gained --flags; full-default soak PASS (37 chunks/20 shops/heap stable/0 errors/leak-free)
and all-on soak (winmap+dig+stock=real+weather=rain+tram) PASS identically.

F4: F-progress round-9 section + FINAL v2 flags table (roster/patronage default-on; dig/winmap/
stock/weather/plansrc/tram/noassets opt-in; goldens frozen). LANE_F_NOTES v2.0 freeze note. CityPlan
v2 contract (A's CITY_SPEC freeze) validated live: selfcheck 1727/1727 + goldens. qa.sh --strict GREEN 5/5.

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

195 lines
10 KiB
Python
Raw Permalink 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
extra = flag('--flags') # arbitrary query flags, e.g. --flags 'winmap=1&dig=1&stock=real&weather=rain&tram=1'
qs = '?dbg=1' + (f'&seed={seed}' if seed else '') + ('&noassets=1' if noassets else '') \
+ (f'&{extra}' if isinstance(extra, str) 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()