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