PROCITY/tools/shots.py
m3ultra 2698b402d1 Lane F R4 finalize: gate table all-6-green + v1 tour shots + interior-shot fix
Gate table (LANE_F_NOTES §4) rewritten to R4 all-green with measured evidence:
 1 determinism (selfcheck 1301/1301, fingerprint 0x3fa36874) · 2 soak (37 chunks/20 shops,
 heap 38->38MB, 0 errors, leak-free) · 3 budget (draws 138/tris 31k, decimated peds) ·
 4 asset-free (heap 13->13MB, 0 net) · 5 selfchecks (qa.sh --strict 4/4, 23 GLBs on depot) ·
 6 shots (10 full-frame 1280x720, no letterbox, night closed-facade + real interiors).

shots.py: interior tour shots (record/opshop) now capture via DBG.enterShop(id) directly —
they were passing {type} to enterShop (needs id) then calling shot() (which forces street
mode), so both showed streets. Now they show the themed interiors with keeper + hero props.

Verified by a 6-auditor adversarial v1-readiness workflow -> GO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:22:23 +10:00

161 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""PROCITY Lane F — beauty-shot / reference-delta harness (ported from 90sDJsim tools/shots.py).
Captures the fixed camera bookmarks the game exposes on window.DBG, writes them to
docs/shots/<label>/ and builds a contact sheet comparing this run | docs/shots/before/ | reference.
This is QA gate 6 (shots) and doubles as the Vuntra-style devlog material.
Run:
cd web && python3 -m http.server 8130 # other terminal
tools/.venv/bin/python tools/shots.py [label] [--seed N]
Setup:
python3 -m venv tools/.venv
tools/.venv/bin/pip install playwright && tools/.venv/bin/python -m playwright install chromium
Contract (Lane F asks Lane B for this — see docs/LANES/LANE_F_NOTES.md §4):
window.DBG.ready -> true once first chunks built + assets settled
window.DBG.shot(name) -> snap camera to a named bookmark, settle a frame
window.DBG.enterShop(id) -> for interior bookmarks (record_interior, milkbar_interior)
Boot the game with ?dbg=1 so DBG is exposed. If DBG is absent this degrades to a single
default-view frame and prints a warning (so it never hard-fails a pre-Lane-B run).
"""
import sys, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
HOST = 'http://127.0.0.1:8130'
# The v1 tour (LANE_F: 10 beauty shots). name -> optional {'enter': shopId or True}.
TOUR = [
'street_noon', # main street, midday, verandahs
'arcade', # covered pedestrian lane
'market_square', # stalls + dept anchor
'milkbar_dusk', # corner milk bar, 6-seg dusk light
'night_neon', # main street at night, window emissives
'warehouse_fringe', # sparse besser/corrugated edge
'residential_collar', # houses + front yards
'record_interior', # interior beauty shot (enters a record shop)
'opshop_interior', # a second interior archetype
'crossroads_busy', # busiest intersection, citizens (budget shot)
]
ENTER = { # shots that must open a door first; value True = let the game pick a type-appropriate shop
'record_interior': {'type': 'record'},
'opshop_interior': {'type': 'opshop'},
}
def parse_args(argv):
label, seed = 'latest', None
i = 0
rest = argv[1:]
while i < len(rest):
if rest[i] == '--seed':
seed = rest[i + 1] if i + 1 < len(rest) else None # guard trailing --seed (no value)
i += 2
else:
label = rest[i]; i += 1
return label, seed
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("playwright not installed — see setup header in this file.")
label, seed = parse_args(sys.argv)
url = f'{HOST}/?dbg=1' + (f'&seed={seed}' if seed else '')
out = ROOT / 'docs' / 'shots' / label
out.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errors = []
pg.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
pg.goto(url)
# DBG installs asynchronously (main() → dynamic import of dbg.js), so POLL for it — reading
# window.DBG the instant goto's load event fires races the async boot and false-reports absent.
try:
pg.wait_for_function("() => !!(window.DBG)", timeout=15000)
has_dbg = True
except Exception:
has_dbg = False
if not has_dbg:
print("⚠ window.DBG absent (Lane B shell not landed or ?dbg hook missing) — "
"capturing a single default frame only.")
pg.wait_for_timeout(2000)
pg.screenshot(path=str(out / 'default_view.png'))
b.close()
print(f"wrote {out/'default_view.png'}")
return
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
# dismiss the start splash (#pc-start, z-index 40) — DBG.shot() renders the canvas beneath it,
# but a headless capture never clicks "walk into town", so the overlay would cover every frame.
pg.evaluate("() => { const o = document.getElementById('pc-start'); if (o) o.style.display = 'none'; }")
# best-effort: let the rig fleet finish so peds/keepers show as GLB characters, not placeholders
try:
pg.wait_for_function("() => window.PROCITY && (!window.PROCITY.fleet || window.PROCITY.fleet.ready)",
timeout=12000)
except Exception:
pass
# A REAL viewport toggle (not a synthetic resize event) forces the EffectComposer to rebuild its
# render targets at the true size — headless can construct the composer before the viewport settles,
# which otherwise letterboxes every frame into a square sub-region.
pg.set_viewport_size({'width': 1281, 'height': 721}); pg.wait_for_timeout(120)
pg.set_viewport_size({'width': 1280, 'height': 720}); pg.wait_for_timeout(250)
for name in TOUR:
enter = ENTER.get(name)
if enter:
# Interior shot: DBG.shot() forces street mode + street bookmarks, so it can't frame an
# interior. Instead resolve an OPEN shop of the wanted type, enterShop(id) (which switches
# to interior mode + poses the camera at the room spawn), and capture that view directly.
pg.evaluate("""(t) => {
const P = window.PROCITY, D = window.DBG;
D.setSegment(2); // midday so the shop is open
const shops = P.plan.shops || [];
const shop = shops.find(s => s.type === t && (!P.isOpen || P.isOpen(s)))
|| shops.find(s => s.type === t);
if (shop) D.enterShop(shop.id);
}""", enter['type'])
pg.wait_for_timeout(900) # interior build + keeper/hero-prop GLBs settle
pg.screenshot(path=str(out / f'{name}.png'))
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(300)
else:
pg.evaluate("(n) => window.DBG.shot(n)", name)
pg.wait_for_timeout(900) # settle canvas textures, lights, async GLB swaps
pg.screenshot(path=str(out / f'{name}.png'))
print('shot', name)
b.close()
if errors:
print(f"{len(errors)} console error(s) during capture:")
for e in errors[:10]:
print(" ", e)
# contact sheet: this run | docs/shots/before | docs/shots/references
def cell(src, cap):
return (f'<figure><img src="{src}" loading="lazy"><figcaption>{cap}</figcaption></figure>'
if src else f'<figure class="empty"><figcaption>{cap} — none</figcaption></figure>')
shots_dir = ROOT / 'docs' / 'shots'
rows = []
for name in TOUR:
ref = next(iter((shots_dir / 'references').glob(name + '.*')), None) \
if (shots_dir / 'references').exists() else None
before = shots_dir / 'before' / f'{name}.png'
cells = cell(f'{label}/{name}.png', f'{name} ({label})')
if label != 'before' and before.exists():
cells += cell(f'before/{name}.png', 'before')
cells += cell(f'references/{ref.name}' if ref else None, 'reference')
rows.append(f'<h3>{name}</h3><div class="row">{cells}</div>')
(shots_dir / 'contact.html').write_text(
'<!doctype html><meta charset="utf-8"><title>PROCITY contact sheet</title>'
'<style>body{background:#111;color:#ddd;font:14px monospace;padding:16px}'
'.row{display:flex;gap:8px}figure{margin:0;flex:1;min-width:0}img{width:100%}'
'figcaption{padding:2px 0;color:#8f8}.empty{display:flex;align-items:center;'
'justify-content:center;border:1px dashed #444;color:#666;min-height:120px}</style>'
+ ''.join(rows))
print('contact sheet: docs/shots/contact.html')
if __name__ == '__main__':
main()