#!/usr/bin/env python3 """PROCITY Lane F — the town-matrix smoke (round 17, v3.2 · the real-map scout gate · ledger #7). The gate side of the scout: run the core smokes across the FULL town matrix — synthetic hero seeds, the three checked-in osm fixtures, and Lane E's real AU town caches — and emit one town × gate table. This is the evidence John charters v4 (THE REAL MAP) on: proof the existing CityPlan contract eats real geography through the default boot, with no new game systems. Gates per town (all on the DEFAULT boot — gigs/weather/winmap/tram on, post-R16 flip): • boot — the plan builds + the shell reaches window.PROCITY.plan, 0 console errors • determ — two fresh in-page generations are byte-equal (the generator is deterministic) • budget — the busiest default view (venue block at night) ≤ 300 draws / 200k tris • district — the gig layer placed on this town: ≥1 venue, a week schedule, posters • noassets — ?noassets=1 boots silent (0 errors, 0 network); real caches fall back to a fixture (correct) Run: tools/.venv/bin/python tools/qa/town_matrix.py Needs the Playwright venv + Lane B's ?dbg=1 hook + Lane F's real-cache shell wiring (R17). """ import sys, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = 8130 HOST = f'http://127.0.0.1:{PORT}' STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000 # the matrix: (row-label, boot-query). Synthetic hero seeds + 3 fixtures + E's 5 real AU caches. MATRIX = ( [(f'synthetic/{s}', f'seed={s}') for s in (20261990, 1234)] + [(f'osm/{t}', f'plansrc=osm&town={t}') for t in ('melbourne', 'katoomba', 'silverton')] + [(f'real/{t}', f'plansrc=osm&town={t}') for t in ('bendigo_real', 'castlemaine_real', 'fremantle_real', 'katoomba_real', 'newtown_real')] ) # default-boot gates 1-4, in one page. Determinism regenerates the plan twice in-page (the real cache is # already registered by the shell) and compares — no second full boot needed. DEFAULT_JS = r""" async () => { const P = window.PROCITY, D = window.DBG; const plan = P.plan; const params = new URLSearchParams(location.search); const src = params.get('plansrc') === 'osm' ? 'osm' : 'synthetic'; const town = params.get('town') || undefined; const cg = await import('./js/citygen/index.js'); const g1 = JSON.stringify(cg.generatePlanFor(plan.citySeed, src, { town, gigs: true })); const g2 = JSON.stringify(cg.generatePlanFor(plan.citySeed, src, { town, gigs: true })); const venues = (plan.shops || []).filter(s => s.venue).length; D.shot('venue_night'); // busiest default view: the venue block at night await new Promise(r => setTimeout(r, 500)); const i = D.info(); return { name: plan.name, shops: (plan.shops || []).length, venues, gigs: (plan.gigs || []).length, posters: (plan.posters || []).length, determ: g1 === g2, draws: i.drawCalls, tris: i.tris }; } """ def port_up(port): with socket.socket() as s: s.settimeout(0.4) return s.connect_ex(('127.0.0.1', port)) == 0 def ensure_server(): if port_up(PORT): return None proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(50): if port_up(PORT): return proc time.sleep(0.1) proc.terminate(); sys.exit(f'could not start http.server on :{PORT}') def run_town(new_page, query): """Returns a dict of gate → (ok, note) for one town.""" row = {} # ── default boot: boot / determ / budget / district ── pg = new_page() errs = [] pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None) pg.on('pageerror', lambda e: errs.append(str(e))) try: pg.goto(f'{HOST}/index.html?{query}&dbg=1') pg.wait_for_function('window.PROCITY && window.PROCITY.plan && window.DBG && window.DBG.ready', timeout=25000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") try: pg.wait_for_function('() => !window.PROCITY.fleet || window.PROCITY.fleet.ready', timeout=12000) except Exception: pass d = pg.evaluate(DEFAULT_JS) row['name'] = d['name']; row['shops'] = d['shops'] row['boot'] = (bool(d['name']) and len(errs) == 0, f"{d['shops']} shops, {len(errs)} err") row['determ'] = (d['determ'], 'byte-equal' if d['determ'] else 'DIFF') row['budget'] = (d['draws'] <= STREET_DRAWS_MAX and d['tris'] <= STREET_TRIS_MAX, f"{d['draws']}d/{d['tris']//1000}k") row['district'] = (d['venues'] >= 1 and d['gigs'] > 0, f"{d['venues']}v/{d['gigs']}g/{d['posters']}p") except Exception as e: row.setdefault('name', '?'); row.setdefault('shops', 0) for g in ('boot', 'determ', 'budget', 'district'): row.setdefault(g, (False, str(e)[:24])) finally: pg.close() # ── ?noassets=1: silent-and-fine (real caches correctly fall back to a fixture — no network) ── pg = new_page() reqs, errs2 = [], [] pg.on('request', lambda r: reqs.append(r.url) if ('/assets/towns/' in r.url or '/assets/audio/' in r.url or r.url.endswith('.glb')) else None) pg.on('pageerror', lambda e: errs2.append(str(e))) try: pg.goto(f'{HOST}/index.html?{query}&noassets=1&dbg=1') pg.wait_for_function('window.PROCITY && window.PROCITY.plan', timeout=20000) has_plan = pg.evaluate("() => !!(window.PROCITY.plan && window.PROCITY.plan.shops.length)") row['noassets'] = (has_plan and len(errs2) == 0 and len(reqs) == 0, f"{len(reqs)} fetch/{len(errs2)} err") except Exception as e: row['noassets'] = (False, str(e)[:24]) finally: pg.close() return row def main(): try: from playwright.sync_api import sync_playwright except ImportError: sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') print(f"\033[1mTOWN-MATRIX SMOKE\033[0m ({len(MATRIX)} towns × 5 gates · the v4 real-map scout evidence)") srv = ensure_server() GATES = ['boot', 'determ', 'budget', 'district', 'noassets'] rows = [] try: with sync_playwright() as p: b = p.chromium.launch() def factory(): return b.new_page(viewport={'width': 1280, 'height': 720}) for label, query in MATRIX: r = run_town(factory, query) rows.append((label, r)) cells = ' '.join(f"{g}:{'✓' if r[g][0] else '✗'}" for g in GATES) print(f" {label:22} {(r.get('name') or '?'):16} {cells}") b.close() finally: if srv: srv.terminate() # the table John reads print(f"\n\033[1mTOWN × GATE\033[0m") hdr = f"{'town':22} {'name':16} " + " ".join(f"{g:>9}" for g in GATES) print(hdr); print('─' * len(hdr)) fails = 0 for label, r in rows: line = f"{label:22} {(r.get('name') or '?'):16} " for g in GATES: ok, note = r[g] fails += 0 if ok else 1 line += f"{('✓ ' if ok else '✗ ') + note:>9} "[:10] + " " print(line) # per-gate note detail print("\nnotes:", "; ".join(f"{label}[{g}={r[g][1]}]" for label, r in rows for g in GATES if not r[g][0]) or "all gates green") print() if fails: print(f"\033[31m● {fails} gate failure(s)\033[0m across {len(rows)} towns — the matrix is not clean.") sys.exit(1) print(f"\033[32m● MATRIX GREEN\033[0m — {len(rows)} towns (synthetic + fixtures + real caches) pass all 5 gates.") sys.exit(0) if __name__ == '__main__': main()