qa.sh --strict: 18 passed · 0 failed · 0 warn · 0 skipped. selfcheck 157,647/157,647, fingerprint 0x5f76e76 unmoved. No tag — John's playtest session rules the epoch. THE FIVE ASKS. [C] wardrobe wired via exported WARDROBE_BASE + stockBaseFor(shop), preloaded inside the existing STOCK_REAL gate: ON under ?stock=real (52 pack items, 69 garment ids on rendered meshes, 1 atlas, exactly 2 requests) and CHEAPER (59->55 draws, 111->100 textures); OFF is what ships by default, A/B'd against a reverted control tree — default arm identical on every counter (160 URLs same hash, 131/18,018 street, 59/28,505 interior). [D] three gates wired (r41_shots.py writes to TMPDIR so gate runs never rewrite committed shots). [E] clips_verify wired — 46 clips / 6 groups / 3,498,124 B. [B's bookmark finding] CONFIRMED AND WIDER: street_noon, shopfront_detail, crossroads_busy AND market_square all give 3 distinct hashes across 3 boots; only night_neon is stable. RULING: no gate pins a screenshot byte-hash on any bookmark — everything R41 asserts is a counter. Filed to B: one await document.fonts.ready in buildings.js. [C's drawSweep] CONFIRMED INDEPENDENTLY with F's own sweep (never calls C's): GLB-on worst 123 @ dept/auto. The phantom control (one stale room left in scene) reads 197 — +74 on every reading — decomposing R39/R40's '188' as 116 real + ~72 phantom. NEW GATES, controls demonstrated. r41_denylist.mjs (ruling 3): 738 files / 197.3 MiB scanned by path AND bytes, 7 banned names, 0 hits; the control plants a banned manifest row every run -> RED on 2 names -> removed -> GREEN. r41_integration.py: wardrobe both arms · interior <=350 on both instruments · ?noassets=1 zero across six fetch classes over a 3-shop walk (control fetches 5/6) · ?clips=0 142 URLs and ?classic=1 125 URLs with zero R41 cargo. BUDGETS. Street: noon 282 · NIGHT 291/120,093 — margin 9, INTACT · classic 269 byte-exact · ?clips=0 NIGHT 291, delta 0. No lane spent a street draw. Interior: true pre-R41 116, R41 123 @ dept/auto, margin 227; over 33 real shops worst 110; pub 49 quiet / 77 gig night. FILED BACK: flags_check's _enter_record_shop went RED on 8 smokes because §41.4's bins put every one of the first six record-shop counters inside 2.6 m — widened to the whole open set, verified not a game defect. smoke_djdance read _actions[0] and D's per-instance clip swapping made the mixer multi-action, so a healthy dancer was called a stall — now follows the clip by name. D's r41_shots.py browse arm is deterministically red here (re-enters the winner after a ~3 s/candidate scan, by which time the occupant has left) — wired WARN-level with the reason at the call site, fix handed to D. F's own r39_transmission.py had an unmeasured 30 s goto default that flaked once under a full strict run; pinned to 90 s. 11 proof frames in docs/shots/laneF_r41/ with sidecars: street_postures (5 rigs, 3 clips playing) against its ?clips=0 control (1 clip) · street_lean · browse_interior · pub_furnished + the honest kit before/after pair · record_dj_booth · opshop_wardrobe on/off · credits_panel with ODbL on screen. Every humanoid frame carries its R10 human-sized line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
738 lines
43 KiB
Python
738 lines
43 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY Lane F — R41 §41.6 THE PROOF SHOTS: the round the game visibly changed.
|
|
|
|
tools/.venv/bin/python tools/qa/r41_tour.py [--only NAME[,NAME…]] [--outdir DIR]
|
|
|
|
R41 is the first round in a long time whose whole point is VISIBLE, so the tour is evidence, not
|
|
decoration. Six frames, each chosen by MEASUREMENT (the camera is derived from where the thing
|
|
actually is, then the frame is checked for what is actually in it), plus three before/after pairs
|
|
where the control arm was cheap:
|
|
|
|
street_postures a busy street with citizens in VISIBLY DIFFERENT postures — D's headline.
|
|
The camera is not a taste call: it is the live crowd's own best cluster, found
|
|
by polling until a bench-sitter, a leaner and ≥2 walkers project into one
|
|
frame, then held. PAIR: the same seed/camera at ?clips=0 (R40's street).
|
|
browse_interior a browsing interior — browsers examining/holding at Lane C's browse points,
|
|
framed from Lane C's own room.spawn (the view walking in gives you).
|
|
pub_furnished the furnished pub. It had a bar and almost nothing else before.
|
|
PAIR: the same room built with Lane C's `noKit:true` — the exact pre-R41 room.
|
|
record_dj_booth the record shop's rigged DJ booth: deck_1200_rigged + mixer_ttm54i_rigged +
|
|
a static 1200. Camera aimed at the booth group by its own userData.
|
|
opshop_wardrobe an op-shop rack wearing the 52 real 1990s Australian garment layers.
|
|
PAIR: the same shop, same camera, on a default boot — the procedural canvas.
|
|
credits_panel Lane B's new attribution surface (F2), on an OSM boot so the ODbL credit that
|
|
the round exists to make sayable is on screen.
|
|
|
|
R10 LAW: every frame containing a humanoid carries the human-sized line in a .txt sidecar — stature
|
|
(feet→crown, off the POSED skeleton) for every figure in shot, as a ratio of that citizen's own
|
|
nominal height. Fails on a giant (>2.0 m) or a fold (<55% of nominal), never on an absolute seated
|
|
band (the library's leans legitimately take 20% off a standing crown — D's R41 finding).
|
|
|
|
Fresh headless context per frame, own no-store server. Exit 0 = every frame captured and every
|
|
human-sized line clean.
|
|
"""
|
|
import sys, os, json, time, socket, subprocess, pathlib
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
|
PORT = int(os.environ.get('PROCITY_R41_TOUR_PORT', '8982'))
|
|
HOST = f'http://127.0.0.1:{PORT}'
|
|
SEED = 20261990
|
|
OUTDIR = ROOT / 'docs' / 'shots' / 'laneF_r41'
|
|
LO, HI = 1.4, 2.0
|
|
if '--outdir' in sys.argv: OUTDIR = pathlib.Path(sys.argv[sys.argv.index('--outdir') + 1])
|
|
ONLY = None
|
|
if '--only' in sys.argv: ONLY = set(sys.argv[sys.argv.index('--only') + 1].split(','))
|
|
|
|
fails = []
|
|
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
|
|
def OK(m): print(f" \033[32m✓\033[0m {m}")
|
|
def head(m): print(f"\n\033[1m{m}\033[0m")
|
|
def note(m): print(f" \033[33m·\033[0m {m}")
|
|
|
|
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()
|
|
'''
|
|
|
|
# ── the street: find the crowd's own best cluster, and hold until the postures are in it ──────────
|
|
# Postures run on 6-20 s dwells, so the honest way to shoot the busy moment is to stand still until
|
|
# it happens. The camera stands out on the carriageway: the peds are at the back of a 3.5 m verge, so
|
|
# there is no room to back off beside them (D measured this the hard way in §41.3).
|
|
STREET_LOOK = r"""
|
|
() => {
|
|
const P = window.PROCITY, C = P.citizens, V3 = P.scene.position.constructor;
|
|
const cam = P.camera;
|
|
const people = [];
|
|
for (const c of C.activeCitizens()) {
|
|
if (!c.actor || c.actorKind !== 'rig') continue;
|
|
const posedNow = c.loiter > 0;
|
|
people.push({ id: c.id, x: c.x, z: c.z,
|
|
state: c.bench ? 'bench-sit' : c.lean ? 'lean' : (posedNow && c.sit) ? 'sit'
|
|
: (posedNow && c.glance) ? 'glance' : (posedNow ? 'idle' : 'walk') });
|
|
}
|
|
// An ANCHOR is any rigged citizen who is not walking — bench-sit and lean are the round's headline,
|
|
// but a citizen stopped at a window in their own seeded idle is the third of D's three new states and
|
|
// is just as much the point. Only ~2-10 of the ~150 active citizens are near-tier RIGS at any instant
|
|
// (the rest are impostors and carry no posture), so the anchor set is small and worth widening.
|
|
const anchors = people.filter(p => p.state !== 'walk');
|
|
if (!anchors.length) return { ok: false, why: 'no non-walking rig on the street yet', n: people.length };
|
|
// Score every candidate stand-off around every anchor: 12 bearings x 3 ranges, keeping the pose
|
|
// that puts the MOST DISTINCT posture states in frame (a frame of six walkers proves nothing).
|
|
// Candidate LOOK-AT targets: every anchor, plus the midpoint of every pair of anchors in DIFFERENT
|
|
// states — the frame the round actually wants (a sitter AND a leaner AND the walkers between them)
|
|
// is a frame aimed between two of them, and no amount of orbiting a single anchor will find it.
|
|
const targets = anchors.map(a => ({ x: a.x, z: a.z, state: a.state }));
|
|
for (let i = 0; i < anchors.length; i++)
|
|
for (let j = i + 1; j < anchors.length; j++)
|
|
if (anchors[i].state !== anchors[j].state && Math.hypot(anchors[i].x - anchors[j].x, anchors[i].z - anchors[j].z) < 34)
|
|
targets.push({ x: (anchors[i].x + anchors[j].x) / 2, z: (anchors[i].z + anchors[j].z) / 2,
|
|
state: anchors[i].state + '+' + anchors[j].state });
|
|
let best = null;
|
|
for (const a of targets) {
|
|
for (let i = 0; i < 12; i++) {
|
|
const th = i * Math.PI / 6;
|
|
for (const r of [7, 9.5, 12, 15, 18]) {
|
|
const px = a.x + Math.cos(th) * r, pz = a.z + Math.sin(th) * r;
|
|
cam.position.set(px, 1.62, pz);
|
|
cam.lookAt(new V3(a.x, 1.25, a.z));
|
|
cam.updateMatrixWorld(true);
|
|
const v = new V3(); const seen = [];
|
|
for (const p of people) {
|
|
const d = Math.hypot(p.x - px, p.z - pz);
|
|
if (d < 2.4 || d > 26) continue;
|
|
v.set(p.x, 1.0, p.z).project(cam);
|
|
if (v.z > 1 || Math.abs(v.x) > 0.9 || Math.abs(v.y) > 0.9) continue;
|
|
seen.push(p);
|
|
}
|
|
const states = new Set(seen.map(s => s.state));
|
|
const score = states.size * 100 + seen.length * 10 - r * 4; // ties go to the CLOSER frame — a
|
|
// busy street 18 m off is mostly pavement (measured: the first accepted frame was two thirds footpath)
|
|
if (!best || score > best.score) best = { score, px, pz, ax: a.x, az: a.z,
|
|
states: [...states], inFrame: seen.length, anchor: a.state };
|
|
}
|
|
}
|
|
}
|
|
cam.position.set(best.px, 1.62, best.pz);
|
|
cam.lookAt(new V3(best.ax, 1.25, best.az));
|
|
cam.updateMatrixWorld(true);
|
|
// MEASURE IN THE SAME BEAT. Searching in one evaluate and measuring in the next lets the dwell
|
|
// clock run between them, and a bench somebody has just got up from is the frame you actually
|
|
// shoot (measured: the first cut of this harness did exactly that, twice).
|
|
const v2 = 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;
|
|
v2.set(c.x, 1.0, c.z).project(cam);
|
|
if (v2.z > 1 || Math.abs(v2.x) > 0.92 || Math.abs(v2.y) > 0.92) 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; } });
|
|
const posedNow = c.loiter > 0;
|
|
// THE CLIP IS READ OFF THE MIXER, not off `c.posture`. `posture` is the ASSIGNMENT — a pure
|
|
// function of (citySeed, id) that exists whether or not the library ever loads (D's law: decided
|
|
// once, never re-rolled; the base clip plays while a variant is in flight). Reading it reports
|
|
// `walk_shopping_bag` on a ?clips=0 boot that has fetched nothing — exactly the note-about-the-
|
|
// thing this round's control arm exists to avoid. Measured: the first cut of this caption did it.
|
|
const playing = ((c.actor.mixer && c.actor.mixer._actions) || [])
|
|
.filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name);
|
|
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: playing[0] || null,
|
|
assigned: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
|
|
: (posedNow ? c.posture.idle : c.posture.walk),
|
|
h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
|
|
}
|
|
rows.sort((a, b) => a.d - b.d);
|
|
const states = [...new Set(rows.map(r => r.state))].sort();
|
|
const clips = [...new Set(rows.map(r => r.clip).filter(Boolean))].sort();
|
|
// Score on DISTINCT CLIPS as well as distinct states — which is the honest headline measure and D's
|
|
// own (§41.3 census: 4 distinct clips across the whole crowd → 20). Two walkers on `@walk` and
|
|
// `walk_shopping_bag` beside a sitter is three visibly different postures in one frame even though
|
|
// the state machine calls two of them "walk".
|
|
return { ok: true, ...best, rows, states, clips, inFrame: rows.length,
|
|
score2: states.length * 100 + clips.length * 60 + rows.length * 10,
|
|
info: window.DBG.info(), clipStats: C.clipStats() };
|
|
}
|
|
"""
|
|
|
|
# The R10 line: stature off the POSED skeleton for every rig actually in the picture.
|
|
STREET_MEASURE = r"""
|
|
() => {
|
|
const P = window.PROCITY, C = P.citizens, 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.92 || Math.abs(v.y) > 0.92) 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; } });
|
|
const posedNow = c.loiter > 0;
|
|
// THE CLIP IS READ OFF THE MIXER, not off `c.posture`. `posture` is the ASSIGNMENT — a pure
|
|
// function of (citySeed, id) that exists whether or not the library ever loads (D's law: decided
|
|
// once, never re-rolled; the base clip plays while a variant is in flight). Reading it reports
|
|
// `walk_shopping_bag` on a ?clips=0 boot that has fetched nothing — exactly the note-about-the-
|
|
// thing this round's control arm exists to avoid. Measured: the first cut of this caption did it.
|
|
const playing = ((c.actor.mixer && c.actor.mixer._actions) || [])
|
|
.filter(x => x.getEffectiveWeight() > 0.5).map(x => x.getClip().name);
|
|
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: playing[0] || null,
|
|
assigned: c.bench ? c.posture.sit : c.lean ? c.posture.lean : (posedNow && c.glance) ? '@look'
|
|
: (posedNow ? c.posture.idle : c.posture.walk),
|
|
h: +c.height.toFixed(3), stature: +(hi - lo).toFixed(3) });
|
|
}
|
|
rows.sort((a, b) => a.d - b.d);
|
|
return { rows, info: window.DBG.info(), clips: C.clipStats() };
|
|
}
|
|
"""
|
|
|
|
# ── interiors: frame a room from Lane C's own spawn, or at a named target ─────────────────────────
|
|
INT_FRAME = r"""
|
|
async ({ target, back, eye }) => {
|
|
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
|
const room = P.interiorMode.current;
|
|
if (!room) return { ok: false, why: 'no room open' };
|
|
const sp = room.spawn || { x: 0, z: 0 };
|
|
let tx, tz, ty = 1.15;
|
|
if (target === 'people') {
|
|
const figs = ((P.interiorMode.keepers || {}).keepers || []).map(k => k.actor.fig);
|
|
if (!figs.length) return { ok: false, why: 'no figures in the room' };
|
|
tx = 0; tz = 0; for (const f of figs) { tx += f.position.x; tz += f.position.z; }
|
|
tx /= figs.length; tz /= figs.length;
|
|
} else if (target === 'centre') {
|
|
tx = 0; tz = -room.dims.D * 0.12; ty = 1.05;
|
|
} else { // a tagged group: djBooth, or a stock-bearing fitting
|
|
let hit = null;
|
|
room.group.traverse(o => {
|
|
if (hit) return;
|
|
const u = o.userData || {};
|
|
if (target === 'djBooth' && u.djBooth) hit = o;
|
|
if (target === 'garment' && Array.isArray(u.buyItems) && u.buyItems.some(b => b && b.item && String(b.item.id).startsWith('wr_'))) hit = o;
|
|
if (target === 'stockrack' && (u.isStock || u.buyMesh) && !hit) hit = o;
|
|
});
|
|
if (!hit) return { ok: false, why: `no ${target} in this room` };
|
|
// A fitting group's ORIGIN sits on the floor, so aiming at it frames carpet and cuts the prop in
|
|
// half. Aim at the bounding-box centre of what is actually there.
|
|
const THREE = await import('three');
|
|
const c = new THREE.Box3().setFromObject(hit).getCenter(new V3());
|
|
tx = c.x; tz = c.z; ty = Math.max(0.95, c.y);
|
|
}
|
|
// stand off `back` metres from the target along the line from Lane C's spawn
|
|
let dx = sp.x - tx, dz = sp.z - tz; const L = Math.hypot(dx, dz) || 1;
|
|
dx /= L; dz /= L;
|
|
const d = Math.min(Math.max(back, 1.9), Math.max(L, back));
|
|
const px = tx + dx * d, pz = tz + dz * d;
|
|
P.camera.position.set(px, eye, pz);
|
|
P.camera.lookAt(new V3(tx, ty, tz));
|
|
P.camera.updateMatrixWorld(true);
|
|
P.renderer.info.reset();
|
|
P.renderer.render(P.interiorMode.scene, P.camera);
|
|
return { ok: true, cam: [+px.toFixed(2), +pz.toFixed(2)], target: [+tx.toFixed(2), +tz.toFixed(2)],
|
|
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles,
|
|
shop: P.interiorMode.shop, dims: room.dims };
|
|
}
|
|
"""
|
|
|
|
INT_MEASURE = r"""
|
|
() => {
|
|
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
|
const rows = [];
|
|
for (const k of ((P.interiorMode.keepers || {}).keepers || [])) {
|
|
const a = k.actor; if (!a || !a.inner) 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({ state: k.browse ? 'browser' : 'keeper', 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,
|
|
stature: +(hi - lo).toFixed(3) });
|
|
}
|
|
return { rows, info: window.DBG.info() };
|
|
}
|
|
"""
|
|
|
|
# Lane C's noKit control, built INSIDE the running game: the same shop, the same seed, the same
|
|
# archetype, with every R41 spec dropped — the exact pre-R41 room, in the room's own scene.
|
|
NOKIT_SWAP = r"""
|
|
async () => {
|
|
const P = window.PROCITY;
|
|
const M = await import('./js/interiors/interiors.js');
|
|
const THREE = await import('three');
|
|
const shopRef = P.interiorMode.shop;
|
|
const shop = (P.plan.shops || []).find(s => s.id === shopRef.id);
|
|
const old = P.interiorMode.current;
|
|
const before = { draws: null };
|
|
P.interiorMode.scene.remove(old.group);
|
|
const manifest = await fetch('assets/manifest.json?x=' + Date.now()).then(r => r.ok ? r.json() : null).catch(() => null);
|
|
const room = M.buildInterior(shop, THREE, { useGLB: true, manifest, noKit: true });
|
|
await room.glbReady;
|
|
room.group.userData.kind = 'interior';
|
|
P.interiorMode.scene.add(room.group);
|
|
window.__F_NOKIT = { room, old };
|
|
return { ok: true, shop: shopRef, pulls: room.pulls || null,
|
|
fittings_before: (old.placement || []).length, fittings_after: (room.placement || []).length };
|
|
}
|
|
"""
|
|
|
|
NOKIT_FRAME = r"""
|
|
({ cam, target, eye }) => {
|
|
const P = window.PROCITY, V3 = P.scene.position.constructor;
|
|
P.camera.position.set(cam[0], eye, cam[1]);
|
|
P.camera.lookAt(new V3(target[0], 1.05, target[1]));
|
|
P.camera.updateMatrixWorld(true);
|
|
P.renderer.info.reset();
|
|
P.renderer.render(P.interiorMode.scene, P.camera);
|
|
return { draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles };
|
|
}
|
|
"""
|
|
|
|
|
|
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 — height-RELATIVE, per D's R41 finding: a 1.32 m leaner is not a defect,
|
|
a 2.4 m anything is, and a body folded to hip height is the R16 failure this exists to catch."""
|
|
out, bad = [], []
|
|
for r in rows:
|
|
h = r.get('h') or 0
|
|
ratio = (r['stature'] / h) if h else None
|
|
if r['stature'] > HI: bad.append(f"{r.get('id', r['state'])} GIANT {r['stature']}m")
|
|
elif h and r['stature'] < 0.55 * h: bad.append(f"{r.get('id', r['state'])} FOLDED {r['stature']}m of {h}m")
|
|
elif h and not (LO <= h <= HI): bad.append(f"{r.get('id', r['state'])} nominal {h}m out of [{LO},{HI}]")
|
|
out.append(f"{r['state']}"
|
|
+ (f"[{r['clip']}]" if r.get('clip') else '')
|
|
+ f" {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 (R10) — {len(out)} figure(s) in frame, stature (feet→crown) measured "
|
|
f"off the posed skeleton: {' · '.join(out)}. {verdict}."), not bad
|
|
|
|
|
|
def write_shot(pg, name, caption, rows=None, capture=True):
|
|
OUTDIR.mkdir(parents=True, exist_ok=True)
|
|
if capture:
|
|
pg.screenshot(path=str(OUTDIR / f'{name}.jpg'), type='jpeg', quality=92)
|
|
txt = caption
|
|
if rows is not None:
|
|
line, ok = human_line(rows)
|
|
txt += '\n\n' + line
|
|
if not ok: FAIL(f'{name}: {line}')
|
|
(OUTDIR / f'{name}.txt').write_text(txt + '\n')
|
|
OK(f'{name}.jpg + .txt')
|
|
|
|
|
|
def new_page(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)))
|
|
return b, pg, errs
|
|
|
|
|
|
def boot(pg, query):
|
|
pg.goto(f'{HOST}/index.html?seed={SEED}&dbg=1' + (('&' + query) if query else ''))
|
|
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=45000)
|
|
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
|
|
|
|
|
# ── 1. THE STREET ────────────────────────────────────────────────────────────────────────────────
|
|
def shot_street(p):
|
|
head('1. street_postures — the crowd stops being one person copy-pasted (+ the ?clips=0 pair)')
|
|
pose = None
|
|
for q, name in (('', 'street_postures'), ('clips=0', 'street_postures_clips0')):
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, q + ('&' if q else '') + 'weather=0&magpie=0&washing=0')
|
|
pg.evaluate("() => window.DBG.setSegment(2)")
|
|
pg.evaluate("() => window.DBG.shot('street_noon')")
|
|
if not q:
|
|
# HOLD until the crowd offers the frame, and CAPTURE THE INSTANT IT DOES. Postures run
|
|
# on 6-20 s dwells, so a camera chosen from a poll twenty seconds ago is aimed at a
|
|
# bench somebody has already got up from — measured, twice, before this loop was built.
|
|
# The search + the measurement are one evaluate; the capture is the next statement; the
|
|
# file on disk is always the best frame seen so far.
|
|
# …and RESAMPLE THE TOWN, not just the clock: the sitters and leaners of one 30 m stretch
|
|
# are the same dozen citizens, so holding one spot for a minute samples one bench. The
|
|
# loop hops between the shell's own street bookmarks every 8 polls.
|
|
# Measured first (probe over 5 bookmarks x 4 samples): the near-tier holds only 2-10 RIGS
|
|
# at a time and `crossroads_busy` is where the rigs are (9-10 vs 1-3 elsewhere), and the
|
|
# non-walk share of them builds over ~10 s of dwell. So: dwell long, hop rarely, start
|
|
# where the people are.
|
|
STOPS = ['crossroads_busy', 'street_noon', 'crossroads_busy', 'patronage_door', 'night_crowd']
|
|
best = None
|
|
byAnchor = {}
|
|
OUTDIR.mkdir(parents=True, exist_ok=True)
|
|
for i in range(72):
|
|
if i % 12 == 0:
|
|
pg.evaluate(f"() => window.DBG.shot('{STOPS[(i // 12) % len(STOPS)]}')")
|
|
pg.evaluate("() => window.DBG.setSegment(2)")
|
|
pg.wait_for_timeout(900)
|
|
r = pg.evaluate(STREET_LOOK)
|
|
if not r.get('ok'):
|
|
pg.wait_for_timeout(600); continue
|
|
a = r['anchor'].split('+')[0]
|
|
if not best or r['score2'] > best['score2']:
|
|
best = r
|
|
pg.wait_for_timeout(150) # one composer frame at the new pose
|
|
pg.screenshot(path=str(OUTDIR / 'street_postures.jpg'), type='jpeg', quality=92)
|
|
if a != 'walk' and (a not in byAnchor or r['score2'] > byAnchor[a]['score2']):
|
|
byAnchor[a] = r
|
|
pg.wait_for_timeout(150)
|
|
pg.screenshot(path=str(OUTDIR / f'street_{a}.jpg'), type='jpeg', quality=92)
|
|
if len(r['states']) >= 3 and len(r['clips']) >= 3 and r['inFrame'] >= 4: break
|
|
pg.wait_for_timeout(450)
|
|
if not best:
|
|
FAIL('street_postures: never found a non-walking rig in 72 polls'); return
|
|
pose = best
|
|
m = dict(rows=best['rows'], info=best['info'], clips=best['clipStats'])
|
|
note(f"camera ({best['px']:.1f},{best['pz']:.1f}) on the {best['anchor']} anchor · "
|
|
f"{best['inFrame']} rigs in frame · states {best['states']} · clips {best['clips']}")
|
|
# One frame can only hold what the near tier holds (2-10 rigs), so the round's three new
|
|
# street states are also shot ONE PER ANCHOR — best frame per anchor kind, same boot.
|
|
for a, r in sorted(byAnchor.items()):
|
|
# …but never twice: if the per-anchor best IS the headline frame, it is the same
|
|
# picture under a second name.
|
|
if abs(r['px'] - best['px']) < 1e-6 and abs(r['pz'] - best['pz']) < 1e-6:
|
|
for ext in ('.jpg', '.txt'):
|
|
f = OUTDIR / f'street_{a}{ext}'
|
|
if f.exists(): f.unlink()
|
|
note(f'street_{a} is the headline frame itself — not written twice')
|
|
continue
|
|
line, ok2 = human_line(r['rows'])
|
|
(OUTDIR / f'street_{a}.txt').write_text(
|
|
f"PROCITY R41 §41.6 — street_{a} (supporting frame: the {a} state)\n"
|
|
f"seed {SEED}, midday, camera ({r['px']:.2f}, {r['pz']:.2f}) → ({r['ax']:.2f}, {r['az']:.2f}).\n"
|
|
f"{r['inFrame']} rigs in frame · states {r['states']} · clips {r['clips']} · "
|
|
f"{r['info']['drawCalls']} draws of the ≤300 street law.\n\n{line}\n")
|
|
if not ok2: FAIL(f'street_{a}: {line}')
|
|
OK(f'street_{a}.jpg + .txt ({a}, {len(r["clips"])} clips in frame)')
|
|
else:
|
|
# the CONTROL arm: identical camera, R40's street (no motion library at all)
|
|
pg.evaluate("""({px,pz,ax,az}) => { const P=window.PROCITY, V3=P.scene.position.constructor;
|
|
P.player.teleport(px, pz, 0);
|
|
P.camera.position.set(px, 1.62, pz); P.camera.lookAt(new V3(ax,1.25,az));
|
|
P.chunks.warmup(P.camera.position); P.camera.updateMatrixWorld(true); }""", pose)
|
|
pg.wait_for_timeout(1500)
|
|
m = pg.evaluate(STREET_MEASURE)
|
|
states = sorted(set(r['state'] for r in m['rows']))
|
|
clips = sorted(set(r['clip'] for r in m['rows'] if r['clip']))
|
|
note(f"{name}: {len(m['rows'])} figures · states {states} · {len(clips)} distinct clips · "
|
|
f"{m['info']['drawCalls']} draws / {m['info']['tris']} tris · bank {m['clips']}")
|
|
cap = (f"PROCITY R41 §41.6 — {name}\n"
|
|
f"seed {SEED}, midday (seg 2), camera ({pose['px']:.2f}, {pose['pz']:.2f}) looking at "
|
|
f"({pose['ax']:.2f}, {pose['az']:.2f}) — the SAME pose on both arms.\n"
|
|
f"{'DEFAULT BOOT (R41): the motion library is on.' if not q else 'CONTROL ?clips=0 (= R40): the motion library is OFF and nothing else changes.'}\n"
|
|
f"{len(m['rows'])} rigs in frame · posture states {states} · {len(clips)} distinct clips "
|
|
f"({', '.join(clips)}) · {m['info']['drawCalls']} draws of the ≤300 street law "
|
|
f"({m['info']['tris']} tris) · clip bank {m['clips'].get('groups')} groups / "
|
|
f"{m['clips'].get('clips')} clips / {m['clips'].get('bytes')} B.")
|
|
write_shot(pg, name, cap, m['rows'], capture=bool(q))
|
|
if not q and (len(clips) < 3 or len(states) < 2):
|
|
FAIL(f'street_postures: {len(states)} state(s) / {len(clips)} clip(s) in frame — '
|
|
f'the headline (visibly different postures) is not visible')
|
|
if errs: FAIL(f'{name}: {len(errs)} console error(s): {errs[:2]}')
|
|
finally:
|
|
b.close()
|
|
|
|
|
|
# ── 2-5. THE INTERIORS ───────────────────────────────────────────────────────────────────────────
|
|
def enter_best(pg, want_type=None, need=None, tries=10):
|
|
"""Enter a shop of `want_type` (or the most-occupied shop) and settle. `need` is a JS predicate
|
|
name checked on the built room: 'browsers' | 'djBooth' | 'garment'."""
|
|
js = r"""
|
|
async ({ want, need, tries }) => {
|
|
const P = window.PROCITY, D = window.DBG, C = P.citizens;
|
|
// Prefer the shop types whose floor is browsable (Lane C puts 3.00 browse points in every type,
|
|
// but a record bin / clothes rail / bookshelf is what a browse clip READS as), then by how many
|
|
// patronage occupants are actually inside — occupancy is what puts browsers on the floor.
|
|
const PREF = ['record', 'opshop', 'book', 'video', 'stall', 'pawn'];
|
|
const shops = (P.plan.shops || []).filter(s => !want || s.type === want)
|
|
.map(s => ({ id: s.id, name: s.name, type: s.type, occ: (C.occupancyOf(s.id) || {}).count || 0 }))
|
|
.sort((a, b) => (b.occ - a.occ) || ((PREF.indexOf(a.type) + 9) % 9) - ((PREF.indexOf(b.type) + 9) % 9));
|
|
const has = (room) => {
|
|
if (!room) return false;
|
|
if (need === 'browsers') return (((P.interiorMode.keepers || {}).keepers) || []).filter(k => k.browse).length > 0;
|
|
if (need === 'djBooth') { let f = false; room.group.traverse(o => { if ((o.userData || {}).djBooth) f = true; }); return f; }
|
|
if (need === 'garment') { let f = false; room.group.traverse(o => { const u = o.userData || {};
|
|
if (Array.isArray(u.buyItems) && u.buyItems.some(b => b && b.item && String(b.item.id).startsWith('wr_'))) f = true; }); return f; }
|
|
return true;
|
|
};
|
|
// Take the BEST candidate, not the first that works: the first shop with one browser and the
|
|
// shop with three are both "ok", and only one of them is a photograph of a browsing interior.
|
|
let best = null;
|
|
const cands = shops.filter(s => need !== 'browsers' || s.occ > 0).slice(0, tries);
|
|
for (const s of cands) {
|
|
D.enterShop(s.id);
|
|
await new Promise(r => setTimeout(r, 2600));
|
|
const room = P.interiorMode.current;
|
|
const ks = ((P.interiorMode.keepers || {}).keepers) || [];
|
|
if (room && has(room)) {
|
|
const cand = { ok: true, shop: s, tried: shops.length,
|
|
browsers: ks.filter(k => k.browse).length, keepers: ks.length,
|
|
fittings: (room.placement || []).length };
|
|
if (!best || cand.browsers > best.browsers) best = cand;
|
|
if (need !== 'browsers' || cand.browsers >= 3) return best;
|
|
}
|
|
D.exitShop(); await new Promise(r => setTimeout(r, 350));
|
|
}
|
|
if (best) { D.enterShop(best.shop.id); await new Promise(r => setTimeout(r, 2600)); return best; }
|
|
return { ok: false, why: `no ${want || 'shop'} satisfied "${need}"`, tried: shops.length };
|
|
}
|
|
"""
|
|
return pg.evaluate(js, dict(want=want_type, need=need, tries=tries))
|
|
|
|
|
|
def shot_interior(p, name, want_type, need, target, back, eye, seg, title, extra_q='', dwell=1200):
|
|
head(f'{title}')
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, f'gigs=1&weather=0&magpie=0&washing=0{extra_q}')
|
|
pg.evaluate(f"() => window.DBG.setSegment({seg})")
|
|
# Patronage is a wall-clock process (R8: peds duck in as they walk past), so a browser shot taken
|
|
# 1.2 s after boot photographs an empty shop. Measured: 0 shops with occupants at 1.2 s.
|
|
pg.wait_for_timeout(dwell)
|
|
occ = pg.evaluate("() => (window.PROCITY.plan.shops||[]).filter(s => (window.PROCITY.citizens.occupancyOf(s.id)||{}).count > 0).length")
|
|
note(f'after {dwell} ms of street time: {occ} shop(s) hold patronage occupants')
|
|
r = enter_best(pg, want_type, need)
|
|
if not r.get('ok'):
|
|
FAIL(f"{name}: {r.get('why')}"); return None
|
|
note(f"{r['shop']['name']} ({r['shop']['type']}, id {r['shop']['id']}) · {r['fittings']} fittings · "
|
|
f"{r['keepers']} figures ({r['browsers']} browsing) · tried {r['tried']} candidates")
|
|
fr = pg.evaluate(INT_FRAME, dict(target=target, back=back, eye=eye))
|
|
if not fr.get('ok'):
|
|
FAIL(f"{name}: {fr.get('why')}"); return None
|
|
m = pg.evaluate(INT_MEASURE)
|
|
note(f"{fr['draws']} draws / {fr['tris']} tris · room {fr['dims']['W']}x{fr['dims']['D']} m · "
|
|
f"camera {fr['cam']} → {fr['target']}")
|
|
cap = (f"PROCITY R41 §41.6 — {name}\n"
|
|
f"seed {SEED}, {r['shop']['name']} ({r['shop']['type']}), segment {seg}. "
|
|
f"{r['fittings']} fittings placed · {r['keepers']} figures in the room "
|
|
f"({r['browsers']} at Lane C browse points) · camera {fr['cam']} looking at {fr['target']} "
|
|
f"(Lane C's own room.spawn line).\n"
|
|
f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris).")
|
|
write_shot(pg, name, cap, m['rows'] if m['rows'] else None)
|
|
if errs: FAIL(f'{name}: {len(errs)} console error(s): {errs[:2]}')
|
|
return dict(page=pg, browser=b, frame=fr, shop=r['shop'])
|
|
finally:
|
|
b.close()
|
|
|
|
|
|
def shot_pub_pair(p):
|
|
"""Two things, deliberately separated.
|
|
|
|
The HERO is the pub on gig night — which is what a player sees, and what the round is for. But a
|
|
gig-night frame is NOT a fair before/after for §41.4, because the band and the crowd ride the room
|
|
group and are disposed with it: a `noKit` rebuild takes the R41 fittings AND the eleven people out
|
|
of the picture, and a pair like that credits the furniture with the crowd. So the A/B pair is shot
|
|
separately, on a QUIET midday pub with the gig layer off, where the only thing that moves between
|
|
the arms is the kit."""
|
|
head("4. pub_furnished (hero, gig night) + pub_kit_after/before (the honest A/B, quiet pub)")
|
|
# ── the hero ──────────────────────────────────────────────────────────────────────────────────
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, 'gigs=1&weather=0&magpie=0&washing=0')
|
|
pg.evaluate("() => window.DBG.setSegment(5)")
|
|
pg.wait_for_timeout(1500)
|
|
r = enter_best(pg, 'pub', None)
|
|
if not r.get('ok'):
|
|
FAIL(f"pub_furnished: {r.get('why')}")
|
|
else:
|
|
fr = pg.evaluate(INT_FRAME, dict(target='centre', back=5.2, eye=1.62))
|
|
m = pg.evaluate(INT_MEASURE)
|
|
note(f"hero: {r['shop']['name']} · {r['fittings']} fittings · {fr['draws']} draws / {fr['tris']} tris "
|
|
f"· {len(m['rows'])} figures")
|
|
write_shot(pg, 'pub_furnished',
|
|
f"PROCITY R41 §41.6 — pub_furnished (the hero: gig night)\n"
|
|
f"seed {SEED}, {r['shop']['name']} (pub), segment 5, ?gigs=1. {r['fittings']} fittings "
|
|
f"placed by §41.4 — the plywood bar, the DJ booth, the speaker stack, the jukebox, the "
|
|
f"couch, the bistro table. The band and the crowd are the R13 gig layer, NOT R41 cargo: "
|
|
f"the fittings claim is measured in the pub_kit pair, not here.\n"
|
|
f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris). "
|
|
f"Camera {fr['cam']} → {fr['target']}.", m['rows'] if m['rows'] else None)
|
|
if errs: FAIL(f'pub_furnished: {len(errs)} console error(s): {errs[:2]}')
|
|
finally:
|
|
b.close()
|
|
# ── the honest A/B: quiet pub, gig layer OFF, the kit is the only variable ────────────────────
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
# gigs stays ON — ?gigs=0 removes the venue TYPES from the plan entirely (measured: "no pub
|
|
# satisfied", 0 pub lots), so the control for "no crowd" is the CLOCK, not the flag. And the
|
|
# clock has to be SEGMENT 4: this town's one pub keeps 17:00-23:00 (measured), so midday is a
|
|
# locked door and segment 5 is the gig. 18:30 is open, quiet, and crowd-free.
|
|
boot(pg, 'gigs=1&weather=0&magpie=0&washing=0')
|
|
pg.evaluate("() => window.DBG.setSegment(4)")
|
|
pg.wait_for_timeout(1500)
|
|
r = enter_best(pg, 'pub', None)
|
|
if not r.get('ok'):
|
|
FAIL(f"pub_kit_after: {r.get('why')}"); return
|
|
fr = pg.evaluate(INT_FRAME, dict(target='centre', back=5.2, eye=1.62))
|
|
if not fr.get('ok'):
|
|
FAIL(f"pub_kit_after: {fr.get('why')}"); return
|
|
m = pg.evaluate(INT_MEASURE)
|
|
write_shot(pg, 'pub_kit_after',
|
|
f"PROCITY R41 §41.6 — pub_kit_after (AFTER — the R41 kit)\n"
|
|
f"seed {SEED}, {r['shop']['name']} (pub), 18:30 (segment 4) — open, gig not on, so no band and no crowd "
|
|
f"on either arm and nothing but the furniture differs. {r['fittings']} fittings.\n"
|
|
f"{fr['draws']} draws / {fr['tris']} tris. Camera {fr['cam']} → {fr['target']}, "
|
|
f"identical on both arms.", m['rows'] if m['rows'] else None)
|
|
sw = pg.evaluate(NOKIT_SWAP)
|
|
if not sw.get('ok'):
|
|
FAIL('pub_kit_before: the noKit rebuild failed'); return
|
|
pg.wait_for_timeout(700)
|
|
fr2 = pg.evaluate(NOKIT_FRAME, dict(cam=fr['cam'], target=fr['target'], eye=1.62))
|
|
note(f"noKit control: {sw['fittings_before']} → {sw['fittings_after']} fittings · "
|
|
f"{fr['draws']} → {fr2['draws']} draws ({fr2['draws'] - fr['draws']:+d}) · "
|
|
f"{fr['tris']} → {fr2['tris']} tris")
|
|
write_shot(pg, 'pub_kit_before',
|
|
f"PROCITY R41 §41.6 — pub_kit_before (BEFORE — the CONTROL)\n"
|
|
f"The SAME shop, the SAME seed, the SAME camera, rebuilt with Lane C's `noKit: true`, which "
|
|
f"drops exactly the R41 specs and reproduces the pre-R41 room.\n"
|
|
f"fittings {sw['fittings_before']} → {sw['fittings_after']} · "
|
|
f"draws {fr['draws']} → {fr2['draws']} ({fr2['draws'] - fr['draws']:+d}) · "
|
|
f"tris {fr['tris']} → {fr2['tris']}. That difference is what §41.4 bought in this room, "
|
|
f"measured — and nothing else in the frame moved.\n"
|
|
f"NOTE: the keeper rides the room group and is disposed with it, so the control frame has "
|
|
f"no shopkeeper. Every OTHER difference in the picture is the kit.")
|
|
if fr2['draws'] >= fr['draws']:
|
|
FAIL(f"pub pair: the noKit control is not cheaper ({fr2['draws']} vs {fr['draws']}) — the pair proves nothing")
|
|
if sw['fittings_after'] >= sw['fittings_before']:
|
|
FAIL(f"pub pair: noKit did not drop any fitting ({sw['fittings_before']} → {sw['fittings_after']})")
|
|
if errs: FAIL(f'pub_kit pair: {len(errs)} console error(s): {errs[:2]}')
|
|
finally:
|
|
b.close()
|
|
|
|
|
|
def shot_wardrobe_pair(p):
|
|
head('5. opshop_wardrobe + opshop_wardrobe_off — the 52 garments, and the canvas that ships default')
|
|
cam = target = None
|
|
for q, name in (('&stock=real', 'opshop_wardrobe'), ('', 'opshop_wardrobe_off')):
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, 'weather=0&magpie=0&washing=0' + q)
|
|
pg.evaluate("() => window.DBG.setSegment(2)")
|
|
pg.wait_for_timeout(1200)
|
|
r = enter_best(pg, 'opshop', 'garment' if q else None)
|
|
if not r.get('ok'):
|
|
FAIL(f"{name}: {r.get('why')}"); return
|
|
if q:
|
|
fr = pg.evaluate(INT_FRAME, dict(target='garment', back=2.6, eye=1.55))
|
|
if not fr.get('ok'): FAIL(f"{name}: {fr.get('why')}"); return
|
|
cam, target = fr['cam'], fr['target']
|
|
else:
|
|
fr = pg.evaluate(NOKIT_FRAME, dict(cam=cam, target=target, eye=1.55))
|
|
fr = dict(fr, cam=cam, target=target, ok=True)
|
|
si = pg.evaluate("() => window.DBG.stockInfo()")
|
|
m = pg.evaluate(INT_MEASURE)
|
|
ids = [i for i in (si.get('renderedIds') or []) if str(i).startswith('wr_')]
|
|
titles = (si.get('renderedTitles') or [])[:6]
|
|
note(f"{name}: {r['shop']['name']} · base {si.get('base')} · {len(ids)} garment ids · "
|
|
f"{fr['draws']} draws / {fr['tris']} tris")
|
|
cap = (f"PROCITY R41 §41.6 — {name}\n"
|
|
f"seed {SEED}, {r['shop']['name']} (opshop), midday. Camera {fr['cam']} → {fr['target']}, "
|
|
f"IDENTICAL on both arms.\n"
|
|
+ (f"?stock=real — Lane C's wardrobe pack, turned on by F's one line: base "
|
|
f"{si.get('base')}, {si.get('packItems')} named 1990s Australian garment layers off "
|
|
f"ONE 246 KB atlas, {len(ids)} of them on rendered meshes "
|
|
f"({', '.join(titles)}…).\n" if q else
|
|
"DEFAULT BOOT (the control, and what ships) — no pack, so Lane C's procedural garment "
|
|
"canvas, byte-identically. Nothing breaks without the wardrobe.\n")
|
|
+ f"{fr['draws']} draws of the ≤350 interior law ({fr['tris']} tris).")
|
|
write_shot(pg, name, cap, m['rows'] if m['rows'] else None)
|
|
if q and not ids: FAIL(f'{name}: no wardrobe ids on rendered meshes — the shot proves nothing')
|
|
if errs: FAIL(f'{name}: {len(errs)} console error(s): {errs[:2]}')
|
|
finally:
|
|
b.close()
|
|
|
|
|
|
def shot_credits(p):
|
|
head('6. credits_panel — Lane B\'s new attribution surface, on an OSM boot (the ODbL credit on screen)')
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, 'plansrc=osm&town=katoomba&weather=0&magpie=0&washing=0')
|
|
pg.evaluate("() => window.DBG.shot('street_noon')")
|
|
pg.wait_for_timeout(600)
|
|
link = pg.evaluate("() => { const el = document.getElementById('pc-creditlink'); return el ? el.textContent.trim() : null; }")
|
|
pg.keyboard.press('F2')
|
|
pg.wait_for_timeout(1200)
|
|
st = pg.evaluate(r"""() => {
|
|
const el = document.getElementById('pc-credits');
|
|
const t = el ? el.innerText : '';
|
|
// NB `offsetParent` is null for a position:fixed element even when it is on screen —
|
|
// the panel is fixed/inset:0, so that test reports "closed" on a perfectly open panel.
|
|
return { open: !!(el && getComputedStyle(el).display !== 'none'),
|
|
entries: (window.PROCITY.credits && window.PROCITY.credits.count) || (t.match(/\n/g) || []).length,
|
|
osm: /OpenStreetMap contributors/.test(t),
|
|
odbl: /ODbL/.test(t), mixamo: /Mixamo/i.test(t), chars: t.length }; }""")
|
|
note(f"HUD link: {link!r} · panel open {st['open']} · OSM string {st['osm']} · ODbL {st['odbl']} · "
|
|
f"Mixamo row {st['mixamo']} · {st['chars']} chars of panel text")
|
|
cap = (f"PROCITY R41 §41.6 — credits_panel\n"
|
|
f"seed {SEED}, ?plansrc=osm&town=katoomba — an OSM boot, so the obligation is live and the HUD "
|
|
f"link itself carries it: {link!r}.\n"
|
|
f"F2 opens Lane B's §41.5 surface. On screen: the © OpenStreetMap contributors credit verbatim "
|
|
f"({st['osm']}), the ODbL 1.0 licence name ({st['odbl']}), and the Mixamo row that records why "
|
|
f"R41's 46 clips owe no attribution ({st['mixamo']}).\n"
|
|
f"Zero draws by construction (DOM), zero boot fetches (credits.json is fetched on first open).")
|
|
write_shot(pg, 'credits_panel', cap)
|
|
if not st['open']: FAIL('credits_panel: the panel did not open on F2')
|
|
if not st['osm']: FAIL('credits_panel: the OSM attribution string is not on screen')
|
|
if errs: FAIL(f'credits_panel: {len(errs)} console error(s): {errs[:2]}')
|
|
finally:
|
|
b.close()
|
|
|
|
|
|
def main():
|
|
srv = serve()
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
with sync_playwright() as p:
|
|
if not ONLY or 'street' in ONLY: shot_street(p)
|
|
if not ONLY or 'browse' in ONLY:
|
|
shot_interior(p, 'browse_interior', None, 'browsers', 'people', 3.0, 1.62, 2,
|
|
"2. browse_interior — browsers examining and holding at Lane C's browse points",
|
|
extra_q='&stock=real', dwell=25000)
|
|
if not ONLY or 'record' in ONLY:
|
|
shot_interior(p, 'record_dj_booth', 'record', 'djBooth', 'djBooth', 2.5, 1.45, 2,
|
|
'3. record_dj_booth — the rigged 1200 + TTM-54i behind the counter')
|
|
if not ONLY or 'pub' in ONLY: shot_pub_pair(p)
|
|
if not ONLY or 'wardrobe' in ONLY: shot_wardrobe_pair(p)
|
|
if not ONLY or 'credits' in ONLY: shot_credits(p)
|
|
finally:
|
|
srv.terminate()
|
|
print('')
|
|
if fails:
|
|
print(f'\033[31m● FAIL\033[0m — {len(fails)} problem(s):')
|
|
for f in fails: print(f' · {f}')
|
|
return 1
|
|
print(f'\033[32m● PASS\033[0m — the tour is in {OUTDIR.relative_to(ROOT)}, every humanoid frame carrying its R10 line')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|