§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>
193 lines
10 KiB
Python
193 lines
10 KiB
Python
#!/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()
|