PROCITY/tools/qa/town_matrix.py

196 lines
10 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 — 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()