PROCITY/tools/qa/r41_shots.py
m3ultra 78f49f7113 Lane D R41 §41.3: the town stops walking — 99.3% to 78.1%, at zero draws
THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that
turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean
0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20.
The town was 99.3% people walking because standing still had nowhere to happen.

Wiring: new postures.js + clipbank.js. idles.glb (10/10) drives a per-citizen deterministic
idle on every near-tier actor plus the seeded shopkeeper. locomotion gives 33.7% of walkers a
shopping bag. sitlean (8/8, lazy) puts 4 sits on Lane B's ACTUAL benches and 4 leans on
shopfront walls. browse (5/8, lazy) is a real BROWSE state at C's browse points, seeded per
(shopId, slot). venue (5/6, lazy) widens the gig crowd, plus a publican pouring and a record
keeper in headphones. social (0/8) is never fetched — two-person conversation needs a paired
state machine, filed to R42.

Cost: boot = 4 requests, 1.24 MB / 16 clips resident; the rest lazy on first need; heap delta
+3.34 MB; mixer median 0.1 ms both arms. ?clips=0 / ?classic=1 / ?noassets=1 fetch ZERO clips
— not even clipbank.js (dynamic import). No shell edit needed.

DRAWS: +0 on every bookmark (street_noon 193, crossroads 108, night_crowd 128, market_square
94, night_neon 111, interior 110 — identical both arms). Ruling 4 respected exactly.

DETERMINISM: 150 citizens, two fresh contexts, byte-equal posture signature. Controls: seed+1
differs; EVERY clip GLB delayed 2 s -> identical signature (posture is a pure function of
(citySeed, id), never of residency). 6 new streams collide with none of the 12 pre-R41 keys.

TWO FINDINGS THAT CHANGED THE DESIGN: the idle pool was INVISIBLE — wired only to the R17/R29
node loiter, so only 0.8% of citizens were ever stopped; and the lean never fired at all (0 in
a 9 s run). Both moved to the patronage stride check. Bench stations are GATED not trusted:
14/14 derived stations coincide with real instanced geometry within 2 cm, and the control
(same stations offset 2 m) matches 0/14. Filed to B: one benchStops(plan) export retires the
mirror, and furniture.js puts the bench's front ALONG the street rather than facing the road,
contradicting its own comment.

Leak: +0 geometries, +1 texture over 6 enter/exit cycles. Goldens 157,647/157,647, 0x5f76e76.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:11:30 +10:00

411 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""PROCITY Lane D — R41 §41.3 acceptance shots: THE STREET STOPS BEING ONE PERSON COPY-PASTED.
tools/.venv/bin/python tools/qa/r41_shots.py [--seed N] [--outdir DIR] [--soak MS]
Two reproducible frames, both chosen by MEASUREMENT rather than by hand:
1. r41_street_postures.jpg the camera pose comes from geometry, not taste: the bench station with
the most patronage door points around it (the one block where BOTH R41 street states can fire in
a single frame), stood off on the carriageway and occlusion-raycast before it is accepted. Then
it HOLDS and waits — postures run on 6-20 s dwells, so the honest way to shoot the busy moment is
to stand still until it happens. Every candidate figure is ray-tested from the lens too: "inside
the frustum" is not "in the picture", and three earlier runs reported 3-6 rigs in shot while the
JPEG showed footpath and a gum-tree billboard.
2. r41_browse_interior.jpg the shop with the most patronage occupants that actually has browse
points, entered through the shell's own enterShop and framed from Lane C's room.spawn — the view
the game gives you walking in. The browser rigs are the peds who ducked in off the street, now
playing browse.glb instead of the shopkeeper's idle.
Every figure in each frame is measured for STATURE (feet->crown span off the posed skeleton) and
reported as the R10 no-giants line, written into a sidecar .txt beside each shot. The check is
height-RELATIVE on purpose (see human_line): fails on a giant (>2.0 m) or a fold (<55% of that
citizen's own nominal height), not on an absolute seated band that a wall lean legitimately breaks.
Fresh headless context, own no-store server (this project's documented ES-module cache burn).
"""
import sys, os, time, json, socket, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
PORT = int(os.environ.get('PROCITY_R41_SHOT_PORT', '8983'))
HOST = f'http://127.0.0.1:{PORT}'
SEED = 20261990
SOAK = 60000
OUTDIR = ROOT / 'docs' / 'shots' / 'laneD'
if '--seed' in sys.argv: SEED = int(sys.argv[sys.argv.index('--seed') + 1])
if '--soak' in sys.argv: SOAK = int(sys.argv[sys.argv.index('--soak') + 1])
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
LO, HI = 1.4, 2.0 # R10 no-giants standing band
SEAT_LO, SEAT_HI = 0.9, 1.5 # R16 seated band
NOSTORE = r'''
import sys, http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
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()
'''
# ── PLACE: stand on the road opposite the bench nearest the retail heart ──────────────────────────
# Deterministic and re-findable: the town anchor (DBG's densest-shop-cluster centroid) picks the
# block, the sim's OWN benchStationsFor picks the bench, and the camera stands out on the carriageway
# so the footpath reads unobstructed. Standing ON the footpath was tried first and framed a shopfront
# wall — the peds are at the back of a 3.5 m verge, so there is no room to back off beside them.
PLACE = r"""
async () => {
const P = window.PROCITY, C = P.citizens;
const THREE = await import('three');
const mod = await import('./js/citizens/sim.js');
const V3 = P.scene.position.constructor;
const ray = new THREE.Raycaster(); ray.far = 40;
// Score every bench station by how many patronage door points sit within 14 m: that is the block
// where BOTH R41 street states fire (bench sits at the station, shopfront leans off those doors),
// so it is the one place a single frame can hold both. Camera 7 m out on the carriageway at a 45°
// three-quarter, looking a little further down the footpath. Occlusion-raycast before accepting —
// an unchecked pose put the camera inside a building twice, high score and a grey wall.
const doors = [];
if (C.shopsByChunk) for (const list of C.shopsByChunk.values()) for (const d of list) doors.push(d);
const clear = (px, pz, tx, tz) => {
const d = Math.hypot(tx - px, tz - pz);
ray.set(new V3(px, 1.6, pz), new V3((tx - px) / d, 0, (tz - pz) / d));
let h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh && !x.object.isSprite);
if (h.length && h[0].distance < d - 0.8) return false;
for (const [ax, az] of [[1,0],[-1,0],[0,1],[0,-1]]) {
ray.set(new V3(px, 1.6, pz), new V3(ax, 0, az));
h = ray.intersectObject(P.scene, true).filter(x => !x.object.isSkinnedMesh);
if (h.length && h[0].distance < 1.3) return false;
}
return true;
};
const out = [];
for (let i = 0; i < C.edges.length; i++) {
const e = C.edges[i];
if ((e.width || 4) < 10) continue;
const L = e.len, ux = e.ux, uz = e.uz, nxE = -uz, nzE = ux;
for (const st of mod.benchStationsFor(e)) {
let nd = 0;
for (const d of doors) if (Math.hypot(d.x - st.x, d.z - st.z) < 14) nd++;
if (!nd) continue;
// which side of the centreline the bench is on → put the camera on the road, same side
const bd = Math.hypot(st.x - (e.A.x + ux * st.s), st.z - (e.A.z + uz * st.s)) || 1;
const nx = (st.x - (e.A.x + ux * st.s)) / bd, nz = (st.z - (e.A.z + uz * st.s)) / bd;
// ON THE CARRIAGEWAY at a quarter of the half-width from the centreline, 8 m back along the
// street, aimed square at the bench. This is the geometry every readable PROCITY street shot
// has: the footpath fills the mid-ground at 12-15 m, the verandah is above the heads, and
// nothing can get between the lens and the subject. Every closer variant tried (4 m, 7 m off
// the ped) put the camera under an awning or inside a shopfront window box.
const back = Math.min(bd * 0.55, 9); // out toward the road, but stay on the deck
for (const sgn of [1, -1]) {
const px = st.x - nx * back - ux * 5 * sgn, pz = st.z - nz * back - uz * 5 * sgn;
out.push({ nd, px, pz, yaw: Math.atan2(-(st.x - px), -(st.z - pz)), bx: st.x, bz: st.z });
}
}
}
out.sort((p, q) => q.nd - p.nd);
const keep = [];
for (const c of out) { if (keep.length >= 6) break; if (clear(c.px, c.pz, c.bx, c.bz)) keep.push(c); }
return { ok: keep.length > 0, cands: keep, stations: out.length / 2 | 0 };
}
"""
# ── FRAME: how good is the view from RIGHT HERE, right now — states and clips inside a tight box ───
FRAME = r"""
async () => {
const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor;
const THREE = await import('three');
const cam = P.camera; cam.updateMatrixWorld(true);
// Per-ped VISIBILITY raycast. "Inside the frustum" is not "in the picture": three frames running
// measured 3-6 rigs in shot while the JPEG showed footpath and a gum-tree billboard. A ped hidden
// behind a verandah post, a tree or a shopfront is not evidence of anything, so each one is
// ray-tested from the lens and only the ones that can actually be SEEN are counted.
const ray = new THREE.Raycaster(); ray.far = 30;
const visible = (c) => {
const dx = c.x - cam.position.x, dz = c.z - cam.position.z, d = Math.hypot(dx, dz);
ray.set(new V3(cam.position.x, 1.35, cam.position.z), new V3(dx / d, 0, dz / d));
const h = ray.intersectObject(P.scene, true)
.filter(x => !x.object.isSkinnedMesh && !x.object.isSprite && !x.object.isPoints);
return !(h.length && h[0].distance < d - 0.5);
};
const v = new V3(); const rows = [];
for (const c of C.activeCitizens()) {
if (!c.actor || c.actorKind !== 'rig') continue;
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
if (d < 3.0 || d > 20) continue; // close enough to READ
v.set(c.x, 1.0, c.z).project(cam);
if (v.z > 1 || Math.abs(v.x) > 0.62 || Math.abs(v.y) > 0.55) continue; // comfortably in shot
if (!visible(c)) continue; // and not behind a post
const posedNow = c.loiter > 0;
rows.push({ id: c.id, d: +d.toFixed(1),
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'),
clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
: (posedNow ? c.posture.idle : c.posture.walk) });
}
const states = new Set(rows.map(r => r.state)), clips = new Set(rows.map(r => r.clip));
const posed = rows.filter(r => r.state === 'bench-sit' || r.state === 'lean').length;
return { n: rows.length, states: [...states], clips: clips.size, posed,
score: clips.size * 100 + rows.length * 25 + posed * 350 };
}
"""
# ── measure every figure in the frame (the human-sized line) ──────────────────────────────────────
MEASURE = r"""
() => {
const P = window.PROCITY, C = P.citizens;
const V3 = P.scene.position.constructor;
const cam = P.camera; cam.updateMatrixWorld(true);
const v = new V3(); const rows = [];
for (const c of C.activeCitizens()) {
if (!c.actor || c.actorKind !== 'rig') continue;
const d = Math.hypot(c.x - cam.position.x, c.z - cam.position.z);
if (d < 2.2 || d > 26) continue;
v.set(c.x, 1.0, c.z).project(cam);
if (v.z > 1 || Math.abs(v.x) > 0.95 || Math.abs(v.y) > 0.95) continue;
let lo = 1e9, hi = -1e9;
c.actor.inner.updateWorldMatrix(true, true);
c.actor.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
// NB `c.sit` / `c.glance` are LATCHED flags (R17/R29 set them at a node and never clear them);
// the ped is only actually posed while `c.loiter > 0`. Reading them raw mislabels a walker as
// seated — which is exactly how this harness first reported a 1.63 m "seated" figure.
const posedNow = c.loiter > 0;
rows.push({ id: c.id, d: +d.toFixed(1),
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk'),
clip: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
: (posedNow ? c.posture.idle : c.posture.walk),
seated: !!c.bench || !!(posedNow && c.sit), h: +c.height.toFixed(3),
stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3) });
}
rows.sort((a, b) => a.d - b.d);
return { rows, info: window.DBG.info(), clips: C.clipStats() };
}
"""
INTERIOR = r"""
async () => {
const P = window.PROCITY, D = window.DBG, C = P.citizens;
// Shops that currently hold patronage occupants — occupancy is what puts BROWSERS on the floor (F
// stands one per browse point per occupant). Not every room type HAS browse points (a market stall
// has one, an op-shop three), so try several and keep the BEST rather than the first that works:
// taking the first gave a one-browser stall on one run and a three-browser op-shop on the next.
const cands = (P.plan.shops || [])
.map((s) => ({ id: s.id, name: s.name, type: s.type, count: C.occupancyOf(s.id).count }))
.filter((s) => s.count > 0).sort((a, b) => b.count - a.count);
if (!cands.length) return { ok: false, why: 'no shop has occupants yet' };
D.setSegment(2);
let best = null;
for (const s of cands.slice(0, 8)) {
D.enterShop(s.id);
await new Promise(r => setTimeout(r, 2600)); // room build + browser spawn + lazy browse.glb
const room = P.interiorMode.current;
const n = room ? (room.browsePoints || []).length : 0;
const browsers = ((P.interiorMode.keepers || {}).keepers || []).filter((k) => k.browse).length;
if (room && n && browsers && (!best || browsers > best.browsers)) best = { shop: s, browsePoints: n, browsers };
if (best && best.browsers >= 3) break;
D.exitShop();
await new Promise(r => setTimeout(r, 500));
}
if (!best) return { ok: false, why: 'no occupied shop had browse points with browsers', cands: cands.slice(0, 6) };
D.enterShop(best.shop.id); // re-enter the winner
await new Promise(r => setTimeout(r, 2600));
return { ok: true, ...best, tried: cands.length };
}
"""
INT_FRAME = r"""
() => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
const room = P.interiorMode.current;
const km = P.interiorMode.keepers;
const figs = (km ? km.keepers : []).map(k => k.actor.fig);
if (!figs.length) return { ok: false, why: 'no keeper/browser figures' };
// Stand where the PLAYER stands walking in (Lane C's room.spawn) and look at the centroid of the
// shop's people — so the frame is the view the game actually gives you, not a staged angle. Backed
// off toward the spawn wall if that puts the camera on top of somebody.
let mx = 0, mz = 0; for (const f of figs) { mx += f.position.x; mz += f.position.z; } mx /= figs.length; mz /= figs.length;
const sp = room.spawn || { x: 0, z: 0 };
let px = sp.x, pz = sp.z;
let d = Math.hypot(mx - px, mz - pz);
if (d < 2.6) { const k = 2.6 / (d || 1); px = mx - (mx - px) * k; pz = mz - (mz - pz) * k; d = 2.6; }
P.camera.position.set(px, 1.62, pz);
P.camera.lookAt(new V3(mx, 1.15, mz));
P.camera.updateMatrixWorld(true);
P.renderer.info.reset();
P.renderer.render(P.interiorMode.scene, P.camera);
// who is actually inside the frame
const v = new V3(); let seen = 0;
for (const f of figs) { v.set(f.position.x, 1.0, f.position.z).project(P.camera);
if (v.z <= 1 && Math.abs(v.x) < 0.9 && Math.abs(v.y) < 0.9) seen++; }
return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+mx.toFixed(2), +mz.toFixed(2)],
figures: figs.length, inFrame: seen,
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
}
"""
INT_MEASURE = r"""
() => {
const P = window.PROCITY, V3 = P.scene.position.constructor;
const km = P.interiorMode.keepers;
const rows = [];
for (const k of (km ? km.keepers : [])) {
const a = k.actor; if (!a.inner) { rows.push({ kind: k.browse ? 'browser' : 'keeper', placeholder: true }); continue; }
let lo = 1e9, hi = -1e9;
a.inner.updateWorldMatrix(true, true);
a.inner.traverse(o => { if (o.isBone) { const w = new V3(); o.getWorldPosition(w); if (w.y < lo) lo = w.y; if (w.y > hi) hi = w.y; } });
rows.push({ kind: k.browse ? 'browser' : 'keeper', shopId: k.shopId, type: k.type, h: +(a.height || 0).toFixed(3),
clip: (a.mixer && a.mixer._actions || []).filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name)[0] || null,
pending: k.want || null,
stature: +(hi - lo).toFixed(3), footY: +lo.toFixed(3),
pos: [+a.fig.position.x.toFixed(2), +a.fig.position.z.toFixed(2)] });
}
return { rows, info: window.DBG.info() };
}
"""
def port_up(port):
with socket.socket() as s:
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
def serve():
p = 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): return p
time.sleep(0.1)
p.terminate(); raise SystemExit(f'could not serve on :{PORT}')
def human_line(rows):
"""The R10 no-giants line — measured, and measured against the law it is actually about.
An absolute seated band was tried first and it was the WRONG instrument for R41: the library's
four sits run from bolt-upright-in-a-chair to slumped, and its four wall leans take 15-25% off a
standing crown by construction (you are leaning BACK). A 1.32 m leaner is not a defect, and a
fixed [0.9,1.5] seated band called it one. What the R10 law forbids is a GIANT, and what R16
forbids is a FOLD (Hips.quaternion laying the body flat, head down at hip height). So: stature is
reported for every figure with its ratio to that citizen's own nominal height, and the gate fails
on stature > 2.0 m (giant), stature < 0.55 x height (folded), or a nominal height outside the
seeded [1.4, 2.0] range."""
out, bad = [], []
for r in rows:
if r.get('placeholder'):
continue
h = r.get('h')
ratio = (r['stature'] / h) if h else None
if r['stature'] > HI: bad.append(f"{r.get('id', r.get('kind'))} GIANT {r['stature']}m")
elif h and r['stature'] < 0.55 * h: bad.append(f"{r.get('id', r.get('kind'))} FOLDED {r['stature']}m of {h}m")
elif h and not (LO <= h <= HI): bad.append(f"{r.get('id', r.get('kind'))} nominal height {h}m out of [{LO},{HI}]")
out.append(f"{r.get('state', r.get('kind'))} {r['stature']}m"
+ (f" ({ratio:.0%} of its {h}m nominal)" if ratio else ""))
verdict = 'ALL HUMAN-SIZED — no giant (>2.0 m), no fold (<55% of nominal)' if not bad else 'FAIL: ' + ', '.join(bad)
return (f"HUMAN-SIZED LINE — {len(out)} figures, stature (feet\u2192crown) measured off the posed "
f"skeleton: {' \u00b7 '.join(out)}. {verdict}."), not bad
def main():
OUTDIR.mkdir(parents=True, exist_ok=True)
from playwright.sync_api import sync_playwright
srv = serve()
rc = 0
try:
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1')
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=40000)
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
pg.evaluate("() => { window.DBG.setSegment(2); window.DBG.shot('crossroads_busy'); }")
print(f'soaking {SOAK} ms so the crowd reaches its posture steady state…')
pg.wait_for_timeout(SOAK)
# ── shot 1: the street ────────────────────────────────────────────────────────────────
# The camera pose is chosen ONCE, from geometry (the bench station with the most shop
# doors around it — the one block where both R41 street states can fire in one frame) and
# occlusion-raycast before it is accepted. Then it HOLDS and waits: postures come and go
# on 620 s dwells, so the honest way to shoot the busy moment is to stand still until it
# happens. Chasing subjects with a moving camera was tried first and lost them to
# rig-reacquisition every time.
place = pg.evaluate(PLACE)
print(f"place: {place.get('stations', 0)} bench stations near shop doors; "
f"{len(place.get('cands', []))} clear camera poses")
best, m = None, None
for cand in place.get('cands', [])[:4]:
for _ in range(2):
pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']])
pg.wait_for_timeout(2500)
for _ in range(14): # ~35 s of holding this pose
fr = pg.evaluate(FRAME)
if fr['n'] and (not best or fr['score'] > best['score']):
mm = pg.evaluate(MEASURE)
pg.screenshot(path=str(OUTDIR / 'r41_street_postures.jpg'), type='jpeg', quality=90)
best, m = dict(fr, **{k: cand[k] for k in ('px', 'pz', 'yaw')}), mm
print(f" ({cand['px']:.0f},{cand['pz']:.0f}) score {fr['score']} n={fr['n']} "
f"clips={fr['clips']} posed={fr['posed']} states={fr['states']}")
if best and best['posed'] >= 2 and best['clips'] >= 4: break
pg.wait_for_timeout(2500)
pg.evaluate("([x,z,y]) => window.DBG.teleport(x,z,y)", [cand['px'], cand['pz'], cand['yaw']])
if best and best['posed'] >= 2 and best['clips'] >= 4: break
if not best:
print(' never framed anybody'); rc = 1
else:
line, ok = human_line(m['rows'])
name = 'r41_street_postures.jpg'
states = {}
for r in m['rows']: states[r['state']] = states.get(r['state'], 0) + 1
cap = (f"PROCITY R41 §41.3 — {name}\n"
f"seed {SEED} · synthetic · MIDDAY · camera ({best['px']:.1f}, {best['pz']:.1f}) yaw {best['yaw']:.3f}\n"
f"{len(m['rows'])} near-tier rigs in frame · states {states} · "
f"{len(set(r['clip'] for r in m['rows']))} distinct clips: "
f"{', '.join(sorted(set(r['clip'] for r in m['rows'])))}\n"
f"draws {m['info']['drawCalls']} / budget {m['info']['budget']['draws']} · tris {m['info']['tris']}\n"
f"clips resident: {m['clips']['groups']} groups / {m['clips']['clips']} clips / {m['clips']['bytes']} B\n"
f"{line}\n")
(OUTDIR / name.replace('.jpg', '.txt')).write_text(cap)
print(cap)
if not ok: rc = 1
# ── shot 2: a browsing interior ───────────────────────────────────────────────────────
info = pg.evaluate(INTERIOR)
print('interior:', json.dumps(info))
if not info.get('ok'):
print(' skipped interior shot'); rc = 1
else:
fr = pg.evaluate(INT_FRAME)
print('frame:', json.dumps(fr))
if fr.get('ok'):
im = pg.evaluate(INT_MEASURE)
browsers = [r for r in im['rows'] if r['kind'] == 'browser']
line, ok = human_line(im['rows'])
name = 'r41_browse_interior.jpg'
pg.screenshot(path=str(OUTDIR / name), type='jpeg', quality=90)
cap = (f"PROCITY R41 §41.3 — {name}\n"
f"seed {SEED} · {info['shop']['name']} ({info['shop']['type']}) · "
f"{info['shop']['count']} patronage occupants · {info['browsePoints']} browse points\n"
f"figures: {json.dumps(im['rows'])}\n"
f"draws {fr['draws']} / interior budget 350 · tris {fr['tris']}\n"
f"{line}\n")
(OUTDIR / name.replace('.jpg', '.txt')).write_text(cap)
print(cap)
if not ok or not browsers: rc = 1
else:
rc = 1
if errs:
print('CONSOLE ERRORS:', errs[:6]); rc = 1
b.close()
finally:
srv.terminate()
print('\033[32m● shots written\033[0m' if rc == 0 else '\033[31m● problems — see above\033[0m')
return rc
if __name__ == '__main__':
sys.exit(main())