#!/usr/bin/env python3 """PROCITY Lane F — R39: RE-BASELINE the two frames that photograph the spawn pose. Lane B's `+sin` fix moved the non-classic spawn (`index.html:500`, the R32 cluster-pose). Two of this lane's picture baselines were taken at the OLD pose and are therefore photographs of the defect: · `docs/shots/v7_tour/02_the_strip.png` — "the cluster-pose spawn: midday on the main street" · `docs/shots/v7_beta/first_five_splash.png` — the R35 first-five-minutes splash, shot at the spawn Those two files are **not overwritten**: they are artefacts of the tagged v7.0 epoch and re-shooting them on a v9 tree would falsify a release record. The re-baseline lands in `docs/shots/laneF_r39/` with its own BEFORE (the same tree, camera parked at the old `-sin` pose), so the pair is a comparison rather than a claim. Run: tools/.venv/bin/python tools/qa/r39_shots.py """ import sys, os, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent OUT = ROOT / 'docs' / 'shots' / 'laneF_r39' PORT = int(os.environ.get('PROCITY_R39_SHOT_PORT', '8761')) HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 NOSTORE = r''' import sys, http.server, functools class H(http.server.SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Cache-Control', 'no-store'); super().end_headers() def log_message(self, *a): pass http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever() ''' # both spawn derivations, from the live plan — identical to tools/qa/r39_runtime.py's JS_SIGN JS_POSES = r""" () => { const P = window.PROCITY, plan = P.plan; const lotById = new Map(plan.lots.map(l => [l.id, l])); const spawnWith = (sgn) => { const ms = new Set((plan.blocks || []).filter(b => b.kind === 'mainstreet').map(b => b.id)); const doorOf = (s) => { const l = lotById.get(s.lot); if (!l) return null; const ry = l.ry || 0; return { x: l.x + sgn * Math.sin(ry) * (l.d / 2 + 2.6), z: l.z + sgn * Math.cos(ry) * (l.d / 2 + 2.6), block: l.block }; }; let doors = plan.shops.map(doorOf).filter(Boolean); const m = doors.filter(d => ms.has(d.block)); if (m.length >= 2) doors = m; if (doors.length < 2) return null; let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length; let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; } const ax = vx >= vz ? 'x' : 'z'; let stand = doors[0], bd = Infinity; for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } } let lo = Infinity, hi = -Infinity; for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); } const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; return { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) }; }; return { fixed: spawnWith(1), old: spawnWith(-1) }; } """ def port_up(port): with socket.socket() as s: s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0 def main(): from playwright.sync_api import sync_playwright OUT.mkdir(parents=True, exist_ok=True) proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(PORT), str(ROOT / 'web')], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(80): if port_up(PORT): break time.sleep(0.1) shots = [] try: with sync_playwright() as p: b = p.chromium.launch() pg = b.new_page(viewport={'width': 1280, 'height': 720}) for town, q, seg, tag in (('synthetic', '', 2, 'the_strip'), ('katoomba_real', 'plansrc=osm&town=katoomba_real', 2, 'the_strip_katoomba')): pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&roster=v1&pop=0' + (('&' + q) if q else '')) pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000) pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") pg.wait_for_timeout(2500) poses = pg.evaluate(JS_POSES) pg.evaluate('(s) => window.DBG.setSegment(s)', seg) for which in ('old', 'fixed'): pz = poses[which] pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) try: pg.wait_for_function('() => window.PROCITY.chunks.pending === 0', timeout=20000) except Exception: pass pg.wait_for_timeout(2000) pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) name = f"{tag}-{'BEFORE-minus-sin' if which == 'old' else 'AFTER-plus-sin'}.png" pg.screenshot(path=str(OUT / name)) shots.append(name) print(f" {name} @ ({pz['x']:.1f}, {pz['z']:.1f}) yaw {pz['yaw']:.3f}") # the R35 first-five frame: a FRESH game at the fixed spawn, splash + hunt line visible b2 = p.chromium.launch() pg2 = b2.new_page(viewport={'width': 1280, 'height': 720}) pg2.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&roster=v1&pop=0') pg2.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000) pg2.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }") pg2.wait_for_timeout(2500) pz = pg2.evaluate(JS_POSES)['fixed'] pg2.evaluate('() => window.DBG.setSegment(2)') pg2.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) try: pg2.wait_for_function('() => window.PROCITY.chunks.pending === 0', timeout=20000) except Exception: pass pg2.wait_for_timeout(2000) pg2.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [pz['x'], pz['z'], pz['yaw']]) hunt = pg2.evaluate("""() => { const h = document.getElementById('pc-hunt'); return h ? { shown: h.style.display !== 'none', text: h.textContent } : null; }""") pg2.screenshot(path=str(OUT / 'first_five-AFTER-plus-sin.png')) shots.append('first_five-AFTER-plus-sin.png') print(f" first_five-AFTER-plus-sin.png hunt line: {hunt}") b2.close(); b.close() finally: proc.terminate() print(f"\n{len(shots)} frames → {OUT}") if __name__ == '__main__': sys.exit(main())