PROCITY/tools/qa/gig_shot.py
jing 2498fdc892 Lane F R12: wire the v3.0-alpha gig layer (?gigs=1) — state machine, cover charge, band+crowd, gig audio
F last: five landed lanes → one Friday night. Seed 20261990 → The Thornbury Hotel,
tonight SCREAMING UTES, $10 on the door. qa.sh --strict GREEN 6/6.

- gig_state.js (new, F-owned): quiet → doors (DUSK) → on (NIGHT) → done (DAWN) → quiet.
  Rides lighting's procity:segment event (a poll only sees the segments it samples, and
  missed the night roll entirely); `state` is a getter because B's audio reads it from its
  own rAF while the player is inside a shop.
- ?gigs=1 seam: custom_bands.json fetched in the bootstrap (no fetch flag-off or ?noassets)
  → generatePlanFor(seed, src, {gigs:true, customBands}). 7 of John's 10 names fill the week.
- Cover charge on the R8 buy seam, zero new UI: paid debits once with a toast, re-entry the
  same night is free, broke → polite knockback, free nights walk straight in.
- interior_mode: opts.gig → C's gigKey; D's GigCrew (band 3 + crowd 8, 3 dancing) at C's
  stage/watch points; both disposed with the room (leak-free over 4 cycles).
- Cross-lane fix at the glue: C emits gigKey 'gig-pubrock', E shipped the bed as
  'pubrock-live' — unresolved the pub plays to a silent room and nothing errors. Bridged
  here; C/E/Fable owe CITY_SPEC a contract line. Details + 2 more findings in LANE_F_NOTES §12.
- smoke_gigs (16 gates): schedule determinism, band==3, crowd ≤ watchPoints, dance mix, bed
  resolves, ≤350 draws (47 rig / 171 placeholder), no-giants by STATURE (the band stands on a
  0.32m deck, so world-crown fails a correct band), cover ruling, ?noassets+?gigs.
  Flags-off regression gains a v3 arm: no state machine, no plan.gigs, no pub, no venue geo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:09:56 +10:00

101 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""PROCITY Lane F — v3.0-alpha acceptance shot (round 12).
The money shot the round asks for: gig ON, band on Lane C's deck, mixed standing/dancing crowd at the
watch points, everything human-sized next to the door/bar/ceiling. Reproducible so the shot can be
re-taken when the gig layer changes (and so it is never a hand-cropped screenshot nobody can redo).
Run: tools/.venv/bin/python tools/qa/gig_shot.py [--seed N] [--out PATH]
Needs the Playwright venv + Lane B's ?dbg=1 hook. Writes a full-frame 1280x720 PNG.
"""
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
OUT = ROOT / 'docs' / 'shots' / 'laneF' / 'r12_v3alpha_gig.png'
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
if '--out' in sys.argv: OUT = pathlib.Path(sys.argv[sys.argv.index('--out') + 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 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}')
# Stand back by the door at eye height and look at the stage over the crowd's heads. Forcing one explicit
# render (rather than trusting rAF) keeps the shot deterministic in headless.
POSE_JS = r"""
async () => {
const P = window.PROCITY, D = window.DBG, THREE = P.THREE;
D.setSegment(5); // NIGHT — the gig is on
D.enterShop(P.gigs.venueShopId);
await new Promise(r => setTimeout(r, 900)); // room build + crew spawn + rig clips settle
const room = P.interiorMode.current;
if (!room) return { ok: false, why: 'not in the venue', state: P.gigs.state };
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));
P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera);
const crew = P.interiorMode.crew;
return { ok: true, venue: (P.plan.shops.find(s => s.venue) || {}).name, band: P.gigs.bandName,
state: P.gigs.state, cover: P.gigs.cover, crew: P.interiorMode.crewInfo,
dancers: crew ? crew.members.filter(m => m.part === 'crowd' && m.dance).length : 0,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
}
"""
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')
srv = ensure_server()
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1')
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 the placeholder crew')
info = pg.evaluate(POSE_JS)
if not info.get('ok'):
b.close(); sys.exit(f'\033[31m✗ could not stage the shot: {info}\033[0m')
OUT.parent.mkdir(parents=True, exist_ok=True)
pg.screenshot(path=str(OUT))
b.close()
finally:
if srv: srv.terminate()
print(f"\033[32m● SHOT\033[0m {OUT.relative_to(ROOT)}")
print(f" {info['venue']} · {info['band']} · state={info['state']} · ${info['cover']} cover")
print(f" band {info['crew']['band']} · crowd {info['crew']['crowd']} ({info['dancers']} dancing) · "
f"{info['draws']} draws · {info['tris']:,} tris")
if errs: print(f" ! {len(errs)} page error(s): {errs[0][:120]}")
if __name__ == '__main__':
main()