F wired the last mile of the real-map scout and gated the whole matrix. - index.js barrel re-exports registerTownCache/validateTownCache; the shell fetches + registers assets/towns/<key>.json before generatePlanFor when the town isn't a checked-in fixture — so ?plansrc=osm&town=bendigo_real boots a REAL AU town in the engine (the gap: E's caches were on disk + in the node selfcheck, but the shell never loaded them → silent Melbourne fallback). Fail-soft (404 / invalid / ?noassets -> fixture fallback); opt-in, never on the default or ?classic path. - tools/qa/town_matrix.py (opt-in qa.sh --matrix): boot / determinism / budget / district / ?noassets across 10 towns (2 synthetic hero seeds + 3 osm fixtures + E's 5 real AU caches). ALL 5x10 GREEN — Castlemaine at the 6-shop floor still lands a 3-venue district. The existing CityPlan contract eats real Australian geography: the evidence John charters v4 = THE REAL MAP on. - docs: LANE_F_NOTES §17 (the town x gate table), README real-town row, F-progress R17. qa.sh --strict --soak GREEN 7/7. Verified: ?plansrc=osm&town=bendigo_real -> Bendigo, 9 shops, 3 venues, 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
7.8 KiB
Python
170 lines
7.8 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, 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()
|