PROCITY/tools/qa/tour_shots.py
m3ultra 9277238462 Lane F R16 (v3.1 THE FLIP) — close: default-boot tour + week soak + docs
The closing half of F's bookend. All gates green on the flipped tree (qa.sh --strict --soak: classic
regression + default-boot gate + smokes + no-giants incl. the seated drummer + the full-week soak).

- tour_shots.py -> docs/shots/release_v3_1/: the money shots on the DEFAULT boot (no per-shot flag juggling
  now the flip covers it) + a ?classic=1 before/after ("the town before v3"). 7 shots, settled, 0 page errors.
- week_soak.py: re-pointed to the default boot; the leak check now treats an at/below-baseline OR non-climbing
  trail as leak-free (a decreasing tex trail is GC/atlas-settle, not a leak — it was false-failing at warn-level).
- docs: README v3.1 flags table (defaults on + ?classic=1), CITY_SPEC v3.1 amendment (the rewritten prime
  law + classic covenant, A's deferred edit), LANE_F_NOTES §16, F-progress R16.

Numbers (seed 20261990): default @ venue block 124 draws / 57k tris (<=300/200k); ?classic 161 draws / 24k
tris (the v2 baseline); seated drummer 1.32 m stature; week soak 42 cycles leak-free (net +0/-6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:32:22 +10:00

183 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""PROCITY Lane F — the v3.0 release money-shot tour (round 15 · ?gigs=1). One deterministic run.
Captures the release set into docs/shots/release_v3/ (+ a contact.html), grouped by the flags each shot
needs, all from seed 20261990 (deterministic, reproducible — never hand-cropped):
• venue_night — lit pub frontage + streetlamp pools + posters at night (?gigs=1)
• district_posters — the spine at night: poster poles + lit frontages receding (?gigs=1)
• queue_night — JOHN'S HERO: close on the pub door at 'on', queue + marquee + A's now-visible poster
• gig_interior — the 4-piece with real instruments + a roster crowd (continuity) (?gigs=1)
• rain_verandah — a wet night outside the lit video-shop window (?weather=rain&winmap=1)
• tram_stop — the bus shelter the tram pauses at (?tram=1)
Discipline (ROUND15 ledger #5 + B's note): every shot is measured SETTLED — after the async instrument
GLBs / poster textures / chunk stream land (shoot-twice), from a clean per-group load (no orphan-chunk
inflation); the queue is driven LIVE (rAF is throttled in automation, so we run the animate loop + admit
explicitly rather than trust auto-drain).
Run: tools/.venv/bin/python tools/qa/tour_shots.py [--seed N]
Needs the Playwright venv + Lane B's ?dbg=1 hook (incl. queue_night). Writes full-frame 1280x720 PNGs.
"""
import sys, time, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = 8130
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
OUTDIR = ROOT / 'docs' / 'shots' / 'release_v3_1'
if '--seed' in sys.argv:
SEED = int(sys.argv[sys.argv.index('--seed') + 1])
# [R16 THE FLIP] shot groups — the DEFAULT boot now has all four flags on, so most shots come from ONE clean
# load (no per-shot flag juggling). Rain is still forced (the default seed rolls clear); ?classic=1 gives the
# before/after comparison. Deterministic, same seed.
GROUPS = [
{'flags': '', 'street': ['venue_night', 'district_posters', 'queue_night', 'tram_stop'], 'interior': True},
{'flags': 'weather=rain', 'street': ['rain_verandah'], 'interior': False},
# the town before v3 — the exact v2 boot; same main-street pose as district_posters for a clean A/B
{'flags': 'classic=1', 'street': ['district_posters'], 'interior': False, 'suffix': '_classic'},
]
# settle + re-pose a street bookmark once its live/async content (queue, instrument GLBs, poster textures,
# streamed chunks) has landed, then report SETTLED stats.
STREET_JS = r"""
async (name) => {
const P = window.PROCITY, D = window.DBG;
D.shot(name); // initial pose + segment (2 renders)
for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r)); // animate loop: queue spawns
const q = (P.gigs && P.venueQueues) ? P.venueQueues.get(P.gigs.venueShopIds[0]) : null;
await new Promise(r => setTimeout(r, 1400)); // async GLB / poster / chunk settle
const stat = D.shot(name); // RE-POSE now that everything is loaded → the settled frame
const queue = (P.gigs && P.queueCountOf) ? P.queueCountOf(P.gigs.venueShopIds[0]) : null;
const posters = (P.plan.posters || []).length;
return { name, draws: stat.drawCalls, tris: stat.tris, queue, posters };
}
"""
INTERIOR_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
const id = P.gigs.venueShopIds[0];
D.setSegment(5);
// drive the queue live + admit a couple so the interior crowd carries roster identities (continuity)
for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r));
const q = P.venueQueues && P.venueQueues.get(id);
if (q) for (let i = 0; i < 3 && q.count() > 0; i++) q.admitOne();
D.enterShop(id); await new Promise(r => setTimeout(r, 900));
const room = P.interiorMode.current; if (!room) return { ok: false };
const st = room.stage;
P.camera.position.set(st.x + 0.2, 1.6, room.dims.D / 2 - 1.2);
P.camera.lookAt(new THREE.Vector3(st.x, 1.35, st.z));
// shoot-twice / settled discipline (ledger #5): immediate vs after the instrument GLBs land
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
const trisImmediate = P.renderer.info.render.triangles;
await new Promise(r => setTimeout(r, 1600));
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
const info = P.interiorMode.crewInfo;
return { ok: true, draws: P.renderer.info.render.calls, trisImmediate, trisSettled: P.renderer.info.render.triangles,
band: info ? info.band : 0, crowd: info ? info.crowd : 0, fromRoster: info ? info.fromRoster : 0,
venue: (P.plan.shops.find(s => s.id === id) || {}).name, bandName: P.gigs.bandNameOf(id) };
}
"""
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 boot(pg, flags):
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1&{flags}')
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
try:
pg.wait_for_function('() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)', timeout=15000)
except Exception:
print(' ! fleet never readied — shooting placeholders')
def contact_html(shots):
cards = []
for s in shots:
cap = s['caption']
cards.append(
f'<figure><img src="{s["file"]}" alt="{s["name"]}" loading="lazy">'
f'<figcaption><b>{s["name"]}</b><br>{cap}</figcaption></figure>')
return (
'<!doctype html><meta charset="utf-8"><title>PROCITY v3.1 — release tour</title>'
'<style>body{background:#14110c;color:#f4efe6;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;margin:24px}'
'h1{font-weight:700}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:18px}'
'figure{margin:0;background:#1d1810;border-radius:10px;overflow:hidden}img{width:100%;display:block}'
'figcaption{padding:8px 12px;color:#cdc4b2}b{color:#ffd9a0}</style>'
f'<h1>PROCITY v3.1 — release money-shot tour (THE FLIP) <small>· seed {SEED} · settled measurements</small></h1>'
f'<div class="grid">{"".join(cards)}</div>')
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[1mv3.1 RELEASE MONEY-SHOT TOUR\033[0m (seed {SEED} → docs/shots/release_v3_1/)")
OUTDIR.mkdir(parents=True, exist_ok=True)
srv = ensure_server()
shots, errs_total = [], 0
try:
with sync_playwright() as p:
b = p.chromium.launch()
for grp in GROUPS:
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('pageerror', lambda e: errs.append(str(e)))
boot(pg, grp['flags'])
sfx = grp.get('suffix', '')
for name in grp['street']:
stat = pg.evaluate(STREET_JS, name)
fname = f'{name}{sfx}.png'
pg.screenshot(path=str(OUTDIR / fname))
cap = (f"?classic=1 — the town before v3 (no gig layer, no pools)" if sfx
else f"{stat['draws']} draws · {stat['tris']:,} tris")
if not sfx and stat.get('queue') is not None: cap += f" · queue {stat['queue']}"
if not sfx and name in ('district_posters', 'venue_night', 'queue_night'): cap += f" · {stat['posters']} posters"
shots.append({'name': name + sfx, 'file': fname, 'caption': cap})
print(f" \033[32m●\033[0m {name}{sfx}: {cap}")
if grp['interior']:
info = pg.evaluate(INTERIOR_JS)
if info.get('ok'):
out = OUTDIR / 'gig_interior.png'
pg.screenshot(path=str(out))
cap = (f"{info['venue']} · {info['bandName']} · {info['band']}-piece · crowd {info['crowd']} "
f"({info['fromRoster']} from roster) · {info['draws']} draws · "
f"{info['trisImmediate']:,}{info['trisSettled']:,} tris (pre-load→settled)")
shots.append({'name': 'gig_interior', 'file': 'gig_interior.png', 'caption': cap})
print(f" \033[32m●\033[0m gig_interior: {cap}")
else:
print(" \033[31m✗\033[0m gig_interior: could not stage")
errs_total += len(errs)
if errs: print(f" ! {len(errs)} page error(s) in [{grp['flags']}]: {errs[0][:100]}")
pg.close()
b.close()
finally:
if srv: srv.terminate()
(OUTDIR / 'contact.html').write_text(contact_html(shots))
print(f"\n\033[1m{len(shots)} shots\033[0m → docs/shots/release_v3_1/ (+ contact.html) · {errs_total} page errors")
sys.exit(1 if errs_total or len(shots) < 6 else 0)
if __name__ == '__main__':
main()