#!/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, os, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = int(os.environ.get('PROCITY_QA_PORT', '8130')) # [R31] port-isolated suite runs (see flags_check.py) HOST = f'http://127.0.0.1:{PORT}' STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000 # [R21 ledger #6c] B's R20 tram ruling: on a REAL-ROADS town the tram only runs if its best main chain # fronts ≥5 shops — otherwise it's "a highway bus" and gets fenced. These two run; the other three are fenced. TRAM_RUN = {'newtown_real', 'castlemaine_real'} TRAM_STOPS_SANE = (2, 40) # B fixed the phantom-stop bug en route (Newtown: 149 → 16) # 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 rep = {}; // [R19] report sink → norm.mode (roads | marched) cg.generatePlanFor(plan.citySeed, src, { town, gigs: true, report: rep }); 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(); const tr = P.tram ? { fenced: !!(P.tram.routeInfo || {}).fenced, stops: P.tram.stops | 0, shopsFronted: (P.tram.routeInfo || {}).shopsFronted } : null; // [R21] B's fence verdict 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, mode: rep.mode || (src === 'synthetic' ? 'synthetic' : 'marched'), edges: (plan.streets.edges || []).length, tram: tr }; } """ 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, label): """Returns a dict of gate → (ok, note) for one town.""" row = {} is_real = label.startswith('real/') # ── 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") # [R19] roads dimension: real caches (schema v2 with roads[]) must build the REAL street graph # (mode='roads'); synthetic/fixture towns are marched/synthetic by design (always ✓). mode = d.get('mode', '?') row['roads'] = ((mode == 'roads') if is_real else True, f"{mode},{d.get('edges', 0)}e") # [R21 #6c] tram: B's R20 ruling — a real-roads town runs the tram only if its best main chain fronts # ≥5 shops; otherwise it's FENCED (empty tram group, 0 stops, no errors). Synthetic/fixtures always run. t, key = d.get('tram'), (label.split('/', 1)[1] if '/' in label else label) if is_real and key in TRAM_RUN: row['tram'] = (bool(t) and not t['fenced'] and TRAM_STOPS_SANE[0] <= t['stops'] <= TRAM_STOPS_SANE[1], f"run {t['stops']}st" if t else 'absent') elif is_real: row['tram'] = (bool(t) and t['fenced'] and t['stops'] == 0, f"fenced/{t.get('shopsFronted', '?')}sh" if t else 'absent') else: row['tram'] = (bool(t) and not t['fenced'], f"run {t['stops']}st" if t else 'absent') except Exception as e: row.setdefault('name', '?'); row.setdefault('shops', 0) for g in ('boot', 'determ', 'budget', 'district', 'roads', 'tram'): 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 × 7 gates (roads + tram dimensions) · the v4 real-map scout evidence)") srv = ensure_server() GATES = ['boot', 'determ', 'budget', 'district', 'roads', 'tram', '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, label) 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 7 gates.") sys.exit(0) if __name__ == '__main__': main()