PROCITY/tools/shots.py
m3ultra aa4ed4fdca Lane F: integration — interiors + keepers + street peds + QA harness
interior_mode.js bridges Lane B's enterShop seam to Lane C buildInterior and Lane D
keepers (spawn at counter keeperStand, greet, leak-free over 5 cycles). CitizenSim
wired into the street loop (pop 140, ~96 active midday, worst ~191 draws). tools/:
qa.sh gate runner (all 4 landed gates GREEN --strict), scaffold/consistency checks,
shots.py + soak.py browser harnesses. docs/shots/ reference tree, LANE_F_NOTES
runbook, V2_IDEAS parking lot.

(F's inline seam edits to index.html/skins.js landed with the Lane B commit.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:29:03 +10:00

130 lines
5.7 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)
has_dbg = pg.evaluate("() => !!(window.DBG)")
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)
for name in TOUR:
enter = ENTER.get(name)
if enter:
pg.evaluate("(o) => window.DBG.enterShop(o)", enter)
pg.wait_for_timeout(600)
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'))
if enter:
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
pg.wait_for_timeout(300)
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()