2098 lines
128 KiB
Python
2098 lines
128 KiB
Python
#!/usr/bin/env python3
|
||
"""PROCITY Lane F — v2 flag enforcement harness (round-6 F1).
|
||
|
||
The machine version of what the R5 review did by hand. Three things, headless, <2 min:
|
||
1. FLAGS-OFF REGRESSION — the enforcement arm of the prime law. Boot with no flags → the plan
|
||
fingerprint must equal the golden 0x5f76e76, the settled street view must stay within the v1
|
||
budget (≤300 draws / ≤200k tris) AND within tolerance of the v1.1 snapshot, every v2 subsystem
|
||
must report inactive, and there must be ZERO console errors. A v2 flag leaking into the default
|
||
boot trips this.
|
||
2. PER-FLAG SMOKE — for each landed flag (winmap, dig, roster, +plansrc when A lands): boot flag-on,
|
||
cross ≥2 chunks, enter one interior (dig: also open+close a riffle), 0 console errors.
|
||
3. ALL-ON COMBO (flag-composition law, decision #4): every landed flag on at once, same smoke.
|
||
|
||
Run: cd web && python3 -m http.server 8130 (or let this script spawn its own)
|
||
tools/.venv/bin/python tools/flags_check.py
|
||
Wired into tools/qa.sh as a WARN-level gate this round (strict in round 7). Exit 0 = all pass;
|
||
non-zero = a check failed (qa.sh reports it as a warning this round).
|
||
"""
|
||
import sys, json, time, socket, subprocess, pathlib, os, urllib.request, urllib.error
|
||
try:
|
||
from playwright.sync_api import sync_playwright
|
||
except ImportError:
|
||
sys.exit("playwright not installed — python3 -m venv tools/.venv && "
|
||
"tools/.venv/bin/pip install playwright && tools/.venv/bin/python -m playwright install chromium")
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||
PORT = 8130
|
||
HOST = f'http://127.0.0.1:{PORT}'
|
||
SEED = 20261990
|
||
GOLDEN_HASH = 0x5f76e76 # [R27w5] AMENDED. Was 0x3fa36874 "forever" since R16 — and that town
|
||
# was a row of BACK WALLS since v1 (A measured 0 toward / 681 away;
|
||
# walked behind them and found NUMBAT PLAYTHINGS facing the dirt).
|
||
# John's ruling: the covenant is anti-drift machinery, NOT a defect
|
||
# preservative. It did its job — it never drifted; it was wrong from
|
||
# the first commit, and no hash can catch what was never right.
|
||
GIG_GOLDEN = 0xec7a2d39 # [R27w5] re-pinned with the facade fix (was 0xb1d48ea1)
|
||
# flags-off DBG.shot('street_noon') snapshot. RE-PINNED at the R7 roster flip (prime-law amendment):
|
||
# old (v1.1, roster-off): 163 draws, 19000 tris
|
||
# new (R7, roster default-on): 164 draws, ~66000 tris
|
||
# Draws barely move — E1's ped sub-mesh merge makes each near-rig ~1 draw, which is exactly what let
|
||
# the roster flip to full density. Tris jump is the streamed crowd (each near-rig ~2.4k tris); it is
|
||
# inherently variable frame-to-frame (how many rigs are near at sample time), so the drift WARN below
|
||
# is DRAWS-ONLY — tris is guarded strictly by the STREET_TRIS_MAX hard cap, not a soft snapshot.
|
||
BASE_DRAWS, BASE_TRIS = 164, 66000
|
||
STREET_DRAWS_MAX, STREET_TRIS_MAX = 300, 200_000
|
||
KNOWN_FLAGS = ['winmap=1', 'dig=1', 'roster=stream'] # plansrc=osm auto-appended if Lane A landed it
|
||
# [R12] gig-night interior budget (ROUND12 law: ≤350 draws incl. the crowd — a room with a band and an
|
||
# audience in it is allowed more than a shop, which is what the ≤300 street/room numbers were tuned for).
|
||
GIG_DRAWS_MAX = 350
|
||
# `cover` is seeded per gig, so which door-charge path the default seed exercises is luck. Pin one seed
|
||
# per half of John's "free AND paid" ruling: 20261990's tonight is $10 (Screaming Utes at The Thornbury
|
||
# Hotel), 1234's is free (Dazza & The Dropbears at The Imperial). Re-pin if Lane A re-seeds the gig stream.
|
||
FREE_GIG_SEED = 1234
|
||
|
||
fails, warns = [], []
|
||
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
|
||
def WARN(m): warns.append(m); print(f" \033[33m! WARN\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 stature_ok(f):
|
||
# [R16 ledger #3] seated figs (the drummer — fig.userData.procitySeated) pass the no-giants floor at a
|
||
# SEATED band [0.9,1.5] m; standing figs stay [1.4,2.0]. A seated human fails the standing floor by design.
|
||
lo, hi = (0.9, 1.5) if f.get('seated') else (1.4, 2.0)
|
||
return lo <= f['stature'] <= hi
|
||
|
||
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 # reuse a running dev server
|
||
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}")
|
||
|
||
# ── page helpers ─────────────────────────────────────────────────────────────
|
||
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=20000)
|
||
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=12000)
|
||
except Exception:
|
||
pass
|
||
|
||
def gigs_landed(p):
|
||
"""Boot ?gigs=1; landed iff Lane A's augmentation produced a schedule (else the flag is inert)."""
|
||
b, pg, _ = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
return bool(pg.evaluate("() => !!(window.PROCITY.plan.gigs && window.PROCITY.plan.gigs.length)"))
|
||
except Exception:
|
||
return False
|
||
finally:
|
||
b.close()
|
||
|
||
def plansrc_landed(p):
|
||
"""Boot ?plansrc=osm; landed iff the shell exposes an 'osm' plan source (else the flag is ignored)."""
|
||
b, pg, _ = new_page(p)
|
||
try:
|
||
boot(pg, 'plansrc=osm')
|
||
src = pg.evaluate("() => (window.PROCITY.plan && (window.PROCITY.plan.source||window.PROCITY.plan.planSource)) "
|
||
"|| window.PROCITY.planSource || null")
|
||
return src == 'osm'
|
||
except Exception:
|
||
return False
|
||
finally:
|
||
b.close()
|
||
|
||
# ── the three checks ─────────────────────────────────────────────────────────
|
||
def classic_regression(p):
|
||
# [R16 — THE FLIP] The prime-law covenant MOVED from "flags off" to "?classic=1": the classic boot must
|
||
# stay byte-identical to the frozen v2 baseline FOREVER — synthetic fingerprint 0x5f76e76, zero gig
|
||
# layer, no opt-in flags, the R7 streamed roster (which IS the v2.0 baseline). Same assertions as the old
|
||
# flags-off gate, now booted with ?classic=1 (the four flipped flags are off, everything else v2-default).
|
||
head('CLASSIC REGRESSION (?classic=1 → the frozen v2 covenant)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'classic=1')
|
||
state = pg.evaluate("""() => {
|
||
const P=window.PROCITY, D=window.DBG;
|
||
D.shot('street_noon'); // fixed pose → comparable draws/tris
|
||
const i = D.info();
|
||
const c = P.citizens || {};
|
||
return { draws:i.drawCalls, tris:i.tris, mode:i.mode,
|
||
winmap: !!(P.winmap && P.winmap.active),
|
||
digActive: !!(P.interiorMode && P.interiorMode.digActive),
|
||
streamMode: !!c.streamMode, // R7: real roster-mode flag (was reading a nonexistent prop)
|
||
// [R12] the gig layer must be wholly absent flags-off: no state machine, no schedule
|
||
// in the plan, no pub converted, and none of Lane B's venue geometry in the scene.
|
||
gigState: !!P.gigs, planGigs: (P.plan.gigs||[]).length, posters: (P.plan.posters||[]).length,
|
||
pub: !!(P.plan.shops||[]).find(s => s.venue || s.type === 'pub'),
|
||
venueGeo: !!P.scene.getObjectByName('venue-presentation') };
|
||
}""")
|
||
# determinism anchor — run Lane A's selfcheck in-process (golden fingerprint)
|
||
r = subprocess.run(['node', 'web/js/citygen/selfcheck.js'], cwd=str(ROOT),
|
||
capture_output=True, text=True)
|
||
# [R27w5] The hardcoded literal that used to ride here (`or '3fa36874' in r.stdout`) is GONE —
|
||
# A caught it. It was dead the moment the covenant moved, and worse than dead: an OR-clause
|
||
# matching a bare string anywhere in stdout would go green off a sentence ABOUT the old covenant.
|
||
# The check derives from GOLDEN_HASH or it doesn't exist.
|
||
hash_ok = (f'{GOLDEN_HASH:#010x}' in r.stdout) or (hex(GOLDEN_HASH) in r.stdout)
|
||
# [R24] These are TWO assertions and they get two verdicts. This gate used to require
|
||
# `hash_ok and r.returncode == 0` and report BOTH failures with the fingerprint's message — so
|
||
# any unrelated red anywhere in Lane A's selfcheck (R24: 8 godverse-identity checks in a
|
||
# different town) screamed "determinism/prime-law broken", the loudest alarm we own, about a
|
||
# covenant that was provably intact (then 0x3fa36874, node-side, unmoved). A gate must name what it
|
||
# actually measured; F wrote this one, so F fixed it. The covenant is asserted on its own.
|
||
if hash_ok: OK(f'plan fingerprint == golden {hex(GOLDEN_HASH)} — the classic covenant holds')
|
||
else: FAIL(f'plan fingerprint != golden {hex(GOLDEN_HASH)} — determinism/prime-law broken')
|
||
if r.returncode == 0: OK('Lane A selfcheck green')
|
||
else:
|
||
tail = [l for l in r.stdout.splitlines() if l.strip().startswith('✗')][:3]
|
||
FAIL('Lane A selfcheck RED (separate from the covenant above): '
|
||
+ ('; '.join(t.strip() for t in tail) if tail else 'see selfcheck output'))
|
||
|
||
# winmap flips OFF under ?classic (it's default-on now); dig stays opt-in. The streamed roster IS the
|
||
# v2.0 baseline (R7 flip / prime-law amendment) — so it must be ON under ?classic.
|
||
if state['winmap'] or state['digActive']:
|
||
FAIL(f"a flipped/opt-in flag leaked into ?classic (winmap/dig): {state}")
|
||
else: OK('flipped + opt-in flags off under ?classic (winmap/dig)')
|
||
if state['streamMode']:
|
||
OK('R7 roster flip in effect — streamed roster ACTIVE under ?classic (the v2 baseline)')
|
||
else: FAIL('roster flip REGRESSED — ?classic is not streaming (expected streamMode:true)')
|
||
|
||
# [R12/R16] gig layer wholly absent under ?classic. Any of these present means the gig layer escaped
|
||
# the classic gate and the frozen v2 covenant is broken.
|
||
gig_leaks = [k for k in ('gigState', 'planGigs', 'posters', 'pub', 'venueGeo') if state[k]]
|
||
if not gig_leaks:
|
||
OK('v3 gig layer wholly absent under ?classic (no state machine, no plan.gigs/posters, no pub, no venue geometry)')
|
||
else:
|
||
FAIL(f"v3 gig layer LEAKED into ?classic: {[(k, state[k]) for k in gig_leaks]}")
|
||
|
||
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
|
||
OK(f"?classic street view within budget (draws {state['draws']}≤{STREET_DRAWS_MAX}, tris {state['tris']}≤{STREET_TRIS_MAX})")
|
||
else: FAIL(f"?classic street view OVER budget: draws {state['draws']}, tris {state['tris']}")
|
||
|
||
# Drift WARN is DRAWS-ONLY — tris is crowd-variable post-flip (guarded by the hard cap above).
|
||
dd = abs(state['draws'] - BASE_DRAWS) / BASE_DRAWS
|
||
if dd <= 0.40:
|
||
OK(f"draws within 40% of re-pinned baseline ({state['draws']} vs {BASE_DRAWS}); tris {state['tris']} (crowd-variable, ≤cap)")
|
||
else:
|
||
WARN(f"draws drifted >40% from baseline ({state['draws']} vs {BASE_DRAWS}) — investigate")
|
||
|
||
if errs: FAIL(f"{len(errs)} console error(s) under ?classic; first: {errs[0][:140]}")
|
||
else: OK('0 console errors under ?classic')
|
||
|
||
# R7 escape hatch: ?roster=v1 must restore the fixed roster (streamMode:false), render, no errors.
|
||
b2, pg2, errs2 = new_page(p)
|
||
try:
|
||
boot(pg2, 'roster=v1')
|
||
esc = pg2.evaluate("() => ({ streamMode: !!(window.PROCITY.citizens||{}).streamMode, mode: window.DBG.info().mode })")
|
||
if not esc['streamMode'] and esc['mode'] == 'street':
|
||
OK('escape hatch ?roster=v1 → fixed roster (streamMode:false), renders')
|
||
else: FAIL(f"escape hatch ?roster=v1 broken: {esc}")
|
||
if errs2: FAIL(f"?roster=v1: {len(errs2)} console error(s); first: {errs2[0][:140]}")
|
||
finally:
|
||
b2.close()
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def default_boot_gate(p):
|
||
# [R16 — THE FLIP] The enforcement arm of the DEFAULT law: the no-flag boot is now the full experience —
|
||
# all four flags on, the gig layer present, deterministic to the gig golden. This is the number every
|
||
# budget/smoke now means (its inverse is classic_regression above).
|
||
head('DEFAULT-BOOT GATE (the flip → the town shows its best by default)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
state = pg.evaluate("""() => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
D.setSegment(5); D.shot('venue_night'); // NIGHT at a venue → the busiest default view (gigs+pools+winmap+tram)
|
||
const i = D.info();
|
||
return { flags: P.flags,
|
||
gigState: !!P.gigs, planGigs: (P.plan.gigs||[]).length, posters: (P.plan.posters||[]).length,
|
||
pub: !!(P.plan.shops||[]).find(s => s.venue), venueGeo: !!P.scene.getObjectByName('venue-presentation'),
|
||
winmapActive: !!(P.winmap && P.winmap.active), tram: !!P.scene.getObjectByName('tram'),
|
||
draws: i.drawCalls, tris: i.tris };
|
||
}""")
|
||
f = state['flags'] or {}
|
||
if f.get('gigs') and f.get('weather') and f.get('winmap') and f.get('tram') and not f.get('classic'):
|
||
OK('default boot flips all four ON (gigs · weather · winmap · tram), classic off')
|
||
else:
|
||
FAIL(f"default boot flag intent wrong: {f}")
|
||
present = [k for k in ('gigState', 'planGigs', 'posters', 'pub', 'venueGeo') if state[k]]
|
||
if len(present) == 5 and state['tram']:
|
||
OK(f"the town is live: gig layer present ({state['planGigs']} gigs, {state['posters']} posters, venue geo) · tram up (winmap is a window shader — its own smoke)")
|
||
else:
|
||
FAIL(f"default boot not fully live: gig={present}, tram={state['tram']}")
|
||
# The default boot's plan IS the gig-augmented plan; selfcheck pins its fingerprint to the gig golden
|
||
# (GIG_GOLDEN) INTERNALLY and is ALL GREEN only if it matches. (selfcheck prints the base fingerprint
|
||
# in its summary but asserts the gig golden silently — so we gate on the green run, not a substring.)
|
||
r = subprocess.run(['node', 'web/js/citygen/selfcheck.js'], cwd=str(ROOT), capture_output=True, text=True)
|
||
if r.returncode == 0 and 'ALL GREEN' in r.stdout:
|
||
OK(f"default plan pins to the gig golden {hex(GIG_GOLDEN)} (selfcheck ALL GREEN — the gig-layer golden asserts pass)")
|
||
else:
|
||
FAIL(f"selfcheck not green — the gig golden {hex(GIG_GOLDEN)} or determinism broke: {r.stdout[-160:]}")
|
||
# budget on the DEFAULT boot at the busiest venue block (the number the budget law now means)
|
||
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
|
||
OK(f"default-boot street budget @ venue block: {state['draws']} draws ≤ {STREET_DRAWS_MAX}, {state['tris']:,} tris ≤ {STREET_TRIS_MAX:,}")
|
||
else:
|
||
FAIL(f"default-boot street OVER budget @ venue block: {state['draws']} draws / {state['tris']:,} tris (Fable scope call — not silent-trim)")
|
||
if errs: FAIL(f"{len(errs)} console error(s) on the default boot; first: {errs[0][:140]}")
|
||
else: OK('0 console errors on the default boot')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def smoke(p, label, query, is_combo=False):
|
||
head(f'SMOKE: {label} (?{query})')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, query)
|
||
# cross ≥2 chunks by teleporting between separated street nodes
|
||
chunks = pg.evaluate("""() => {
|
||
const P=window.PROCITY, D=window.DBG, seen=new Set();
|
||
const ns = P.plan.streets.nodes;
|
||
for (const idx of [0, (ns.length/3|0), (2*ns.length/3|0), ns.length-1]) {
|
||
const n = ns[idx]; D.teleport(n.x, n.z, 0);
|
||
for (let k=0;k<10;k++) (P.citizens&&P.citizens.update&&P.citizens.update(0.05));
|
||
const c = D.info().chunk; if (c!=null) seen.add(String(c));
|
||
}
|
||
return seen.size;
|
||
}""")
|
||
if chunks >= 2: OK(f'crossed {chunks} chunks streaming (flag active, no crash)')
|
||
else: WARN(f'only {chunks} distinct chunks seen (teleport/stream probe)')
|
||
|
||
# enter one interior (open shop), exit
|
||
entered = pg.evaluate("""() => {
|
||
const P=window.PROCITY, D=window.DBG;
|
||
D.setSegment(2);
|
||
const s=(P.plan.shops||[]).find(x=>x.type==='record' && P.isOpen(x)) || (P.plan.shops||[]).find(x=>P.isOpen(x));
|
||
if(!s) return {ok:false};
|
||
D.enterShop(s.id);
|
||
const inMode = D.info().mode==='interior';
|
||
return {ok:inMode, shop:s.name};
|
||
}""")
|
||
if entered.get('ok'): OK(f"entered interior '{entered.get('shop')}' (mode=interior)")
|
||
else: FAIL('could not enter an interior')
|
||
|
||
# dig-specific: open one riffle + close, leak-free
|
||
if 'dig=1' in query:
|
||
dug = pg.evaluate("""() => {
|
||
const P=window.PROCITY, THREE=P.THREE, room=P.interiorMode.current;
|
||
if(!room) return {ok:false, why:'no room'};
|
||
const bins=[]; room.group.traverse(o=>{ if(o.userData&&o.userData.kind==='bin') bins.push(o); });
|
||
if(!bins.length) return {ok:false, why:'no bins'};
|
||
const bp=new THREE.Vector3(); bins[0].getWorldPosition(bp);
|
||
P.camera.position.set(bp.x,1.6,bp.z+1.8); P.camera.lookAt(bp.x,bp.y+0.4,bp.z); P.camera.updateMatrixWorld();
|
||
window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'}));
|
||
const opened = P.interiorMode.digActive;
|
||
const btn=document.querySelector('.pcdg-out'); if(btn) btn.click();
|
||
return { ok: opened, closed: !P.interiorMode.digActive };
|
||
}""")
|
||
pg.wait_for_timeout(300)
|
||
if dug.get('ok') and dug.get('closed'): OK('dig: riffle opened (E on bin) + closed (WALK OUT)')
|
||
elif dug.get('ok'): WARN(f"dig: opened but close not confirmed ({dug})")
|
||
else: FAIL(f"dig: riffle did not open ({dug.get('why','?')})")
|
||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||
|
||
if errs: FAIL(f"{label}: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK(f'{label}: 0 console errors')
|
||
finally:
|
||
b.close()
|
||
|
||
def interior_budget(p):
|
||
"""Decision #2: interior draw budget is law — ≤350 draws worst room. Sweep every shop type
|
||
(dig on, so this also confirms batching kept ?dig=1 riffling). C enforces via its sweep; F pins it."""
|
||
head('INTERIOR DRAW BUDGET (≤350 draws/room law — decision #2)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'dig=1')
|
||
pg.evaluate("() => window.DBG.setSegment(2)") # midday → shops open
|
||
types = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall']
|
||
rows, seen = [], set()
|
||
for t in types:
|
||
picked = pg.evaluate("""(t) => {
|
||
const P=window.PROCITY, D=window.DBG;
|
||
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x));
|
||
if(!s) return null; D.enterShop(s.id); return {id:s.id, name:s.name};
|
||
}""", t)
|
||
if not picked:
|
||
continue
|
||
pg.wait_for_timeout(500) # let async GLB fittings load → settled worst-case draws
|
||
info = pg.evaluate("() => { const i=window.DBG.info(); return {draws:i.drawCalls, tris:i.tris, mode:i.mode}; }")
|
||
rows.append({'type': t, 'shop': picked['name'], **info})
|
||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||
pg.wait_for_timeout(150)
|
||
over = [r for r in rows if r['draws'] > 350]
|
||
for r in over:
|
||
FAIL(f"interior {r['type']} '{r['shop']}': {r['draws']} draws > 350")
|
||
if rows and not over:
|
||
worst = max(rows, key=lambda r: r['draws'])
|
||
OK(f"all {len(rows)} room types ≤350 draws (worst: {worst['type']} {worst['draws']}, tris {worst['tris']})")
|
||
elif not rows:
|
||
WARN('no interiors sampled (no open shops of the swept types)')
|
||
if errs:
|
||
FAIL(f"interior sweep: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def _enter_type(pg, t, extra=''):
|
||
"""Enter an open shop of type `t`; returns the shop name or {err}."""
|
||
return pg.evaluate("""(t) => {
|
||
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x));
|
||
if(!s) return {err:'no open '+t+' shop'};
|
||
D.enterShop(s.id); return { shop: s.name };
|
||
}""", t)
|
||
|
||
def _pack_ready(pg, t):
|
||
try:
|
||
pg.wait_for_function("async () => { const m = await import('./js/interiors/interiors.js');"
|
||
f" return !!(m.getStockPack && m.getStockPack('{t}')); }}", timeout=12000)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def smoke_stock(p):
|
||
"""GRADUATED STRICT (R8): ?stock=real → real GODVERSE sleeves (batched userData.isStock atlas meshes)."""
|
||
head('SMOKE: stock=real (?stock=real — real record sleeves; STRICT r8)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'stock=real&dig=1')
|
||
ready = _pack_ready(pg, 'record')
|
||
r = _enter_type(pg, 'record')
|
||
if r.get('err'): FAIL(f"stock=real: {r['err']}"); return
|
||
n = pg.evaluate("() => { let n=0; window.PROCITY.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.isStock&&o.material&&o.material.map) n++; }); return n; }")
|
||
if not ready: FAIL(f"stock=real: record pack didn't preload in 12s (E ships it) — got {n} isStock meshes")
|
||
elif n > 0: OK(f"stock=real: {n} real atlas sleeve mesh(es) in '{r['shop']}' — non-parody, batched")
|
||
else: FAIL(f"stock=real: pack loaded but 0 isStock atlas meshes in '{r['shop']}'")
|
||
if errs: FAIL(f"stock=real: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_weather(p):
|
||
"""GRADUATED STRICT (R8): ?weather=rain → rain THREE.Points layer + PROCITY.weather.state=='rain'."""
|
||
head('SMOKE: weather (?weather=rain — rain particles + wet ground; STRICT r8)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'weather=rain')
|
||
st = pg.evaluate("() => { const P=window.PROCITY; return { state: P.weather&&P.weather.state, "
|
||
"intensity: P.weather&&P.weather.intensity, rainPts: !!P.scene.getObjectByProperty('type','Points') }; }")
|
||
if st['state'] == 'rain' and st['rainPts']:
|
||
OK(f"weather=rain: rain Points present + PROCITY.weather.state=='rain' (intensity {st['intensity']})")
|
||
else: FAIL(f"weather=rain broken: {st}")
|
||
if errs: FAIL(f"weather=rain: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_buy(p):
|
||
"""NEW (warn): buy-v0 — open dig, pull + BUY a sleeve, assert wallet cash dropped + item banked."""
|
||
head('SMOKE: buy-v0 (dig BUY → session wallet; STRICT r9)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'dig=1')
|
||
setup = pg.evaluate("""() => {
|
||
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||
const rec=(P.plan.shops||[]).find(s=>s.type==='record' && P.isOpen(s));
|
||
if(!rec) return {err:'no record shop'};
|
||
D.enterShop(rec.id);
|
||
const bins=[]; P.interiorMode.current.group.traverse(o=>{if(o.userData&&o.userData.kind==='bin')bins.push(o)});
|
||
if(!bins.length) return {err:'no bins'};
|
||
const bp=new P.THREE.Vector3(); bins[0].getWorldPosition(bp);
|
||
P.camera.position.set(bp.x,1.6,bp.z+1.4); P.camera.lookAt(bp.x,bp.y+0.4,bp.z); P.camera.updateMatrixWorld();
|
||
return { cash0: P.wallet.cash(), count0: P.wallet.count() };
|
||
}""")
|
||
if setup.get('err'): FAIL(f"buy-v0: {setup['err']}"); return
|
||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'}))") # open dig
|
||
pg.wait_for_timeout(600)
|
||
# pull the front sleeve (canvas click, no drag → dig.pull) then click its BUY IT button
|
||
pg.evaluate("""() => { const c=window.PROCITY.renderer.domElement;
|
||
c.dispatchEvent(new PointerEvent('pointerdown',{clientX:640,clientY:360,bubbles:true}));
|
||
c.dispatchEvent(new PointerEvent('pointerup',{clientX:640,clientY:360,bubbles:true})); }""")
|
||
pg.wait_for_timeout(400)
|
||
res = pg.evaluate("""() => {
|
||
const w=window.PROCITY.wallet, cash0=w.cash(), count0=w.count();
|
||
const btn=document.querySelector('.pcdg-panel button');
|
||
if(!btn) return {err:'no BUY panel (pull failed)'};
|
||
if(btn.disabled) return {err:'BUY disabled (broke?)'};
|
||
btn.click();
|
||
return { cash0, count0, cash1:w.cash(), count1:w.count() };
|
||
}""")
|
||
if res.get('err'): FAIL(f"buy-v0: {res['err']}")
|
||
elif res['cash1'] < res['cash0'] and res['count1'] == res['count0'] + 1:
|
||
OK(f"buy-v0: bought 1 — cash {res['cash0']}→{res['cash1']}, bag {res['count0']}→{res['count1']}")
|
||
else: FAIL(f"buy-v0: unexpected wallet delta {res}")
|
||
if errs: FAIL(f"buy-v0: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_patronage(p):
|
||
"""NEW (warn): peds duck into open shops — assert ≥1 ped parks at a shop door over N seconds."""
|
||
head('SMOKE: patronage (peds visit open shops; STRICT r9)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '') # default: streamed roster + patronage on
|
||
# advance the sim ~12s of ticks at a busy midday node; count peds flagged as inside/at-door
|
||
res = pg.evaluate("""() => {
|
||
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||
const plan=P.plan, deg={}; for(const e of plan.streets.edges){deg[e.a]=(deg[e.a]||0)+1;deg[e.b]=(deg[e.b]||0)+1;}
|
||
let best=null,bd=0; for(const n of plan.streets.nodes){const d=deg[n.id]||0;if(d>bd){bd=d;best=n;}}
|
||
D.teleport(best.x,best.z,0); P.citizens.setPopulation(200);
|
||
let everInside=0; const seen=new Set();
|
||
for(let i=0;i<360;i++){ P.citizens.update(0.05);
|
||
const list=P.citizens._activeList||[];
|
||
for(const c of list){ if(c && (c.patronInside||c.inside||c.patronTarget) && !seen.has(c.id)){ seen.add(c.id); everInside++; } }
|
||
}
|
||
return { active:(P.citizens._activeList||[]).length, everVisited: everInside, patronOn: P.citizens.patronageOn };
|
||
}""")
|
||
if not res['patronOn']: FAIL("patronage: off (patronageOn=false) — expected default-on")
|
||
elif res['everVisited'] > 0: OK(f"patronage: {res['everVisited']} ped(s) headed to/into a shop door over ~18s (active {res['active']})")
|
||
else: FAIL(f"patronage: 0 peds visited a shop over ~18s (active {res['active']}) — check door points / timing")
|
||
if errs: FAIL(f"patronage: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_booktoy(p):
|
||
"""NEW (warn): book/toy packs — ?stock=real static shelf stock (real spine/box atlas meshes)."""
|
||
head('SMOKE: book/toy stock (?stock=real — real spines/boxes; STRICT r9)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'stock=real')
|
||
for t in ('book', 'toy'):
|
||
_pack_ready(pg, t)
|
||
r = _enter_type(pg, t)
|
||
if r.get('err'): FAIL(f"book/toy: {r['err']}"); continue
|
||
# R9: real static stock is EITHER an isStock atlas mesh (records/toys) OR a buyMesh shelf
|
||
# (book spines / toy boxes, C2 buy-anywhere). Parody stock has neither.
|
||
n = pg.evaluate("""() => { let n=0; window.PROCITY.interiorMode.current.group.traverse(o=>{
|
||
const u=o.userData||{}; if((u.isStock && o.material && o.material.map) || (u.buyMesh && u.buyItems && u.buyItems.length)) n++; }); return n; }""")
|
||
if n > 0: OK(f"{t} stock: {n} real stock mesh(es) in '{r['shop']}'")
|
||
else: FAIL(f"{t} stock: 0 isStock atlas meshes in '{r['shop']}' (pack missing → parody fallback?)")
|
||
pg.evaluate("() => window.DBG.exitShop && window.DBG.exitShop()")
|
||
pg.wait_for_timeout(150)
|
||
if errs: FAIL(f"book/toy: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_presence(p):
|
||
"""NEW (warn): interior presence — enter an OCCUPIED shop, assert browser rigs == min(occupancy,3),
|
||
dispose clean on exit, 0 errors (the C→D→F seam)."""
|
||
head('SMOKE: interior presence (browsers in occupied shops; new → warn-level)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'stock=real')
|
||
res = pg.evaluate("""() => {
|
||
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||
const plan=P.plan, deg={}; for(const e of plan.streets.edges){deg[e.a]=(deg[e.a]||0)+1;deg[e.b]=(deg[e.b]||0)+1;}
|
||
let best=null,bd=0; for(const n of plan.streets.nodes){const d=deg[n.id]||0;if(d>bd){bd=d;best=n;}}
|
||
D.teleport(best.x,best.z,0); P.citizens.setPopulation(200);
|
||
for(let i=0;i<400;i++) P.citizens.update(0.05);
|
||
let shop=null,occ=null;
|
||
for(const s of plan.shops){ const o=P.citizens.occupancyOf(s.id); if(o&&o.count>0){shop=s;occ=o;break;} }
|
||
if(!shop) return {err:'no occupied shop after patronage'};
|
||
D.enterShop(shop.id);
|
||
const kl=P.interiorMode.keepers.keepers||[];
|
||
const total=kl.length, pts=(P.interiorMode.current.browsePoints||[]).length;
|
||
return { shop:shop.name, occ:occ.count, expect:Math.min(occ.count,3), totalKeepers:total, browsePoints:pts };
|
||
}""")
|
||
if res.get('err'): WARN(f"presence: {res['err']}"); return
|
||
# total keepers = 1 counter + browsers; browsers should == min(occ,3)
|
||
browsers = res['totalKeepers'] - 1
|
||
if browsers == res['expect'] and res['browsePoints'] >= 1:
|
||
OK(f"presence: {browsers} browser(s) in '{res['shop']}' == min(occupancy {res['occ']},3); {res['browsePoints']} browse points")
|
||
else:
|
||
WARN(f"presence: browsers {browsers} != expect {res['expect']} (occ {res['occ']}, pts {res['browsePoints']}, keepers {res['totalKeepers']})")
|
||
clean = pg.evaluate("() => { window.DBG.exitShop(); return (window.PROCITY.interiorMode.keepers.keepers||[]).length; }")
|
||
if clean == 0: OK("presence: browsers disposed clean on exit (0 keepers)")
|
||
else: WARN(f"presence: {clean} keeper(s) leaked after exit")
|
||
if errs: WARN(f"presence: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_shelfbuy(p):
|
||
"""NEW (warn): buy-anywhere — E aimed at a book spine / toy box buys over the wallet (Lane C C2)."""
|
||
head('SMOKE: shelf-buy (?stock=real book/toy pull-and-buy; new → warn-level)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'stock=real')
|
||
_pack_ready(pg, 'book')
|
||
res = pg.evaluate("""() => {
|
||
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||
const bk=(P.plan.shops||[]).find(s=>s.type==='book' && P.isOpen(s));
|
||
if(!bk) return {err:'no book shop'};
|
||
D.enterShop(bk.id);
|
||
const meshes=[]; P.interiorMode.current.group.traverse(o=>{ if(o.userData&&o.userData.buyMesh&&o.userData.buyItems&&o.userData.buyItems.length) meshes.push(o); });
|
||
if(!meshes.length) return {err:'no buy meshes (pack not loaded?)'};
|
||
const m=meshes[0], it=m.userData.buyItems[0], wp=new P.THREE.Vector3();
|
||
m.localToWorld(wp.copy(it.center));
|
||
P.camera.position.set(wp.x,wp.y,wp.z+1.0); P.camera.lookAt(wp.x,wp.y,wp.z); P.camera.updateMatrixWorld();
|
||
const cash0=P.wallet.cash(), count0=P.wallet.count();
|
||
window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyE'}));
|
||
return { shop:bk.name, buyMeshes:meshes.length, cash0, count0, cash1:P.wallet.cash(), count1:P.wallet.count() };
|
||
}""")
|
||
if res.get('err'): WARN(f"shelf-buy: {res['err']}")
|
||
elif res['cash1'] < res['cash0'] and res['count1'] == res['count0'] + 1:
|
||
OK(f"shelf-buy: bought a book spine in '{res['shop']}' — cash {res['cash0']}→{res['cash1']}, bag {res['count0']}→{res['count1']} ({res['buyMeshes']} buy meshes)")
|
||
else: WARN(f"shelf-buy: no purchase — {res}")
|
||
if errs: WARN(f"shelf-buy: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_tram(p):
|
||
"""NEW (warn): ?tram=1 — Lane B tram loop. Auto-skips if not landed."""
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'tram=1')
|
||
present = pg.evaluate("() => !!window.PROCITY.scene.getObjectByName('tram')")
|
||
if not present:
|
||
print("· tram=1 not landed (no 'tram' in scene) — skipping (Lane B)")
|
||
return
|
||
head('SMOKE: tram (?tram=1; new → warn-level)')
|
||
OK("tram=1: 'tram' present in scene")
|
||
if errs: WARN(f"tram: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_audio(p):
|
||
"""R11 audio house-law (Lane F): silent-and-happy, nothing plays pre-gesture, ?mute=1 silences,
|
||
?noassets=1 fetches zero audio, and interior beds play + release AudioNodes across enter/exit.
|
||
Each arm uses a fresh Chromium context (standing law — a reused tab's module cache burned us in R10)."""
|
||
head('SMOKE: audio (R11 — silent-happy · pre-gesture silence · mute · noassets · leak)')
|
||
|
||
# 1) boot-with-audio: 0 console errors, and nothing plays before a gesture (ctx suspended → state.ready false)
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
pg.wait_for_function("() => window.PROCITY && window.PROCITY.audio && window.PROCITY.audio.state", timeout=8000)
|
||
st = pg.evaluate("() => { const s=window.PROCITY.audio.state; return {ready:!!s.ready, muted:!!s.muted, mode:s.mode}; }")
|
||
if not st['ready']:
|
||
OK(f"pre-gesture: audio NOT unlocked (state.ready=false, mode={st['mode']}) — silent until first gesture")
|
||
else:
|
||
FAIL(f"audio unlocked before any user gesture (autoplay-policy violation): {st}")
|
||
if errs: FAIL(f"audio boot: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK("audio boot: 0 console errors (silent-and-happy)")
|
||
finally:
|
||
b.close()
|
||
|
||
# 2) ?mute=1 → engine reports muted
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'mute=1')
|
||
pg.wait_for_function("() => window.PROCITY && window.PROCITY.audio", timeout=8000)
|
||
st = pg.evaluate("() => ({muted:!!window.PROCITY.audio.state.muted, mode:window.PROCITY.audio.state.mode})")
|
||
if st['muted']: OK(f"?mute=1: engine muted (mode={st['mode']})")
|
||
else: FAIL(f"?mute=1 not honored: {st}")
|
||
if errs: FAIL(f"?mute=1: {len(errs)} console error(s)")
|
||
finally:
|
||
b.close()
|
||
|
||
# 3) ?noassets=1 → zero audio network fetches (house law: the noassets gate implies no audio)
|
||
b, pg, errs = new_page(p)
|
||
audio_reqs = []
|
||
pg.on('request', lambda r: audio_reqs.append(r.url)
|
||
if ('/assets/audio/' in r.url or r.url.endswith('.ogg') or r.url.endswith('.m4a')) else None)
|
||
try:
|
||
boot(pg, 'noassets=1')
|
||
pg.keyboard.press('Space') # a keyboard gesture unlocks the AudioContext WITHOUT a pointer-lock attempt (headless-safe) # a gesture, so the audio path is genuinely exercised
|
||
pg.evaluate("""() => { const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||
const s=(P.plan.shops||[]).find(x=>P.isOpen(x)); if(s) D.enterShop(s.id); }""")
|
||
pg.wait_for_timeout(800)
|
||
man = pg.evaluate("() => window.PROCITY.audio.state.manifest")
|
||
if len(audio_reqs) == 0: OK(f"?noassets=1: 0 audio fetches (manifest={man})")
|
||
else: FAIL(f"?noassets=1: {len(audio_reqs)} audio fetch(es) leaked; first {audio_reqs[0]}")
|
||
if errs: FAIL(f"?noassets=1 audio: {len(errs)} console error(s)")
|
||
finally:
|
||
b.close()
|
||
|
||
# 4) interior beds play + enter/exit is AudioNode-leak-free (live BufferSources stay bounded, not O(N))
|
||
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.add_init_script("""
|
||
window.__live = new Set(); window.__made = 0;
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
if (AC) { const orig = AC.prototype.createBufferSource;
|
||
AC.prototype.createBufferSource = function () { const s = orig.call(this);
|
||
window.__made++; window.__live.add(s);
|
||
const ostop = s.stop.bind(s); s.stop = function (w) { window.__live.delete(s); return ostop(w); };
|
||
s.addEventListener('ended', () => window.__live.delete(s)); return s; }; }
|
||
""")
|
||
try:
|
||
boot(pg, '')
|
||
pg.keyboard.press('Space') # a keyboard gesture unlocks the AudioContext WITHOUT a pointer-lock attempt (headless-safe) # user gesture → unlock the AudioContext
|
||
pg.wait_for_timeout(250)
|
||
played = pg.evaluate("""async () => {
|
||
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
|
||
const types=['record','milkbar','video','opshop','book','toy'];
|
||
let entered=0, withMusic=0;
|
||
for (let rep=0; rep<2; rep++) for (const t of types) {
|
||
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x)); if(!s) continue;
|
||
D.enterShop(s.id); entered++;
|
||
const a = P.interiorMode.current && P.interiorMode.current.audio;
|
||
if (a && a.musicKey) withMusic++;
|
||
await new Promise(r=>setTimeout(r,120));
|
||
D.exitShop && D.exitShop(); await new Promise(r=>setTimeout(r,120));
|
||
}
|
||
return { entered, withMusic, ready:!!P.audio.state.ready };
|
||
}""")
|
||
pg.wait_for_timeout(600)
|
||
live = pg.evaluate("() => window.__live.size")
|
||
made = pg.evaluate("() => window.__made")
|
||
if not played['ready']:
|
||
WARN(f"audio interior: ctx never unlocked in headless — leak check inconclusive ({played}, live {live}/made {made})")
|
||
elif played['entered'] >= 4 and live <= 12:
|
||
OK(f"audio interior: {played['entered']} enter/exit, {played['withMusic']} with a music bed; live AudioNodes {live} bounded (made {made}) — leak-free")
|
||
else:
|
||
FAIL(f"audio interior: {live} live AudioNodes after {played['entered']} enter/exit (made {made}) — possible leak")
|
||
if errs: FAIL(f"audio interior: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK("audio interior: 0 console errors across enter/exit cycles")
|
||
finally:
|
||
b.close()
|
||
|
||
def smoke_gigs(p):
|
||
"""R13 v3.0-beta THE DISTRICT gig layer (Lane F). The ROUND13 §Lane F.6 gates — district determinism ·
|
||
a 4-piece band · per-venue crowd ≤ watchPoints · EVERY venue's gigKey resolves to a real manifest bed
|
||
(debt #1, now across 3 genres) · gig-night interior draw budget · street budget at the busiest venue
|
||
block · no-giants (STATURE) on the stage/crowd rigs · queue ≤ cap and drains by 'done' (pending Lane D)
|
||
· ?noassets=1 still silent-and-fine with ?gigs=1 — plus John's cover ruling, now PER VENUE (paid · free
|
||
· broke-knockback · no re-charge on re-entry · a night at two venues is two covers).
|
||
|
||
Seeds are pinned because `cover` is seeded: 20261990's tonight is a $10 night (Screaming Utes at The
|
||
Thornbury Hotel) and 1234's is free (Dazza & The Dropbears at The Imperial), so BOTH halves of "free
|
||
AND paid" are covered deterministically rather than whichever the default seed happened to roll."""
|
||
head('SMOKE: gigs (R13 v3.0-beta district — determinism · 4-piece · per-venue crowd/cover · gigKeys · budgets · queue · noassets)')
|
||
if not gigs_landed(p):
|
||
print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)')
|
||
return
|
||
|
||
# 1) schedule determinism — two independent boots (fresh contexts) → byte-equal gigs + posters
|
||
sigs = []
|
||
for _ in range(2):
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
sigs.append(pg.evaluate("() => JSON.stringify({g: window.PROCITY.plan.gigs, p: window.PROCITY.plan.posters})"))
|
||
if errs: FAIL(f"gigs boot: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
if sigs[0] == sigs[1]:
|
||
n = len(json.loads(sigs[0])['g'])
|
||
OK(f"gig schedule deterministic: two runs byte-equal ({n} nights + posters)")
|
||
else:
|
||
FAIL('gig schedule NOT deterministic across two runs (byte-diff in plan.gigs/plan.posters)')
|
||
|
||
# 2) the gig night itself: band · crowd · dance mix · budget · the live bed · no giants
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
pg.keyboard.press('Space') # gesture → AudioContext unlock (headless-safe, no pointer-lock)
|
||
night = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
|
||
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id
|
||
D.setSegment(5); // NIGHT — the gig is on
|
||
const state = P.gigs.stateOf(id);
|
||
D.enterShop(id);
|
||
await new Promise(r => setTimeout(r, 500));
|
||
const room = P.interiorMode.current;
|
||
if (!room) return { entered: false, state };
|
||
const crew = P.interiorMode.crew, info = P.interiorMode.crewInfo;
|
||
const mem = crew ? crew.members : [];
|
||
// STATURE, not world crown: the band stands on Lane C's stage deck (deckY ~0.32 m), so a
|
||
// perfectly human 1.73 m guitarist has a world crown of 2.05 m and a naive [1.4,2.0] world-Y
|
||
// check fails a correct band (it did, on the first R12 verify run). Measure crown MINUS the
|
||
// figure's own base y — that is what "human-sized" actually means — and keep the separate
|
||
// "never clips the ceiling" assert on the world crown, which is the real R10 failure mode.
|
||
const figs = mem.map(m => {
|
||
let crown = 0;
|
||
m.actor.fig.updateWorldMatrix(true, true);
|
||
(m.actor.inner || m.actor.fig).traverse(o => { if (o.isBone && /head/i.test(o.name)) {
|
||
o.getWorldPosition(v); crown = Math.max(crown, v.y); } });
|
||
return { part: m.part, kind: m.kind, dance: !!m.dance,
|
||
crown: +crown.toFixed(3), stature: +(crown - (m.base.y || 0)).toFixed(3),
|
||
seated: !!(m.actor.fig.userData && m.actor.fig.userData.procitySeated) }; // [R16 #3]
|
||
});
|
||
// Force one clean interior frame rather than trusting whatever the last rAF left in
|
||
// renderer.info — a throttled/backgrounded tab otherwise reports the last STREET frame
|
||
// (composer + bloom), which reads as a wildly over-budget room. Bit us on the first verify.
|
||
P.renderer.info.reset();
|
||
P.renderer.render(P.interiorMode.scene, P.camera);
|
||
return {
|
||
entered: true, state, H: +room.dims.H.toFixed(2),
|
||
watchPoints: room.watchPoints.length, stage: !!room.stage,
|
||
band: info ? info.band : 0, crowd: info ? info.crowd : 0,
|
||
dancers: mem.filter(m => m.part === 'crowd' && m.dance).length,
|
||
gigKey: room.audio.gigKey || null, musicKey: room.audio.musicKey || null,
|
||
draws: P.renderer.info.render.calls, tris: P.renderer.info.render.triangles, figs,
|
||
};
|
||
}""")
|
||
if not night.get('entered'):
|
||
FAIL(f"gig night: could not enter the venue at NIGHT ({night})")
|
||
else:
|
||
if night['state'] == 'on': OK(f"gig state machine: NIGHT → 'on' (venue {night['H']}m room)")
|
||
else: FAIL(f"gig state machine: NIGHT should be 'on', got '{night['state']}'")
|
||
# band is a 4-piece (R13 debt #3: front-line trio + a drummer on C's riser)
|
||
if night['band'] == 4: OK('band: 4-piece on the stage (front-line trio + drummer)')
|
||
else: FAIL(f"band: expected a 4-piece (trio + drummer), got {night['band']}")
|
||
# crowd cap ≤ watchPoints
|
||
if night['crowd'] <= night['watchPoints'] and night['crowd'] > 0:
|
||
OK(f"crowd: {night['crowd']} at {night['watchPoints']} watch points (≤ cap)")
|
||
else:
|
||
FAIL(f"crowd {night['crowd']} vs {night['watchPoints']} watch points (must be >0 and ≤ cap)")
|
||
# John's "a bit of both": the crowd must not be all-standing or all-dancing
|
||
d, c = night['dancers'], night['crowd']
|
||
if c and 0 < d < c: OK(f"crowd mix: {d} dancing / {c - d} standing — 'a bit of both'")
|
||
else: FAIL(f"crowd is not mixed: {d} dancing of {c} (John's ruling: some of both)")
|
||
# the live bed actually resolves (the C↔E gigKey seam F bridges — see LANE_F_NOTES §12)
|
||
if night['gigKey']: OK(f"live bed: room.audio.gigKey = {night['gigKey']} (F resolves → manifest key)")
|
||
else: FAIL('live bed: no room.audio.gigKey on a gig-on venue (Lane C opts.gig seam)')
|
||
# gig-night interior draw budget (ROUND12: ≤350 incl. the crowd)
|
||
if night['draws'] <= GIG_DRAWS_MAX:
|
||
OK(f"gig-night interior budget: {night['draws']} draws ≤ {GIG_DRAWS_MAX} (tris {night['tris']:,})")
|
||
else:
|
||
FAIL(f"gig-night interior budget: {night['draws']} draws > {GIG_DRAWS_MAX} (tris {night['tris']:,})")
|
||
# no giants on the stage/crowd rigs (R10 gate, inherited by the gig path)
|
||
rigs = [f for f in night['figs'] if f['kind'] == 'rig']
|
||
bad = [f for f in rigs if not stature_ok(f) or f['crown'] >= night['H']] # [R16 #3] seated exemption
|
||
if not rigs:
|
||
WARN(f"no-giants: crew spawned as placeholders (no fleet) — rig check n/a ({len(night['figs'])} figs)")
|
||
elif bad:
|
||
FAIL(f"no-giants: {len(bad)} gig rig(s) out of [1.4,2.0]m stature or through the {night['H']}m ceiling: {bad[:3]}")
|
||
else:
|
||
lo = min(f['stature'] for f in rigs); hi = max(f['stature'] for f in rigs)
|
||
hc = max(f['crown'] for f in rigs)
|
||
OK(f"no-giants: {len(rigs)} band+crowd rigs human-sized ({lo:.2f}–{hi:.2f} m stature; "
|
||
f"tallest crown {hc:.2f} m under the {night['H']} m ceiling)")
|
||
if errs: FAIL(f"gig night: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK('gig night: 0 console errors')
|
||
finally:
|
||
b.close()
|
||
|
||
# 2b) THE DISTRICT (R13): every playing venue fires — its live bed resolves to a REAL manifest bar for
|
||
# its genre (debt #1, now across pub/band_room/rsl), its crowd is capped at its OWN watch points, and it
|
||
# is a 4-piece. Plus the queue ≤ cap / drains-by-done probe (WARN-skips until Lane D wires the seam).
|
||
music_keys = set()
|
||
try:
|
||
mani = json.loads((ROOT / 'web' / 'assets' / 'manifest.json').read_text())
|
||
music_keys = set((mani.get('audio', {}).get('music', {}) or {}).keys())
|
||
except Exception as e:
|
||
WARN(f"district: could not read manifest music keys ({e}) — gigKey-resolves check is JS-only")
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
pg.keyboard.press('Space')
|
||
district = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG, THREE = P.THREE, v = new THREE.Vector3();
|
||
D.setSegment(5); // NIGHT — every playing venue is 'on'
|
||
const ids = P.gigs.venueShopIds || [];
|
||
const out = [];
|
||
for (const id of ids) {
|
||
const shop = (P.plan.shops || []).find(s => s.id === id);
|
||
if (!P.gigs.onOf(id)) { out.push({ id, kind: shop && shop.venueKind, dark: true }); continue; }
|
||
D.enterShop(id);
|
||
await new Promise(r => setTimeout(r, 450));
|
||
const room = P.interiorMode.current, info = P.interiorMode.crewInfo;
|
||
const mem = P.interiorMode.crew ? P.interiorMode.crew.members : [];
|
||
const figs = mem.map(m => {
|
||
let crown = 0; m.actor.fig.updateWorldMatrix(true, true);
|
||
(m.actor.inner || m.actor.fig).traverse(o => { if (o.isBone && /head/i.test(o.name)) {
|
||
o.getWorldPosition(v); crown = Math.max(crown, v.y); } });
|
||
return { part: m.part, kind: m.kind, stature: +(crown - (m.base.y || 0)).toFixed(3),
|
||
seated: !!(m.actor.fig.userData && m.actor.fig.userData.procitySeated) }; // [R16 #3]
|
||
});
|
||
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
|
||
out.push({ id, kind: shop && shop.venueKind, genreKey: shop && shop.genreKey,
|
||
gigKey: (room && room.audio && room.audio.gigKey) || null,
|
||
band: info ? info.band : 0, crowd: info ? info.crowd : 0,
|
||
watchPoints: room ? room.watchPoints.length : 0, figs });
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 200));
|
||
}
|
||
return { ids, out };
|
||
}""")
|
||
playing = [d for d in district['out'] if not d.get('dark')]
|
||
OK(f"district: {len(district['ids'])} venue(s) — {len(playing)} playing tonight, "
|
||
f"{len(district['out']) - len(playing)} dark (reads true to the seeded week)")
|
||
genres_seen = set()
|
||
for d in playing:
|
||
tag = f"{d['kind']} #{d['id']}"
|
||
want = f"gig-{d['genreKey']}"
|
||
if d['gigKey'] == want and (not music_keys or want in music_keys):
|
||
OK(f" {tag}: live bed '{d['gigKey']}' resolves to a real manifest bar (genre {d['genreKey']})")
|
||
genres_seen.add(d['genreKey'])
|
||
else:
|
||
FAIL(f" {tag}: gigKey '{d['gigKey']}' != '{want}' or not in manifest.music {sorted(music_keys)}")
|
||
if 0 < d['crowd'] <= d['watchPoints']:
|
||
OK(f" {tag}: crowd {d['crowd']} ≤ {d['watchPoints']} watch points")
|
||
else:
|
||
FAIL(f" {tag}: crowd {d['crowd']} vs {d['watchPoints']} watch points (must be >0 and ≤ cap)")
|
||
if d['band'] == 4: OK(f" {tag}: 4-piece band")
|
||
else: FAIL(f" {tag}: expected a 4-piece, got {d['band']}")
|
||
rigs = [f for f in d['figs'] if f['kind'] == 'rig']
|
||
bad = [f for f in rigs if not stature_ok(f)] # [R16 #3] seated exemption
|
||
if rigs and bad: FAIL(f" {tag}: {len(bad)} giant rig(s) by stature: {bad[:2]}")
|
||
if len(genres_seen) >= 2:
|
||
OK(f"district: {len(genres_seen)} distinct genres verified live ({', '.join(sorted(genres_seen))})")
|
||
|
||
# queue ≤ cap and drains by 'done' — Lane D's outdoor line (charter item). D shipped the VenueQueue
|
||
# class and left the street-side lifecycle to F, which wires it in the shell (spawn at doors, drive
|
||
# each frame, disposeAll at done); read via P.queueCountOf(id). The line is a seeded short 2–6.
|
||
q = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
if (typeof P.queueCountOf !== 'function') return { wired: false };
|
||
const id = P.gigs.venueShopIds[0];
|
||
D.setSegment(4); await new Promise(r => setTimeout(r, 700)); // DUSK — doors open, the line forms
|
||
const atDoors = P.queueCountOf(id) | 0;
|
||
D.setSegment(0); await new Promise(r => setTimeout(r, 700)); // DAWN — closing time ('done'), line gone
|
||
const atDone = P.queueCountOf(id) | 0;
|
||
return { wired: true, atDoors, atDone };
|
||
}""")
|
||
QUEUE_CAP = 6
|
||
if not q.get('wired'):
|
||
FAIL("queue: P.queueCountOf(id) not exposed — F's shell wiring missing")
|
||
elif 0 < q['atDoors'] <= QUEUE_CAP and q['atDone'] == 0:
|
||
OK(f"queue: {q['atDoors']} punters in line at doors (≤ cap {QUEUE_CAP}), drained to 0 by 'done'")
|
||
else:
|
||
FAIL(f"queue: at doors {q['atDoors']} (want 1..{QUEUE_CAP}), at done {q['atDone']} (want 0)")
|
||
|
||
if errs: FAIL(f"district: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK('district: 0 console errors across all venues')
|
||
finally:
|
||
b.close()
|
||
|
||
# 2c) STREET BUDGET at the busiest venue block (R13 law: ≤300 draws / ≤200k tris with the lit frontage,
|
||
# town-wide posters and the queue zone all in). Park on the footpath in front of the primary venue at
|
||
# NIGHT with the gig on, let the block stream in, and sample the settled composer frame.
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
st = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
D.setSegment(5); // NIGHT — frontage lit, posters up
|
||
const id = P.gigs.venueShopIds[0], shop = (P.plan.shops || []).find(s => s.id === id);
|
||
const lot = shop && (P.plan.lots || []).find(l => l.id === shop.lot);
|
||
if (lot) {
|
||
const fx = Math.sin(lot.ry || 0), fz = Math.cos(lot.ry || 0);
|
||
D.teleport(lot.x + fx * 10, lot.z + fz * 10, Math.atan2(-fx, -fz)); // 10 m out, facing the venue
|
||
}
|
||
await new Promise(r => setTimeout(r, 700)); // chunks/frontage/posters/pools settle
|
||
let draws = Infinity, tris = 0; // take the settled floor (ignore stream spikes)
|
||
for (let i = 0; i < 12; i++) {
|
||
await new Promise(r => requestAnimationFrame(r));
|
||
if (P.renderer.info.render.calls < draws) { draws = P.renderer.info.render.calls; tris = P.renderer.info.render.triangles; }
|
||
}
|
||
return { draws, tris, venue: shop && shop.name };
|
||
}""")
|
||
if st['draws'] <= STREET_DRAWS_MAX and st['tris'] <= STREET_TRIS_MAX:
|
||
OK(f"street budget @ venue block ({st['venue']}): {st['draws']} draws ≤ {STREET_DRAWS_MAX}, {st['tris']:,} tris ≤ {STREET_TRIS_MAX:,}")
|
||
else:
|
||
FAIL(f"street budget @ venue block ({st['venue']}): {st['draws']} draws / {st['tris']:,} tris over ({STREET_DRAWS_MAX} / {STREET_TRIS_MAX:,})")
|
||
if errs: FAIL(f"street budget: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
# 3) cover ruling, PAID night (seed 20261990 → $10): debit once · no re-charge on re-entry · broke = knockback
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
paid = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id
|
||
D.setSegment(5);
|
||
const cover = P.gigs.coverOf(id);
|
||
if (!cover) return { skip: true, cover };
|
||
const before = P.wallet.cash();
|
||
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
|
||
const inRoom1 = !!P.interiorMode.current, afterPay = P.wallet.cash();
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 250));
|
||
const mid = P.wallet.cash();
|
||
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
|
||
const inRoom2 = !!P.interiorMode.current, afterReentry = P.wallet.cash();
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 250));
|
||
// now go broke and try again on the SAME night (stamp already paid → still free), then roll the
|
||
// night over (MORNING clears the stamp) and try broke → must be knocked back at the door.
|
||
P.wallet.buy({ title: 'qa: drain', price: P.wallet.cash() });
|
||
D.setSegment(1); D.setSegment(5); // NIGHT → MORNING → NIGHT = a new night, cover due
|
||
const brokeCash = P.wallet.cash();
|
||
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
|
||
const inRoomBroke = !!P.interiorMode.current;
|
||
return { cover, before, afterPay, inRoom1, mid, afterReentry, inRoom2, brokeCash, inRoomBroke,
|
||
inv: P.wallet.inventory().map(i => i.title) };
|
||
}""")
|
||
if paid.get('skip'):
|
||
WARN(f"cover: seed {SEED} tonight is free (cover 0) — paid path not exercised here")
|
||
else:
|
||
if paid['inRoom1'] and paid['afterPay'] == paid['before'] - paid['cover']:
|
||
OK(f"cover: ${paid['cover']} debited once at the door (${paid['before']} → ${paid['afterPay']}), entry granted")
|
||
else:
|
||
FAIL(f"cover: wrong debit/entry on a paid night: {paid}")
|
||
if paid['inRoom2'] and paid['afterReentry'] == paid['mid']:
|
||
OK(f"cover: re-entry the same night is FREE (no re-charge, still ${paid['afterReentry']})")
|
||
else:
|
||
FAIL(f"cover: re-entry re-charged or was refused: {paid}")
|
||
if not paid['inRoomBroke'] and paid['brokeCash'] < paid['cover']:
|
||
OK(f"cover: broke (${paid['brokeCash']}) on a NEW night → polite knockback, no entry")
|
||
else:
|
||
FAIL(f"cover: broke player got in (or the night never rolled): {paid}")
|
||
if errs: FAIL(f"cover: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
# 4) cover ruling, FREE night (seed 1234 → cover 0): walk straight in, wallet untouched
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
pg.goto(f'{HOST}/index.html?seed={FREE_GIG_SEED}&dbg=1')
|
||
pg.wait_for_function("window.DBG && window.DBG.ready === true", timeout=20000)
|
||
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
||
free = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id
|
||
D.setSegment(5);
|
||
const cover = P.gigs.coverOf(id), before = P.wallet.cash();
|
||
D.enterShop(id); await new Promise(r => setTimeout(r, 350));
|
||
return { cover, before, after: P.wallet.cash(), inRoom: !!P.interiorMode.current, band: P.gigs.bandNameOf(id) };
|
||
}""")
|
||
if free['cover'] == 0 and free['inRoom'] and free['after'] == free['before']:
|
||
OK(f"cover: free night (seed {FREE_GIG_SEED}, {free['band']}) → walked straight in, wallet untouched (${free['after']})")
|
||
else:
|
||
FAIL(f"cover: free-night entry wrong: {free}")
|
||
if errs: FAIL(f"free night: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
# 5) ?noassets=1 + ?gigs=1 — still silent-and-fine: zero audio/band-file fetches, gig still happens
|
||
b, pg, errs = new_page(p)
|
||
reqs = []
|
||
pg.on('request', lambda r: reqs.append(r.url)
|
||
if ('/assets/audio/' in r.url or r.url.endswith('.ogg') or r.url.endswith('.m4a')
|
||
or 'custom_bands' in r.url or r.url.endswith('.glb')) else None)
|
||
try:
|
||
boot(pg, 'noassets=1')
|
||
pg.keyboard.press('Space')
|
||
na = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
const id = P.gigs.venueShopIds[0]; // R15: no scalar alias — the primary venue by id
|
||
D.setSegment(5);
|
||
D.enterShop(id); await new Promise(r => setTimeout(r, 450));
|
||
const info = P.interiorMode.crewInfo;
|
||
return { state: P.gigs.stateOf(id), inRoom: !!P.interiorMode.current, band: info && info.band,
|
||
crowd: info && info.crowd, gigs: (P.plan.gigs || []).length,
|
||
bandName: P.gigs.bandNameOf(id), audio: P.audio && P.audio.state.manifest };
|
||
}""")
|
||
if na['inRoom'] and na['band'] == 4 and na['crowd'] > 0:
|
||
OK(f"?noassets=1&gigs=1: the gig still happens (4-piece band, crowd {na['crowd']}, placeholders)")
|
||
else:
|
||
FAIL(f"?noassets=1&gigs=1: no gig without assets: {na}")
|
||
if len(reqs) == 0:
|
||
OK(f"?noassets=1&gigs=1: 0 audio/GLB/band-file fetches (manifest={na['audio']}, bands from the generator)")
|
||
else:
|
||
FAIL(f"?noassets=1&gigs=1: {len(reqs)} asset fetch(es) leaked; first {reqs[0]}")
|
||
if errs: FAIL(f"?noassets=1&gigs=1: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK('?noassets=1&gigs=1: 0 console errors (silent-and-happy)')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
GODVERSE_TOWN = 'redhill_godverse'
|
||
GODVERSE_SHOP = 3962749 # Monster Robot Party, 147 Musgrave Rd, Red Hill QLD — the one REAL crate
|
||
GODVERSE_BASE = f'assets/stock_godverse/{GODVERSE_SHOP}/'
|
||
STOCK_MANIFEST = 'assets/stock_godverse/index.json'
|
||
|
||
|
||
def smoke_real_crate(p):
|
||
"""R26 — THE BETA GATE: every crate different, sourcing visible, and NOT ONE 404.
|
||
|
||
The lineage of this gate is the epoch's whole argument. R23's #7a would have gone green on "real
|
||
covers render" while the room held the generic v2 pack. R24's brief then said assert id equality —
|
||
which also couldn't discriminate, because both packs numbered items positionally and every atlas id
|
||
was a generic-pack id too. What survives is what a wrong pack cannot fake: TITLES only this crate
|
||
has, and the TEXTURE URL the GPU actually samples.
|
||
|
||
R26 adds the two beta claims. DISTINCTNESS: 15 keyed crates, pairwise title-disjoint — asserted
|
||
across the indexes of record, so it cannot pass by entering one lucky shop. SOURCING (C's §7.2b,
|
||
which correctly overruled the brief's `tier`): every index declares real|mint, a mint crate can
|
||
never report as real, and the gate PRINTS it.
|
||
|
||
And the 404s are gone for the right reason. R24 probed every keyed shop and ate a 404 for each one
|
||
with no atlas; F's smoke could only tolerate that by naming it — a tolerance is a confession. G's
|
||
manifest now says which (shop, type) pairs exist, so the shell asks for nothing else. ZERO console
|
||
errors, NO attribution exceptions. Subject absent ⇒ SKIP with the reason, never a quiet pass."""
|
||
head(f'SMOKE: the beta crate gate (R26 — 15 crates distinct · sourcing · zero 404s · {GODVERSE_TOWN})')
|
||
b, pg, errs = new_page(p)
|
||
four04 = []
|
||
pg.on('response', lambda r: four04.append(r.url) if r.status == 404 else None)
|
||
try:
|
||
pg.goto(f'{HOST}/{STOCK_MANIFEST}')
|
||
if 'godverseShopId' not in pg.content():
|
||
print('· SKIP: no atlas manifest — subject absent, nothing asserted (Lane G)'); return
|
||
boot(pg, f'plansrc=osm&town={GODVERSE_TOWN}&stock=real&dig=1')
|
||
res = pg.evaluate("""async ({manifest, real, base}) => {
|
||
const D = window.DBG, P = window.PROCITY;
|
||
const man = await (await fetch(manifest)).json();
|
||
|
||
// ── DISTINCTNESS across the indexes of record (not just the shop we walk into) ──
|
||
const crates = [];
|
||
for (const sh of (man.shops || [])) for (const t of (sh.types || [])) {
|
||
const u = `assets/stock_godverse/${sh.godverseShopId}/stock_${t}_index.json`;
|
||
const r = await fetch(u);
|
||
if (!r.ok) return { fail: `manifest lists ${u} but it 404s` };
|
||
const idx = await r.json();
|
||
crates.push({ id: sh.godverseShopId, type: t, manSourcing: sh.sourcing,
|
||
sourcing: idx.sourcing || null,
|
||
titles: (idx.items || []).map(i => i.title) });
|
||
}
|
||
const gen = {};
|
||
for (const t of ['record', 'book', 'toy'])
|
||
gen[t] = new Set((((await (await fetch(`assets/models/stock_${t}_index.json`)).json()).items) || [])
|
||
.map(i => i.title));
|
||
let shared = 0, vsGeneric = 0, noSourcing = 0, manMismatch = 0;
|
||
for (let i = 0; i < crates.length; i++) {
|
||
if (!crates[i].sourcing) noSourcing++;
|
||
if (crates[i].sourcing !== crates[i].manSourcing) manMismatch++;
|
||
if (crates[i].titles.some(t => gen[crates[i].type] && gen[crates[i].type].has(t))) vsGeneric++;
|
||
for (let j = i + 1; j < crates.length; j++) {
|
||
const a = new Set(crates[i].titles);
|
||
if (crates[j].titles.some(t => a.has(t))) shared++;
|
||
}
|
||
}
|
||
|
||
// ── the ROOM binds to the right crate: one REAL, one MINT, one soft fall ──
|
||
const byId = new Map((man.shops || []).map(s => [s.godverseShopId, s]));
|
||
const stocked = (P.plan.shops || []).filter(s => { const e = byId.get(s.godverseShopId);
|
||
return e && (e.types || []).includes(s.type); });
|
||
const unfetch = (P.plan.shops || []).find(s => { if (!s.godverseShopId) return false;
|
||
const e = byId.get(s.godverseShopId);
|
||
return !e || !(e.types || []).includes(s.type); });
|
||
const visit = async (shop) => {
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 250));
|
||
D.enterShop('g:' + shop.godverseShopId); await new Promise(r => setTimeout(r, 1200));
|
||
const si = D.stockInfo();
|
||
const c = crates.find(c => c.id === shop.godverseShopId && c.type === shop.type);
|
||
const own = c ? new Set(c.titles) : new Set();
|
||
return { name: si.name, gid: si.godverseShopId, want: shop.godverseShopId, type: si.type,
|
||
base: si.base, packItems: si.packItems,
|
||
sourcing: c ? c.sourcing : null,
|
||
inOwn: si.packTitles.filter(t => own.has(t)).length,
|
||
fromOwnTex: si.texUrls.filter(u => u.includes(`stock_godverse/${shop.godverseShopId}/`)).length,
|
||
fromGenericTex: si.texUrls.filter(u => u.includes('assets/models/stock_')).length };
|
||
};
|
||
const realShop = stocked.find(s => s.godverseShopId === real);
|
||
const mintShop = stocked.find(s => (byId.get(s.godverseShopId) || {}).sourcing === 'mint');
|
||
const out = { crates: crates.length, shared, vsGeneric, noSourcing, manMismatch,
|
||
stocked: stocked.length,
|
||
realRoom: realShop ? await visit(realShop) : null,
|
||
mintRoom: mintShop ? await visit(mintShop) : null };
|
||
if (unfetch) { D.exitShop(); await new Promise(r => setTimeout(r, 250));
|
||
D.enterShop('g:' + unfetch.godverseShopId); await new Promise(r => setTimeout(r, 900));
|
||
const o = D.stockInfo();
|
||
out.softFall = { name: o.name, gid: o.godverseShopId, want: unfetch.godverseShopId,
|
||
packItems: o.packItems }; }
|
||
return out;
|
||
}""", {'manifest': STOCK_MANIFEST, 'real': GODVERSE_SHOP, 'base': GODVERSE_BASE})
|
||
if res.get('fail'):
|
||
FAIL(f"beta crate gate: {res['fail']}"); return
|
||
|
||
# ── distinctness (independently measured; G reported 15/15) ──
|
||
pairs = res['crates'] * (res['crates'] - 1) // 2
|
||
OK(f"DISTINCTNESS: {res['crates']} keyed crates, {pairs} pairs, {res['shared']} sharing a title") \
|
||
if res['shared'] == 0 else FAIL(f"DISTINCTNESS: {res['shared']} crate pair(s) share titles")
|
||
OK(f"DISTINCTNESS: 0/{res['crates']} crates overlap the generic v2 pack") \
|
||
if res['vsGeneric'] == 0 else FAIL(f"{res['vsGeneric']} crate(s) overlap the generic pack")
|
||
# ── sourcing (C §7.2b — an index with no `sourcing` FAILS; the gate PRINTS it) ──
|
||
OK(f"SOURCING: {res['crates']}/{res['crates']} indexes declare sourcing") \
|
||
if res['noSourcing'] == 0 else FAIL(f"SOURCING: {res['noSourcing']} index(es) declare none")
|
||
OK('SOURCING: every index agrees with the manifest') \
|
||
if res['manMismatch'] == 0 else FAIL(f"SOURCING: {res['manMismatch']} index/manifest disagreement(s)")
|
||
|
||
for label, want in (('realRoom', 'real'), ('mintRoom', 'mint')):
|
||
r = res.get(label)
|
||
if not r:
|
||
FAIL(f"{label}: no {want} crate reachable in {GODVERSE_TOWN}"); continue
|
||
if r['gid'] != r['want']:
|
||
FAIL(f"{label}: entered the WRONG shop — wanted {r['want']}, got {r['gid']}"); continue
|
||
print(f" room: {r['name']} (godverse {r['gid']}, {r['type']}) → {r['base']} [sourcing: {r['sourcing']}]")
|
||
OK(f" {want}: sourcing reports '{r['sourcing']}'") if r['sourcing'] == want \
|
||
else FAIL(f" {want} crate reports sourcing '{r['sourcing']}' — a {want} crate must never report otherwise")
|
||
OK(f" {want}: TITLE equality {r['inOwn']}/{r['packItems']} from its OWN crate") \
|
||
if (r['packItems'] > 0 and r['inOwn'] == r['packItems']) \
|
||
else FAIL(f" {want}: only {r['inOwn']}/{r['packItems']} titles are its own")
|
||
OK(f" {want}: TEXTURE provenance {r['fromOwnTex']} own / {r['fromGenericTex']} generic") \
|
||
if (r['fromOwnTex'] > 0 and r['fromGenericTex'] == 0) \
|
||
else FAIL(f" {want}: textures {r['fromOwnTex']} own / {r['fromGenericTex']} generic — wrong file")
|
||
|
||
sf = res.get('softFall')
|
||
if sf:
|
||
if sf['gid'] != sf['want']: FAIL(f"soft fall: entered the wrong shop ({sf['gid']} != {sf['want']})")
|
||
elif sf['packItems'] == 0: OK(f"soft fall: keyed, unfetchable type \"{sf['name']}\" → parody, silently")
|
||
else: FAIL(f"soft fall BROKEN: \"{sf['name']}\" inherited {sf['packItems']} items")
|
||
|
||
# ── ZERO 404s. No exceptions. The manifest is what makes this honest. ──
|
||
OK('ZERO 404s — the manifest means we never ask for an atlas that is not there') \
|
||
if not four04 else FAIL(f"{len(four04)} unexpected 404(s) — the manifest should have prevented every one; first: {four04[0]}")
|
||
OK('0 console errors (no attribution exceptions)') if not errs \
|
||
else FAIL(f'the crate: {len(errs)} console error(s); first: {errs[0][:140]}')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
# ── TIER 2 (v5.0) — the kill-the-server gate's own lifecycle ───────────────────────────────────────
|
||
GV = 'pipeline/godverse_server.py'
|
||
LIVE_BASE = 'http://127.0.0.1:8778'
|
||
LIVE_SHOP = 3962749
|
||
FIXTURE_DB = 'recordgod_fixture'
|
||
REAL_DB = 'recordgod'
|
||
|
||
|
||
def _gv(args, db=None):
|
||
env = dict(os.environ)
|
||
if db: env['GODVERSE_POS_DB'] = db
|
||
return subprocess.run(['python3', GV] + args, cwd=str(ROOT), capture_output=True, text=True, env=env)
|
||
|
||
|
||
def _gv_kill():
|
||
subprocess.run("lsof -ti :8778 | xargs kill -9", shell=True, capture_output=True)
|
||
time.sleep(0.8)
|
||
|
||
|
||
def _gv_start(db):
|
||
_gv_kill()
|
||
env = dict(os.environ); env['GODVERSE_POS_DB'] = db
|
||
subprocess.Popen(['python3', GV, 'start'], cwd=str(ROOT), env=env,
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
for _ in range(20):
|
||
time.sleep(0.3)
|
||
try:
|
||
urllib.request.urlopen(f'{LIVE_BASE}/godverse/v1/shop/{LIVE_SHOP}/stock', timeout=1).read()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def smoke_tier2(p):
|
||
"""R27 — THE KILL-THE-SERVER GATE. Charter law #1 made falsifiable: the server enriches, never gates.
|
||
|
||
This gate owns its own subject and its own weather. Two lanes made that necessary and both said so
|
||
before it could go wrong:
|
||
· G: production `gone` is EMPTY — 0 of crate 550's 120 records have sold since 2026-07-01 — so a
|
||
sold-means-gone assert on live data would pass VACUOUSLY FOREVER. G built `make-fixture`, and the
|
||
gate MUST use it. "A fixture is not a lie; a green assert over an absent subject is."
|
||
· C: pick-then-omit and filter-the-array BOTH hide the sold record, so "the gone item never renders"
|
||
cannot tell them apart — it is vacuous and it goes green. The discriminating assert is COLLATERAL:
|
||
the survivors' identity AND ORDER unchanged across a `gone` delta. One sale must remove one
|
||
record, not re-stock the shop (C measured the naive wiring changing 21 other records).
|
||
|
||
Tier 2 is non-deterministic BY DESIGN (charter law #2), so this gate never asserts dig determinism
|
||
with the server up. What it asserts is the LADDER: live enriches, dead degrades, and the player never
|
||
waits on either. Subject absent (no psql / no POS db) ⇒ SKIP with the reason printed, never a pass."""
|
||
head('SMOKE: the kill-the-server gate (R27 — charter law #1: the server enriches, never gates)')
|
||
if subprocess.run(['which', 'psql'], capture_output=True).returncode != 0:
|
||
print('· SKIP: no psql — the POS fixture cannot be built, nothing asserted (needs the GODVERSE box)')
|
||
return
|
||
if subprocess.run(['psql', '-lqt'], capture_output=True, text=True).stdout.find(REAL_DB) < 0:
|
||
print(f'· SKIP: no `{REAL_DB}` POS database here — subject absent, nothing asserted (Lane G)')
|
||
return
|
||
|
||
fx = _gv(['make-fixture', '--shop', str(LIVE_SHOP), '--sold', '3'])
|
||
sold = [l.strip() for l in fx.stdout.splitlines() if l.strip().startswith('sku_')]
|
||
if len(sold) != 3:
|
||
FAIL(f'the fixture built no subject (want 3 sold, got {len(sold)}): {fx.stderr[:120]}'); return
|
||
print(f' subject: {len(sold)} record(s) marked SOLD in a fixture POS — production `gone` is empty, '
|
||
f'so this is the only honest way to have one')
|
||
|
||
try:
|
||
# ── ARM 1: server UP → live fields land, and the crate omits WITHOUT reshuffling ──
|
||
if not _gv_start(FIXTURE_DB):
|
||
FAIL('the fixture server would not come up'); return
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, f'plansrc=osm&town=redhill_godverse&stock=real&dig=1&live=1')
|
||
r1 = pg.evaluate("""async (shop) => {
|
||
const D = window.DBG, m = await import('./js/interiors/stockpack.js');
|
||
const BASE = `assets/stock_godverse/${shop}/`;
|
||
D.enterShop('g:' + shop); await new Promise(r => setTimeout(r, 200));
|
||
const pack = m.getStockPack('record', BASE);
|
||
const before = pack.items.map(i => i.id);
|
||
await new Promise(r => setTimeout(r, 1800));
|
||
const gone = pack.gone ? [...pack.gone] : [];
|
||
const after = pack.items.map(i => i.id);
|
||
const survB = before.filter(i => !gone.includes(i));
|
||
const survA = after.filter(i => !gone.includes(i));
|
||
return { gone, itemsBefore: before.length, itemsAfter: after.length,
|
||
orderIntact: before.length === after.length && before.every((x, i) => x === after[i]),
|
||
survivors: survA.length,
|
||
collateral: survB.filter((x, i) => survA[i] !== x).length };
|
||
}""", LIVE_SHOP)
|
||
OK(f"ARM 1 server-up: live read consumed — gone[{len(r1['gone'])}], {r1['survivors']} survivors") \
|
||
if len(r1['gone']) == 3 else FAIL(f"ARM 1: gone[{len(r1['gone'])}], expected 3 from the fixture")
|
||
OK(f"ARM 1: items[] untouched — {r1['itemsAfter']} ids, identity AND ORDER intact") \
|
||
if r1['orderIntact'] and r1['itemsAfter'] == 120 \
|
||
else FAIL(f"ARM 1: items[] MUTATED ({r1['itemsBefore']}→{r1['itemsAfter']}, order intact={r1['orderIntact']})")
|
||
OK('ARM 1: COLLATERAL 0 — one sale removed one record, it did not re-stock the shop') \
|
||
if r1['collateral'] == 0 \
|
||
else FAIL(f"ARM 1: COLLATERAL {r1['collateral']} — the crate RESHUFFLED (filter-the-array, not pick-then-omit)")
|
||
if set(r1['gone']) != set(sold):
|
||
FAIL(f"ARM 1: gone {r1['gone']} != the fixture's {sold} — the subject is not what the gate thinks")
|
||
else:
|
||
OK('ARM 1: `gone` carries the atlas id form verbatim — zero transformation (R26 fence)')
|
||
|
||
# ── ARM 2: KILL IT MID-SESSION ──
|
||
_gv_kill()
|
||
errs.clear()
|
||
r2 = pg.evaluate("""async (shop) => {
|
||
const D = window.DBG, m = await import('./js/interiors/stockpack.js');
|
||
const pack = m.getStockPack('record', `assets/stock_godverse/${shop}/`);
|
||
const frames = []; let last = performance.now();
|
||
const iv = setInterval(() => { const n = performance.now(); frames.push(n - last); last = n; }, 0);
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 300));
|
||
const t0 = performance.now();
|
||
D.enterShop('g:' + shop); // fires a read at a DEAD server
|
||
const enterMs = performance.now() - t0;
|
||
await new Promise(r => setTimeout(r, 2000)); // past the 1200ms abort
|
||
clearInterval(iv);
|
||
const si = D.stockInfo();
|
||
frames.sort((a, b) => a - b);
|
||
return { enterMs, worst: frames[frames.length - 1], packItems: si.packItems,
|
||
ownTex: si.texUrls.filter(u => u.includes(`stock_godverse/${shop}/`)).length };
|
||
}""", LIVE_SHOP)
|
||
# THE CONTROL (R21: a number without a control is not a measurement). Entering a shop
|
||
# REBUILDS an interior — that costs the same with or without a server, so an absolute
|
||
# threshold measures Lane C's room build, not the network failure. Walk the same shop with
|
||
# tier 2 OFF and compare: the question is "did the DEATH cost anything", not "is entering fast".
|
||
ctrl = pg.evaluate("""async (shop) => {
|
||
const D = window.DBG;
|
||
const frames = []; let last = performance.now();
|
||
const iv = setInterval(() => { const n = performance.now(); frames.push(n - last); last = n; }, 0);
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 300));
|
||
const t0 = performance.now();
|
||
D.enterShop('g:' + shop);
|
||
const enterMs = performance.now() - t0;
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
clearInterval(iv); frames.sort((a, b) => a - b);
|
||
return { enterMs, worst: frames[frames.length - 1] };
|
||
}""", LIVE_SHOP) # the breaker has tripped by now → this walk touches no network at all
|
||
dEnter = r2['enterMs'] - ctrl['enterMs']
|
||
dWorst = r2['worst'] - ctrl['worst']
|
||
print(f" control (same walk, breaker tripped, no network): enter {ctrl['enterMs']:.0f}ms, worst frame {ctrl['worst']:.0f}ms")
|
||
OK(f"ARM 2 kill mid-session: enter {r2['enterMs']:.0f}ms vs control {ctrl['enterMs']:.0f}ms (Δ{dEnter:+.0f}ms) — "
|
||
f"worst frame {r2['worst']:.0f}ms vs {ctrl['worst']:.0f}ms (Δ{dWorst:+.0f}ms): the death cost nothing the room didn't") \
|
||
if (dEnter < 100 and dWorst < 100) \
|
||
else FAIL(f"ARM 2: the DEATH itself stuttered — enter Δ{dEnter:+.0f}ms, worst frame Δ{dWorst:+.0f}ms over control")
|
||
OK(f"ARM 2: the room is still complete from files — {r2['packItems']} items, {r2['ownTex']} own atlas tex") \
|
||
if (r2['packItems'] == 120 and r2['ownTex'] > 0) \
|
||
else FAIL(f"ARM 2: the room degraded WRONG — {r2['packItems']} items, {r2['ownTex']} own tex")
|
||
# THE BRIEF SAID ZERO. ZERO IS UNACHIEVABLE, AND F MEASURED WHY — see LANE_F_NOTES §27.
|
||
# A fetch to a dead host logs `net::ERR_CONNECTION_REFUSED` at the BROWSER level; no
|
||
# `.catch()` suppresses it (same species as R26's 404s, one layer up). To log zero the
|
||
# reader would have to know the server is dead WITHOUT ASKING, which it cannot. So one
|
||
# refusal is the irreducible cost of discovering death — and asserting EXACTLY ONE is
|
||
# strictly stronger than asserting zero: it proves the circuit breaker. Without the
|
||
# breaker this is one error per shop entry, forever, and "zero" could never distinguish
|
||
# a working breaker from a broken one because both fail it.
|
||
game_errs = [e for e in errs if 'ERR_CONNECTION_REFUSED' not in e]
|
||
net_errs = [e for e in errs if 'ERR_CONNECTION_REFUSED' in e]
|
||
OK('ARM 2: ZERO errors from game code — nothing threw, nothing warned') \
|
||
if not game_errs else FAIL(f"ARM 2: {len(game_errs)} GAME error(s) on server death; first: {game_errs[0][:120]}")
|
||
OK(f'ARM 2: EXACTLY 1 refused connection — the circuit breaker tripped and never asked again') \
|
||
if len(net_errs) == 1 \
|
||
else FAIL(f"ARM 2: {len(net_errs)} refused connections — the breaker did NOT trip "
|
||
f"(1 = discovery, >1 = it keeps asking a dead server)")
|
||
finally:
|
||
b.close()
|
||
|
||
# ── ARM 3: boot with the server DOWN → pure tier 1, no live state at all ──
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'plansrc=osm&town=redhill_godverse&stock=real&dig=1&live=1')
|
||
r3 = pg.evaluate("""async (shop) => {
|
||
const D = window.DBG, m = await import('./js/interiors/stockpack.js');
|
||
D.enterShop('g:' + shop); await new Promise(r => setTimeout(r, 2000));
|
||
const pack = m.getStockPack('record', `assets/stock_godverse/${shop}/`);
|
||
const si = D.stockInfo();
|
||
return { items: si.packItems, goneSize: pack.gone ? pack.gone.size : 0,
|
||
ownTex: si.texUrls.filter(u => u.includes(`stock_godverse/${shop}/`)).length };
|
||
}""", LIVE_SHOP)
|
||
OK(f"ARM 3 server-down boot: pure tier 1 — {r3['items']} items, gone[{r3['goneSize']}], {r3['ownTex']} own tex") \
|
||
if (r3['items'] == 120 and r3['goneSize'] == 0 and r3['ownTex'] > 0) \
|
||
else FAIL(f"ARM 3: not pure tier 1 — {r3['items']} items, gone[{r3['goneSize']}]")
|
||
g3 = [e for e in errs if 'ERR_CONNECTION_REFUSED' not in e]
|
||
n3 = [e for e in errs if 'ERR_CONNECTION_REFUSED' in e]
|
||
OK('ARM 3: ZERO errors from game code booting against a dead server') \
|
||
if not g3 else FAIL(f"ARM 3: {len(g3)} game error(s); first: {g3[0][:120]}")
|
||
OK('ARM 3: EXACTLY 1 refused connection for the whole boot — asked once, learned, stopped') \
|
||
if len(n3) == 1 else FAIL(f"ARM 3: {len(n3)} refused connections (want exactly 1: the breaker)")
|
||
finally:
|
||
b.close()
|
||
|
||
# ── THE SANDBOX, BOTH DIRECTIONS (#4) ──
|
||
# real→game proven above (a real sale removed a record from the game crate).
|
||
# game→real: prove the ABSENCE of the verb. G: 501 is a stronger proof than 404 — a 404 means a
|
||
# handler ran and declined; 501 means there is NO write handler in the process at all.
|
||
_gv_start(FIXTURE_DB)
|
||
codes = {}
|
||
for verb in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
||
rq = urllib.request.Request(f'{LIVE_BASE}/godverse/v1/shop/{LIVE_SHOP}/stock', method=verb, data=b'{}')
|
||
try:
|
||
urllib.request.urlopen(rq, timeout=3); codes[verb] = 200
|
||
except urllib.error.HTTPError as e:
|
||
codes[verb] = e.code
|
||
except Exception as e:
|
||
codes[verb] = str(e)[:20]
|
||
OK(f'SANDBOX game→real: every write verb 501 — there is no write handler in the process {codes}') \
|
||
if all(c == 501 for c in codes.values()) \
|
||
else FAIL(f'SANDBOX game→real: a write verb did NOT answer 501 — {codes}')
|
||
try:
|
||
urllib.request.urlopen(f'{LIVE_BASE}/godverse/v1/shop/158/stock', timeout=3)
|
||
FAIL('SANDBOX: a MINT shop served live stock — the metadata fence leaks')
|
||
except urllib.error.HTTPError as e:
|
||
OK('SANDBOX: a mint shop 404s — only the one real shop is live, which is the point') \
|
||
if e.code == 404 else FAIL(f'SANDBOX: mint shop answered {e.code}, expected 404')
|
||
finally:
|
||
_gv_start(REAL_DB) # leave the box as we found it: the REAL POS serving
|
||
|
||
|
||
def smoke_glance(p):
|
||
"""R29 Spike 1 — THE POSE GATE, un-SKIPPED. v6's first thread, and the first clip to reach the game.
|
||
|
||
R28 wrote this gate and it had NO SUBJECT: `look.glb` was converted, verified, published, and
|
||
sha1-proven by fetching the bytes back off the depot — and nothing bound it. Every check E ran was
|
||
real and passed; none could see that no consumer existed (a gate on the artifact cannot see the wire
|
||
— the R17 pattern's fifth outing). It SKIPPED with its reason printed rather than report green over
|
||
nothing. D bound it in R29 wave 0, so the gate binds too.
|
||
|
||
Asserts the two things that matter, and they pull in opposite directions:
|
||
· the glance REACHES THE GAME — peds actually enter the state, on the default boot;
|
||
· `?classic=1` never fetches the clip and never glances — while STILL DRAWING THE ROLL, because
|
||
D takes from a dedicated rng stream always and only flips the flag when a clip exists. That is
|
||
what keeps the covenant byte-identical while the behaviour is gated: the determinism is in the
|
||
draw, not in the outcome. Asserting "0 glancing" alone would pass on a fleet that failed to load
|
||
— so we assert the clip is ABSENT under classic and PRESENT on the default boot."""
|
||
head('SMOKE: the glance (R29 Spike 1 — v6\'s first clip in the game · classic still zero-fetch)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
# Check the subject on DISK, not via pg.goto — navigating to a .glb starts a download and
|
||
# Playwright aborts the navigation, which crashes the gate instead of skipping it. A gate that
|
||
# dies looking for its subject is worse than one that skips: it takes the suite with it.
|
||
if not os.path.exists(os.path.join(str(ROOT), 'web', 'models', 'peds', 'look.glb')):
|
||
print('· SKIP: look.glb not on disk — subject absent, nothing asserted (Lane E)'); return
|
||
boot(pg, 'plansrc=osm&town=redhill_godverse')
|
||
r = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, c = P.citizens;
|
||
let peak = 0;
|
||
for (let i = 0; i < 9000; i++) { c.update(1/60);
|
||
if (i % 30 === 0) { const p = (c._activeList||[]).concat(c.roster||[]);
|
||
const g = p.filter(x => x && x.glance === true).length; if (g > peak) peak = g; } }
|
||
const p = (c._activeList||[]).concat(c.roster||[]);
|
||
return { bound: !!P.fleet.lookClip, dur: P.fleet.lookClip ? P.fleet.lookClip.duration : 0,
|
||
tracks: P.fleet.lookClip ? P.fleet.lookClip.tracks.length : 0,
|
||
peak, pop: p.length, rng: p.some(x => x && !!x.glanceRng) };
|
||
}""")
|
||
OK(f"default boot: look.glb BOUND — {r['dur']:.1f}s, {r['tracks']} tracks") \
|
||
if r['bound'] else FAIL('default boot: look.glb is NOT bound — the R28 gap is back')
|
||
OK(f"the glance REACHES THE GAME — peak {r['peak']} of {r['pop']} peds glancing") \
|
||
if r['peak'] > 0 else FAIL(f"no ped ever glanced across 9000 ticks ({r['pop']} peds) — clip bound, behaviour absent")
|
||
if errs: FAIL(f'glance: {len(errs)} console error(s); first: {errs[0][:120]}')
|
||
else: print(' · 0 console errors')
|
||
finally:
|
||
b.close()
|
||
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'classic=1')
|
||
r = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, c = P.citizens;
|
||
let peak = 0;
|
||
for (let i = 0; i < 6000; i++) { c.update(1/60);
|
||
if (i % 40 === 0) { const p = (c._activeList||[]).concat(c.roster||[]);
|
||
const g = p.filter(x => x && x.glance === true).length; if (g > peak) peak = g; } }
|
||
const p = (c._activeList||[]).concat(c.roster||[]);
|
||
return { bound: !!P.fleet.lookClip, peak, pop: p.length, rng: p.some(x => x && !!x.glanceRng) };
|
||
}""")
|
||
# NOT bound is the assertion: ?classic must not fetch the clip at all (zero-fetch-delta covenant)
|
||
OK('?classic=1: look.glb NEVER FETCHED — the zero-fetch-delta covenant holds') \
|
||
if not r['bound'] else FAIL('?classic=1 FETCHED look.glb — the covenant is breached')
|
||
OK(f"?classic=1: glance inert at the source — 0 of {r['pop']} peds, and the rng stream still draws "
|
||
f"({r['rng']}) so determinism is in the DRAW, not the outcome") \
|
||
if (r['peak'] == 0 and r['rng']) \
|
||
else FAIL(f"?classic=1: peak {r['peak']} glancing / rng stream present={r['rng']}")
|
||
if errs: FAIL(f'glance/classic: {len(errs)} console error(s); first: {errs[0][:120]}')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def smoke_continuity(p):
|
||
"""R15 identity continuity (ledger #3): the ped who queued/surged into a venue turns up in its crowd.
|
||
Exercises F's real relay end-to-end — VenueQueue.admitOne() → onAdmit → citizens.recordVenueEntry →
|
||
tonightRoster(id) → GigCrew.spawn({roster}) → the front crowd slots carry those identities. Asserts the
|
||
seam on identities (crewInfo.fromRoster + member.pedIndex ∈ tonightRoster), the invariant crowd ⊇
|
||
roster ∩ cap, and that an EMPTY roster injects nobody (D's byte-identical law, proven here on the shell
|
||
side; D's sim soak proves the sim side). Also ledger #5 — reports SETTLED tris (after the async
|
||
instrument GLBs land), not the pre-load number C caught the R13 smoke reporting."""
|
||
head('SMOKE: continuity (R15 — the ped you followed is in the crowd · empty-roster clean · settled tris)')
|
||
if not gigs_landed(p):
|
||
print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)')
|
||
return
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, '')
|
||
pg.keyboard.press('Space')
|
||
res = pg.evaluate("""async () => {
|
||
const P = window.PROCITY, D = window.DBG;
|
||
const id = P.gigs.venueShopIds[0];
|
||
const rosterOf = () => (P.citizens.tonightRoster(id) || []).map(e => e.pedIndex);
|
||
// 1) EMPTY-ROSTER PATH — enter before any admit; crew must inject only what's in the roster
|
||
// (usually 0). This is the shell side of D's "empty roster = byte-identical to R13" law.
|
||
D.setSegment(5); // NIGHT — the gig is on
|
||
const rosterAtEmpty = rosterOf();
|
||
D.enterShop(id); await new Promise(r => setTimeout(r, 550));
|
||
const ei = P.interiorMode.crewInfo;
|
||
const emptyFromRoster = ei ? ei.fromRoster : -1, emptyCrowd = ei ? ei.crowd : 0;
|
||
D.exitShop(); await new Promise(r => setTimeout(r, 250));
|
||
// 2) POPULATED PATH — let the street loop spawn the queue, then admit a few EXPLICITLY (rAF
|
||
// auto-drain is throttled in automation, per B's note). Each admit fires onAdmit → recordVenueEntry.
|
||
for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r));
|
||
const q = P.venueQueues && P.venueQueues.get(id);
|
||
const admitted = [];
|
||
if (q) for (let i = 0; i < 3 && q.count() > 0; i++) { const e = q.admitOne(); if (e) admitted.push(e.pedIndex); }
|
||
const roster = rosterOf();
|
||
D.enterShop(id); await new Promise(r => setTimeout(r, 700)); // build + crew spawn
|
||
const info = P.interiorMode.crewInfo, room = P.interiorMode.current;
|
||
const crowd = P.interiorMode.crew ? P.interiorMode.crew.members.filter(m => m.part === 'crowd') : [];
|
||
const rosterCrowd = crowd.filter(m => m.fromRoster).map(m => m.pedIndex);
|
||
// 3) SETTLED TRIS (ledger #5) — immediately after build vs after the instrument GLBs + rig atlas land
|
||
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
|
||
const trisImmediate = P.renderer.info.render.triangles, draws = P.renderer.info.render.calls;
|
||
await new Promise(r => setTimeout(r, 1600));
|
||
P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera);
|
||
const trisSettled = P.renderer.info.render.triangles, drawsSettled = P.renderer.info.render.calls;
|
||
return { rosterAtEmpty, emptyFromRoster, emptyCrowd, admitted, roster, rosterCrowd,
|
||
fromRoster: info ? info.fromRoster : -1, crowd: info ? info.crowd : 0,
|
||
watchPoints: room ? room.watchPoints.length : 0,
|
||
trisImmediate, draws, trisSettled, drawsSettled };
|
||
}""")
|
||
# empty-roster path: crew injects exactly what's in the roster at that moment (0 unless surge beat us)
|
||
want_empty = min(len(res['rosterAtEmpty']), res['emptyCrowd'])
|
||
if res['emptyFromRoster'] == want_empty:
|
||
OK(f"empty roster: crew injected {res['emptyFromRoster']} from a {len(res['rosterAtEmpty'])}-entry roster "
|
||
f"(crowd {res['emptyCrowd']} — no phantom continuity, R13-identical path)")
|
||
else:
|
||
FAIL(f"empty roster: fromRoster {res['emptyFromRoster']} != roster∩cap {want_empty} (roster leaked)")
|
||
# the queue admits were recorded into tonightRoster (the onAdmit → recordVenueEntry relay)
|
||
roster, admitted, rosterCrowd = res['roster'], res['admitted'], res['rosterCrowd']
|
||
if admitted and all(a in roster for a in admitted):
|
||
OK(f"relay: {len(admitted)} queue admits → tonightRoster ({admitted} ⊆ roster of {len(roster)})")
|
||
else:
|
||
FAIL(f"relay: queue admits {admitted} not all in tonightRoster {roster} (onAdmit→recordVenueEntry broke)")
|
||
# THE SEAM — crowd ⊇ roster ∩ cap, and every roster-crowd member's pedIndex is genuinely in the roster
|
||
cap = res['crowd']
|
||
want = min(len(roster), cap)
|
||
subset_ok = set(rosterCrowd).issubset(set(roster))
|
||
if res['fromRoster'] == want and want > 0 and subset_ok and set(roster[:cap]).issubset(set(rosterCrowd)):
|
||
OK(f"continuity seam: {res['fromRoster']} of {cap} crowd came from the roster "
|
||
f"(crowd ⊇ roster∩cap ✓, all pedIndex ∈ tonightRoster ✓) — the ped you followed is in the crowd")
|
||
else:
|
||
FAIL(f"continuity seam: fromRoster {res['fromRoster']} vs roster∩cap {want}; rosterCrowd {rosterCrowd} "
|
||
f"vs roster {roster} (subset={subset_ok})")
|
||
# ledger #5 — settled-tris honesty (report both; interior gate is draws, which stays low)
|
||
OK(f"settled tris: {res['trisImmediate']:,} pre-load → {res['trisSettled']:,} settled "
|
||
f"(instrument GLBs; draws {res['draws']}→{res['drawsSettled']} ≤ {GIG_DRAWS_MAX} gate)")
|
||
if errs: FAIL(f"continuity: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK('continuity: 0 console errors')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
# ═══ R30 v7.0-alpha — THE GATES (ledger #5). Every gate below names its subject and proves it
|
||
# touched it (the vacuous-gate law): storage counts come from a prototype instrument with a POSITIVE
|
||
# control, the rotation fp hashes VERTEX DATA (the wave-3 binding — F measured the transform fp
|
||
# vacuous over batched parody stock), and the no-pump run drives the REAL dig/sell UI, not the API. ═══
|
||
|
||
GAME_DOM_IDS = ['pc-game', 'pc-gamebar', 'pc-crate', 'pc-crate-btn', 'pc-sleep-btn', 'pc-day', 'pc-cash']
|
||
|
||
# Installed BEFORE any page script (add_init_script) — counts every Storage-prototype method call.
|
||
# "Measure, don't trust": save.js promises zero module-scope storage access; this sees through it.
|
||
STORAGE_INSTRUMENT = """(() => {
|
||
window.__storageCalls = [];
|
||
for (const m of ['getItem', 'setItem', 'removeItem', 'clear', 'key']) {
|
||
const orig = Storage.prototype[m];
|
||
Storage.prototype[m] = function (...a) {
|
||
window.__storageCalls.push(m + ':' + String(a.length ? a[0] : ''));
|
||
return orig.apply(this, a);
|
||
};
|
||
}
|
||
})();"""
|
||
|
||
# In-page FNV-1a over the serialized plan — the world fingerprint for the delta-law asserts.
|
||
JS_PLAN_FP = """() => { const s = JSON.stringify(window.PROCITY.plan); let h = 0x811c9dc5;
|
||
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
||
return h >>> 0; }"""
|
||
|
||
# The stock fingerprint: FNV-1a over the position + uv ARRAYS of every isStock/buyMesh mesh in the
|
||
# open room. NOT transforms — batchRoom merges parody stock at identity transform, so a transform
|
||
# hash reads constants and reports "no rotation" over stock that rotates (F's own R30 harness bug).
|
||
# The day-salted picks/jitters live in the vertex data; bytes/meshes come back so the caller can
|
||
# prove the subject was present (a hash over zero bytes is a green light over nothing).
|
||
JS_VERTEX_FP = """() => {
|
||
const P = window.PROCITY; let h = 0x811c9dc5 >>> 0, bytes = 0, meshes = 0;
|
||
const mix = (arr) => { const u = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
|
||
for (let i = 0; i < u.length; i++) { h = (h ^ u[i]) >>> 0; h = Math.imul(h, 0x01000193) >>> 0; }
|
||
bytes += u.length; };
|
||
P.interiorMode.current.group.traverse((o) => {
|
||
const u = o.userData || {};
|
||
if (!(u.isStock || u.buyMesh) || !o.geometry) return;
|
||
meshes++;
|
||
const pos = o.geometry.getAttribute('position'), uv = o.geometry.getAttribute('uv');
|
||
if (pos) mix(pos.array); if (uv) mix(uv.array);
|
||
});
|
||
return { fp: h >>> 0, bytes, meshes };
|
||
}"""
|
||
|
||
|
||
def _game_state(pg):
|
||
"""The byte-comparable game state + a savedAt-normalized export (both stringified IN PAGE so key
|
||
order is identical on every read — python re-serialization would reorder and fake a diff)."""
|
||
return pg.evaluate("""() => { const g = window.PROCITY.game;
|
||
const ex = JSON.parse(g.export()); delete ex.savedAt;
|
||
return { state: JSON.stringify({ day: g.day, cash: g.cash, town: g.townKey, collection: g.collection }),
|
||
exNorm: JSON.stringify(ex), day: g.day, cash: g.cash, n: g.collection.length }; }""")
|
||
|
||
|
||
def _at_bin(pg):
|
||
"""Stand at the first bin of the open room, aimed at it (smoke_buy's proven setup)."""
|
||
return pg.evaluate("""() => {
|
||
const P = window.PROCITY;
|
||
const bins = []; P.interiorMode.current.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||
if (!bins.length) return { err: 'no bins' };
|
||
const bp = new P.THREE.Vector3(); bins[0].getWorldPosition(bp);
|
||
P.camera.position.set(bp.x, 1.6, bp.z + 1.4); P.camera.lookAt(bp.x, bp.y + 0.4, bp.z); P.camera.updateMatrixWorld();
|
||
return { ok: true };
|
||
}""")
|
||
|
||
|
||
def _dig_buy(pg):
|
||
"""One REAL buy: E at the aimed bin → riffle → pull the front sleeve → click BUY → WALK OUT.
|
||
Returns {cash0,n0,disabled,cash1,n1} read off the live wallet + collection."""
|
||
pg.evaluate("() => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }))")
|
||
pg.wait_for_timeout(500)
|
||
pg.evaluate("""() => { const c = window.PROCITY.renderer.domElement;
|
||
c.dispatchEvent(new PointerEvent('pointerdown', { clientX: 640, clientY: 360, bubbles: true }));
|
||
c.dispatchEvent(new PointerEvent('pointerup', { clientX: 640, clientY: 360, bubbles: true })); }""")
|
||
pg.wait_for_timeout(350)
|
||
r = pg.evaluate("""() => {
|
||
const P = window.PROCITY, g = P.game, w = P.wallet;
|
||
const btn = document.querySelector('.pcdg-panel button');
|
||
if (!btn) return { err: 'no pull panel (pull failed)' };
|
||
const cash0 = w.cash(), n0 = g.collection.length, disabled = btn.disabled;
|
||
btn.click();
|
||
const out = document.querySelector('.pcdg-out'); if (out) out.click();
|
||
return { cash0, n0, disabled, cash1: w.cash(), n1: g.collection.length };
|
||
}""")
|
||
pg.wait_for_timeout(250)
|
||
return r
|
||
|
||
|
||
def _counter_sell(pg, max_sales=10):
|
||
"""Walk to the counter, press E — THE REAL ROUTING opens C's sell card — then click SELL up to
|
||
max_sales times (card closes itself when nothing sellable remains). Camera looks UP so the
|
||
bin-aim fallback can't win the E (aimed bin → dig is the routing's first branch, by contract)."""
|
||
r = pg.evaluate("""() => {
|
||
const P = window.PROCITY, room = P.interiorMode.current;
|
||
const c = room.counter && room.counter.pose;
|
||
if (!c) return { err: 'no counter pose' };
|
||
P.camera.position.set(c.x, 1.6, c.z + 1.2);
|
||
P.camera.lookAt(c.x, 4.0, c.z); P.camera.updateMatrixWorld(); // aim above the room — no bin under aim
|
||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }));
|
||
const panel = document.querySelector('.pcsl-panel');
|
||
return { opened: !!(panel && panel.style.display === 'block'), sellActive: P.interiorMode.sellActive };
|
||
}""")
|
||
if r.get('err') or not r.get('opened'):
|
||
return r
|
||
sold = 0
|
||
for _ in range(max_sales):
|
||
s = pg.evaluate("""() => {
|
||
const p = document.querySelector('.pcsl-panel');
|
||
if (!p || p.style.display !== 'block') return { open: false };
|
||
const b = p.querySelector('button');
|
||
if (!b || b.disabled) return { open: true, stuck: true };
|
||
b.click(); return { open: true, sold: true };
|
||
}""")
|
||
if not s.get('open') or s.get('stuck'):
|
||
break
|
||
sold += 1
|
||
pg.wait_for_timeout(60)
|
||
pg.evaluate("""() => { const p = document.querySelector('.pcsl-panel');
|
||
if (p && p.style.display === 'block') { const x = p.querySelector('.x'); if (x) x.click(); } }""")
|
||
pg.wait_for_timeout(100)
|
||
r['sold'] = sold
|
||
return r
|
||
|
||
|
||
def _enter_record_shop(pg):
|
||
"""Midday, first OPEN record shop whose counter sits > 2.6 m from every bin (so counter-E can
|
||
never fall into binUnderAim's 2.5 m nearest-bin fallback — the routing's dig branch would win).
|
||
Returns the shop id it settled in, or an err."""
|
||
return pg.evaluate("""() => {
|
||
const P = window.PROCITY, D = window.DBG; D.setSegment(2);
|
||
const recs = (P.plan.shops || []).filter(s => s.type === 'record' && P.isOpen(s));
|
||
if (!recs.length) return { err: 'no open record shop' };
|
||
for (const s of recs.slice(0, 6)) {
|
||
D.enterShop(s.id);
|
||
const room = P.interiorMode.current;
|
||
const c = room.counter && room.counter.pose;
|
||
const bins = []; room.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins.push(o); });
|
||
if (c && bins.length) {
|
||
const wp = new P.THREE.Vector3();
|
||
const near = bins.some(b => { b.getWorldPosition(wp); return Math.hypot(wp.x - c.x, wp.z - c.z) < 2.6; });
|
||
if (!near) return { shop: s.id, name: s.name, bins: bins.length };
|
||
}
|
||
D.exitShop();
|
||
}
|
||
return { err: 'no record shop with counter clear of bins in the first 6' };
|
||
}""")
|
||
|
||
|
||
def smoke_sell_routing(p):
|
||
"""R30 §9.4 — THE HELD JOIN, verified live: dig → buy → walk to the counter → E → C's sell card
|
||
→ SELL → wallet credits, collection shrinks via game.removeFind, B's HUD follows. Also the two
|
||
negative routing legs: E at the counter with NOTHING sellable opens no card; ?game=0 never routes."""
|
||
head('GATE R30·routing: the E-key sell routing (dig → counter → sell card → credit + removeFind + HUD)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'dig=1')
|
||
# HUD present (B's game bar — the gate reads it after the sale)
|
||
try:
|
||
pg.wait_for_function("() => !!document.getElementById('pc-cash')", timeout=8000)
|
||
except Exception:
|
||
pass
|
||
r0 = _enter_record_shop(pg)
|
||
if r0.get('err'): FAIL(f"routing: {r0['err']}"); return
|
||
# negative leg FIRST: empty collection ⇒ E at the counter opens NOTHING (open() false, no DOM)
|
||
r_neg = _counter_sell(pg, max_sales=0)
|
||
if r_neg.get('opened'): FAIL('routing: sell card opened over an EMPTY collection (open() must be false)')
|
||
else: OK('routing: E at the counter with nothing sellable → no card, no DOM (fail-closed)')
|
||
# buy one through the real dig
|
||
_at_bin(pg)
|
||
rb = _dig_buy(pg)
|
||
if rb.get('err') or rb.get('disabled'): FAIL(f"routing: dig buy failed — {rb}"); return
|
||
paid = rb['cash0'] - rb['cash1']
|
||
if rb['n1'] == rb['n0'] + 1 and paid > 0:
|
||
OK(f"routing: dig buy — ${paid} debited, collection {rb['n0']}→{rb['n1']}")
|
||
else:
|
||
FAIL(f"routing: dig buy wallet/collection delta wrong — {rb}"); return
|
||
ent = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||
const e = g.collection[g.collection.length - 1];
|
||
return { type: e.type, pricePaid: e.pricePaid, hasId: !!(e.sku || e.slotId) }; }""")
|
||
if ent['type'] == 'record' and ent['hasId'] and ent['pricePaid'] == paid:
|
||
OK(f"routing: entry shape honest — type=record, sku|slotId set, pricePaid == debit (${paid})")
|
||
else:
|
||
FAIL(f"routing: entry shape wrong — {ent}")
|
||
# the sale, through the card
|
||
cash_before = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||
n_before = pg.evaluate("() => window.PROCITY.game.collection.length")
|
||
rs = _counter_sell(pg, max_sales=1)
|
||
if rs.get('err') or not rs.get('opened'):
|
||
FAIL(f"routing: E at the counter did not open the sell card — {rs}"); return
|
||
# HUD refreshes in STREET mode only (it is hidden inside a shop by design — setVisible(m==='street')),
|
||
# so "the HUD follows" is measured where the player can see it: back on the street, frames run.
|
||
pg.evaluate("() => window.DBG.exitShop()")
|
||
pg.wait_for_timeout(500)
|
||
after = pg.evaluate("""() => { const P = window.PROCITY;
|
||
const cashEl = document.getElementById('pc-cash'), crateBtn = document.getElementById('pc-crate-btn');
|
||
return { cash: P.wallet.cash(), n: P.game.collection.length,
|
||
hudCash: cashEl ? cashEl.textContent : null,
|
||
hudCrate: crateBtn ? crateBtn.textContent : null }; }""")
|
||
credited = after['cash'] - cash_before
|
||
if rs.get('sold') == 1 and after['n'] == n_before - 1 and 0 < credited < paid:
|
||
OK(f"routing: SELL — +${credited} credited (< ${paid} paid, no-pump by construction), collection {n_before}→{after['n']}")
|
||
else:
|
||
FAIL(f"routing: sale delta wrong — sold={rs.get('sold')}, credited {credited} vs paid {paid}, n {n_before}→{after['n']}")
|
||
if after['hudCash'] is not None:
|
||
if after['hudCash'] == str(after['cash']) and after['hudCrate'] == f"CRATE {after['n']}":
|
||
OK(f"routing: B's HUD follows — $" + after['hudCash'] + f" · {after['hudCrate']}")
|
||
else:
|
||
FAIL(f"routing: HUD stale — hudCash {after['hudCash']} vs {after['cash']}, {after['hudCrate']} vs CRATE {after['n']}")
|
||
else:
|
||
WARN('routing: game bar not built in this context — HUD leg unobserved (B verified it R30w2)')
|
||
if errs: FAIL(f"routing: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK('routing: 0 console errors')
|
||
finally:
|
||
b.close()
|
||
|
||
# ?game=0: the routing must be inert — E at the counter does nothing, zero sell DOM, zero errors
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'dig=1&game=0')
|
||
r = pg.evaluate("""() => {
|
||
const P = window.PROCITY, D = window.DBG; D.setSegment(2);
|
||
const rec = (P.plan.shops || []).find(s => s.type === 'record' && P.isOpen(s));
|
||
D.enterShop(rec.id);
|
||
const c = P.interiorMode.current.counter.pose;
|
||
P.camera.position.set(c.x, 1.6, c.z + 1.2); P.camera.lookAt(c.x, 4.0, c.z); P.camera.updateMatrixWorld();
|
||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }));
|
||
return { game: !!P.game, panels: document.querySelectorAll('.pcsl-panel').length,
|
||
sellActive: P.interiorMode.sellActive };
|
||
}""")
|
||
if not r['game'] and r['panels'] == 0 and not r['sellActive']:
|
||
OK('routing: ?game=0 — no game, E at the counter is a no-op, zero sell DOM')
|
||
else:
|
||
FAIL(f"routing: ?game=0 leaked — {r}")
|
||
if errs: FAIL(f"routing/game=0: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def smoke_save_determinism(p):
|
||
"""R30 gate (a) — scripted session (buy 2, sell 1, sleep 2×) → export → FRESH boot → import →
|
||
byte-equal game state AND world fingerprint unchanged (the delta law's falsifiable form)."""
|
||
head('GATE R30a: save/load determinism (buy 2 · sell 1 · sleep 2× → export → fresh boot → import)')
|
||
b, pg, errs = new_page(p)
|
||
exported = None; snap0 = None; fp0 = None
|
||
try:
|
||
boot(pg, 'dig=1')
|
||
r0 = _enter_record_shop(pg)
|
||
if r0.get('err'): FAIL(f"save-det: {r0['err']}"); return
|
||
for i in (1, 2):
|
||
_at_bin(pg)
|
||
rb = _dig_buy(pg)
|
||
if rb.get('err') or rb.get('disabled') or rb['n1'] != rb['n0'] + 1:
|
||
FAIL(f"save-det: scripted buy {i} failed — {rb}"); return
|
||
rs = _counter_sell(pg, max_sales=1)
|
||
if rs.get('err') or not rs.get('opened') or rs.get('sold') != 1:
|
||
FAIL(f"save-det: scripted sell failed — {rs}"); return
|
||
pg.evaluate("() => window.DBG.exitShop()")
|
||
pg.wait_for_timeout(200)
|
||
try: # B's game bar builds lazily on street frames — prefer the REAL surface
|
||
pg.wait_for_function("() => !!document.getElementById('pc-sleep-btn')", timeout=4000)
|
||
except Exception:
|
||
pass
|
||
slept = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||
const btn = document.getElementById('pc-sleep-btn');
|
||
if (btn) { btn.click(); btn.click(); } else { g.sleep(); g.sleep(); }
|
||
return { day: g.day, viaBtn: !!btn }; }""")
|
||
if slept['day'] != 3: FAIL(f"save-det: 2 sleeps → day {slept['day']} (want 3)"); return
|
||
via = "via B's SLEEP button" if slept['viaBtn'] else "via game.sleep() — HUD bar absent in this context"
|
||
OK(f"save-det: scripted session done — bought 2, sold 1, slept 2× → day 3 ({via})")
|
||
snap0 = _game_state(pg)
|
||
exported = pg.evaluate("() => window.PROCITY.game.export()")
|
||
fp0 = pg.evaluate(JS_PLAN_FP)
|
||
print(f" subject: day {snap0['day']} · ${snap0['cash']} · {snap0['n']} item(s) · plan fp {fp0:#010x}")
|
||
if errs: FAIL(f"save-det/session: {len(errs)} console error(s); first: {errs[0][:140]}"); return
|
||
finally:
|
||
b.close()
|
||
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'dig=1') # fresh browser = fresh localStorage = a day-1 game
|
||
fp1 = pg.evaluate(JS_PLAN_FP)
|
||
if fp1 != fp0: FAIL(f"save-det: fresh boot plan fp {fp1:#010x} != {fp0:#010x} (seeded world drifted?)"); return
|
||
r = pg.evaluate("""(saved) => { const g = window.PROCITY.game;
|
||
const fresh = { day: g.day, n: g.collection.length };
|
||
const ok = g.import(saved);
|
||
return { ok, fresh, week: window.PROCITY.gigs ? window.PROCITY.gigs.weekNight : null }; }""", exported)
|
||
if not (r['ok'] and r['fresh']['day'] == 1 and r['fresh']['n'] == 0):
|
||
FAIL(f"save-det: import on a fresh boot — {r}"); return
|
||
snap1 = _game_state(pg)
|
||
fp2 = pg.evaluate(JS_PLAN_FP)
|
||
if snap1['state'] == snap0['state'] and snap1['exNorm'] == snap0['exNorm']:
|
||
OK(f"save-det: BYTE-EQUAL after import — state ({len(snap1['state'])} bytes) and savedAt-normalized "
|
||
f"export ({len(snap1['exNorm'])} bytes) identical to the exporting session")
|
||
else:
|
||
FAIL(f"save-det: state diverged — day {snap1['day']} vs {snap0['day']}, cash {snap1['cash']} vs "
|
||
f"{snap0['cash']}, n {snap1['n']} vs {snap0['n']}")
|
||
if fp2 == fp0:
|
||
OK(f"save-det: world fingerprint UNCHANGED across export→import ({fp2:#010x}) — the delta law holds")
|
||
else:
|
||
FAIL(f"save-det: import MOVED THE WORLD — plan fp {fp2:#010x} != {fp0:#010x}")
|
||
if r['week'] == 2:
|
||
OK('save-det: day-derived state applied on import — weekNight 2 == (3−1)%7')
|
||
else:
|
||
FAIL(f"save-det: weekNight {r['week']} after importing day 3 (want 2)")
|
||
if errs: FAIL(f"save-det/import: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK('save-det: 0 console errors both contexts')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def smoke_nopump(p):
|
||
"""R30 gate (b) — buy-then-immediately-sell ×5 through the REAL dig + sell-card paths → strictly
|
||
negative per trip. PLUS C's negative control (the wave-3 binding): with cash 0, the real buy paths
|
||
invoke wallet.buy, it returns false, and NO entry enters the collection — the pump C found is dead."""
|
||
head('GATE R30b: the no-pump gate v0 (5 real round trips → monotonic loss · the debit-gate negative control)')
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'dig=1&stock=real')
|
||
_pack_ready(pg, 'book') # the negative control's shelf needs the book pack
|
||
r0 = _enter_record_shop(pg)
|
||
if r0.get('err'): FAIL(f"no-pump: {r0['err']}"); return
|
||
deltas = []
|
||
for i in range(5):
|
||
_at_bin(pg)
|
||
cash0 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||
rb = _dig_buy(pg)
|
||
if rb.get('err') or rb.get('disabled') or rb['n1'] != rb['n0'] + 1:
|
||
FAIL(f"no-pump: trip {i + 1} buy failed — {rb}"); return
|
||
rs = _counter_sell(pg) # sell EVERYTHING so no held item distorts the trip
|
||
if rs.get('err') or not rs.get('opened') or rs.get('sold', 0) < 1:
|
||
FAIL(f"no-pump: trip {i + 1} sell failed — {rs}"); return
|
||
cash1 = pg.evaluate("() => window.PROCITY.wallet.cash()")
|
||
deltas.append(cash1 - cash0)
|
||
if all(d <= -1 for d in deltas):
|
||
OK(f"no-pump: 5/5 round trips strictly negative — deltas {deltas} (Σ {sum(deltas)}) — "
|
||
f"the spread is the house's, by the sellOffer clamp")
|
||
else:
|
||
FAIL(f"no-pump: a round trip did NOT lose money — deltas {deltas}")
|
||
|
||
# ── C's negative control: no debit ⇒ no entry, on the REAL paths ──
|
||
broke = pg.evaluate("""() => { const g = window.PROCITY.game;
|
||
const ex = JSON.parse(g.export()); ex.cash = 0; ex.savedAt = Date.now();
|
||
const ok = g.import(JSON.stringify(ex));
|
||
return { ok, cash: g.cash, n: g.collection.length }; }""")
|
||
if not (broke['ok'] and broke['cash'] == 0):
|
||
FAIL(f"no-pump: could not set up the broke player — {broke}"); return
|
||
# arm 1: the dig — pull panel's BUY must be disabled; clicking it moves nothing
|
||
_at_bin(pg)
|
||
rd = _dig_buy(pg)
|
||
if rd.get('err'):
|
||
FAIL(f"no-pump/control: dig arm — {rd}")
|
||
elif rd['disabled'] and rd['n1'] == rd['n0'] and rd['cash1'] == 0:
|
||
OK('no-pump/control dig: BUY disabled at $0 — clicked anyway, zero debit, zero entry')
|
||
else:
|
||
FAIL(f"no-pump/control dig: {rd} — an unpaid entry got in or cash moved")
|
||
# arm 2: the shelf — wallet.buy IS invoked (counted, behavior untouched), returns false,
|
||
# and recordFind stays unreachable. This is the exact pump C built by accident, proven dead.
|
||
pg.evaluate("() => window.DBG.exitShop()")
|
||
pg.wait_for_timeout(200)
|
||
ra = pg.evaluate("""() => {
|
||
const P = window.PROCITY, D = window.DBG; D.setSegment(2);
|
||
const bk = (P.plan.shops || []).find(s => s.type === 'book' && P.isOpen(s));
|
||
if (!bk) return { err: 'no open book shop' };
|
||
D.enterShop(bk.id);
|
||
const meshes = []; P.interiorMode.current.group.traverse(o => {
|
||
if (o.userData && o.userData.buyMesh && o.userData.buyItems && o.userData.buyItems.length) meshes.push(o); });
|
||
if (!meshes.length) return { err: 'no buy meshes (book pack absent)' };
|
||
const m = meshes[0], it = m.userData.buyItems[0], wp = new P.THREE.Vector3();
|
||
m.localToWorld(wp.copy(it.center));
|
||
P.camera.position.set(wp.x, wp.y, wp.z + 1.0); P.camera.lookAt(wp.x, wp.y, wp.z); P.camera.updateMatrixWorld();
|
||
const g = P.game, w = P.wallet, n0 = g.collection.length;
|
||
const orig = w.buy; const calls = []; // instrument the REAL facade — count, don't mock
|
||
w.buy = function (o) { const r = orig.call(this, o); calls.push(r); return r; };
|
||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }));
|
||
w.buy = orig;
|
||
return { calls, n0, n1: g.collection.length, cash: w.cash() };
|
||
}""")
|
||
if ra.get('err'):
|
||
FAIL(f"no-pump/control shelf: {ra['err']}")
|
||
elif ra['calls'] == [False] and ra['n1'] == ra['n0'] and ra['cash'] == 0:
|
||
OK("no-pump/control shelf: wallet.buy INVOKED once, returned false, recordFind unreachable — "
|
||
"zero entry, cash still $0 (C's pump is dead)")
|
||
else:
|
||
FAIL(f"no-pump/control shelf: {ra} — buy not invoked, or an entry minted without a debit")
|
||
# positive arm (the control's control): with cash restored the SAME aim buys — the gate discriminates
|
||
rp = pg.evaluate("""() => { const g = window.PROCITY.game, w = window.PROCITY.wallet;
|
||
const ex = JSON.parse(g.export()); ex.cash = 100; ex.savedAt = Date.now();
|
||
g.import(JSON.stringify(ex));
|
||
const n0 = g.collection.length, cash0 = w.cash();
|
||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' }));
|
||
const n1 = g.collection.length, cash1 = w.cash();
|
||
const e = g.collection[g.collection.length - 1] || {};
|
||
return { n0, n1, cash0, cash1, paidMatches: n1 === n0 + 1 && e.pricePaid === cash0 - cash1 }; }""")
|
||
if rp['n1'] == rp['n0'] + 1 and rp['cash1'] < rp['cash0'] and rp['paidMatches']:
|
||
OK(f"no-pump/control positive: same aim with cash — debit ${rp['cash0'] - rp['cash1']} == entry.pricePaid, "
|
||
f"1 entry in (the control can tell success from failure)")
|
||
else:
|
||
FAIL(f"no-pump/control positive: {rp} — the negative control was measuring a dead path")
|
||
if errs: FAIL(f"no-pump: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
else: OK('no-pump: 0 console errors')
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def smoke_classic_purity(p):
|
||
"""R30 gate (c) — ?classic=1 and ?game=0: ZERO Storage-prototype calls (instrumented before any
|
||
page script), zero game DOM (B's ids), game null. Default boot is the POSITIVE control (the
|
||
instrument must SEE calls, and the game DOM must exist) — without it a broken instrument passes
|
||
forever. Plus the plan delta: game layer on vs off moves ZERO plan bytes."""
|
||
head('GATE R30c: classic purity (instrumented Storage calls · zero game DOM · plan fp game-on == game-off)')
|
||
res = {}
|
||
for q, name in (('classic=1', 'classic'), ('game=0', 'game0'), ('', 'default')):
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
pg.add_init_script(STORAGE_INSTRUMENT)
|
||
boot(pg, q)
|
||
if name == 'default':
|
||
try: pg.wait_for_function("() => !!document.getElementById('pc-game')", timeout=8000)
|
||
except Exception: pass
|
||
r = pg.evaluate("""(ids) => ({
|
||
count: window.__storageCalls.length, first: window.__storageCalls.slice(0, 6),
|
||
saveTouched: window.__storageCalls.some(c => c.includes('procity-save')),
|
||
game: !!(window.PROCITY && window.PROCITY.game),
|
||
dom: ids.filter(id => !!document.getElementById(id)),
|
||
})""", GAME_DOM_IDS)
|
||
r['fp'] = pg.evaluate(JS_PLAN_FP)
|
||
r['errs'] = list(errs)
|
||
res[name] = r
|
||
finally:
|
||
b.close()
|
||
for name in ('classic', 'game0'):
|
||
r = res[name]
|
||
if r['count'] == 0 and not r['game'] and not r['dom']:
|
||
OK(f"?{'classic=1' if name == 'classic' else 'game=0'}: ZERO Storage calls · game null · zero game DOM")
|
||
else:
|
||
FAIL(f"?{name}: storage calls {r['count']} (first {r['first']}), game={r['game']}, DOM leak {r['dom']}")
|
||
if r['errs']: FAIL(f"?{name}: {len(r['errs'])} console error(s); first: {r['errs'][0][:140]}")
|
||
d = res['default']
|
||
if d['count'] > 0 and d['saveTouched'] and d['game']:
|
||
OK(f"positive control: default boot — instrument SAW {d['count']} Storage call(s) incl. procity-save, game live")
|
||
else:
|
||
FAIL(f"positive control BROKEN: default boot count {d['count']}, saveTouched {d['saveTouched']}, game {d['game']}"
|
||
" — the zero readings above are unproven")
|
||
if d['dom'] == GAME_DOM_IDS:
|
||
OK('positive control: all 7 game DOM ids present on the default boot')
|
||
else:
|
||
WARN(f"positive control: game DOM partial on default boot — {d['dom']} (headless rAF timing?)")
|
||
if res['game0']['fp'] == d['fp']:
|
||
OK(f"plan fp game-on == game-off ({d['fp']:#010x}) — the game layer moves ZERO plan bytes")
|
||
else:
|
||
FAIL(f"plan fp DIFFERS with the game layer: game0 {res['game0']['fp']:#010x} vs default {d['fp']:#010x}")
|
||
print(f" (?classic plan fp {res['classic']['fp']:#010x} differs by design — gig-less plan; its golden is classic_regression's)")
|
||
|
||
|
||
def smoke_rotation(p):
|
||
"""R30 gate (d) — rotation determinism over VERTEX DATA (the wave-3 binding): same (seed, day) →
|
||
byte-identical stock fp (re-entry AND across a reboot); day 1→2→3 each actually different;
|
||
?game=0 == day 1 (the day-1 convention). Subject proven present: bytes and mesh count printed."""
|
||
head('GATE R30d: rotation determinism (vertex-data fp · same day byte-identical · day N ≠ N+1 · cross-boot)')
|
||
b, pg, errs = new_page(p)
|
||
shop_id = None; fps = {}
|
||
try:
|
||
boot(pg, '')
|
||
r0 = _enter_record_shop(pg)
|
||
if r0.get('err'): FAIL(f"rotation: {r0['err']}"); return
|
||
shop_id = r0['shop']
|
||
|
||
def fp_of(day_label):
|
||
r = pg.evaluate(JS_VERTEX_FP)
|
||
if r['bytes'] == 0 or r['meshes'] == 0:
|
||
FAIL(f"rotation: VACUOUS — {day_label}: 0 bytes / 0 stock meshes hashed (no subject)"); return None
|
||
return r
|
||
|
||
def reenter():
|
||
pg.evaluate("(id) => { window.DBG.exitShop(); }", shop_id); pg.wait_for_timeout(150)
|
||
pg.evaluate("(id) => { window.DBG.setSegment(2); window.DBG.enterShop(id); }", shop_id)
|
||
pg.wait_for_timeout(150)
|
||
|
||
a1 = fp_of('day1'); reenter(); a2 = fp_of('day1 re-entry')
|
||
if not a1 or not a2: return
|
||
if a1['fp'] == a2['fp']: OK(f"day 1: re-entry byte-identical (fp {a1['fp']:#010x}, {a1['bytes']:,} bytes / {a1['meshes']} meshes)")
|
||
else: FAIL(f"day 1: re-entry fp moved {a1['fp']:#010x} → {a2['fp']:#010x} — same (seed, day) not deterministic")
|
||
pg.evaluate("() => { window.DBG.exitShop(); window.PROCITY.game.sleep(); }")
|
||
reenter(); b1 = fp_of('day2'); reenter(); b2 = fp_of('day2 re-entry')
|
||
if not b1 or not b2: return
|
||
if b1['fp'] != a1['fp']: OK(f"day 2 ≠ day 1: stock ROTATED ({a1['fp']:#010x} → {b1['fp']:#010x})")
|
||
else: FAIL(f"day 2 == day 1: fp {b1['fp']:#010x} — rotation is not happening (or the fp is vacuous)")
|
||
if b1['fp'] == b2['fp']: OK(f"day 2: re-entry byte-identical ({b1['fp']:#010x})")
|
||
else: FAIL(f"day 2: re-entry fp moved — not deterministic within the day")
|
||
pg.evaluate("() => { window.DBG.exitShop(); window.PROCITY.game.sleep(); }")
|
||
reenter(); c1 = fp_of('day3')
|
||
if not c1: return
|
||
if c1['fp'] != b1['fp'] and c1['fp'] != a1['fp']:
|
||
OK(f"day 3 ≠ day 2 ≠ day 1 ({c1['fp']:#010x}) — each day is its own crate")
|
||
else:
|
||
FAIL(f"day 3 fp {c1['fp']:#010x} collides (day1 {a1['fp']:#010x}, day2 {b1['fp']:#010x})")
|
||
pg.evaluate("() => window.PROCITY.game.save()")
|
||
fps = { 'day1': a1['fp'], 'day3': c1['fp'] }
|
||
# cross-boot: reload the SAME browser context (localStorage carries day 3) — same (seed, day) again
|
||
boot(pg, '')
|
||
day = pg.evaluate("() => window.PROCITY.game.day")
|
||
if day != 3: FAIL(f"rotation: reboot loaded day {day} (want 3 — the save didn't carry)"); return
|
||
pg.evaluate("(id) => { window.DBG.setSegment(2); window.DBG.enterShop(id); }", shop_id)
|
||
pg.wait_for_timeout(150)
|
||
r = pg.evaluate(JS_VERTEX_FP)
|
||
if r['fp'] == fps['day3']: OK(f"cross-boot: day-3 fp byte-identical after a full reboot ({r['fp']:#010x})")
|
||
else: FAIL(f"cross-boot: day-3 fp {r['fp']:#010x} != pre-reboot {fps['day3']:#010x} — rotation not seed-stable")
|
||
if errs: FAIL(f"rotation: {len(errs)} console error(s); first: {errs[0][:140]}")
|
||
finally:
|
||
b.close()
|
||
if not fps: return
|
||
# the day-1 convention: a ?game=0 boot's stock is byte-identical to a fresh game's day 1
|
||
b, pg, errs = new_page(p)
|
||
try:
|
||
boot(pg, 'game=0')
|
||
pg.evaluate("(id) => { window.DBG.setSegment(2); window.DBG.enterShop(id); }", shop_id)
|
||
pg.wait_for_timeout(150)
|
||
r = pg.evaluate(JS_VERTEX_FP)
|
||
if r['fp'] == fps['day1']: OK(f"?game=0 == day 1 ({r['fp']:#010x}) — the pre-v7 town IS day 1, measured")
|
||
else: FAIL(f"?game=0 fp {r['fp']:#010x} != day-1 fp {fps['day1']:#010x} — the day-1 convention broke")
|
||
finally:
|
||
b.close()
|
||
|
||
|
||
def main():
|
||
srv = ensure_server()
|
||
try:
|
||
with sync_playwright() as p:
|
||
flags = list(KNOWN_FLAGS)
|
||
if plansrc_landed(p):
|
||
flags.append('plansrc=osm'); print("· plansrc=osm detected as landed — included")
|
||
else:
|
||
print("· plansrc=osm not landed (Lane A) — skipping that flag")
|
||
|
||
classic_regression(p) # [R16] the frozen v2 covenant moved here: ?classic=1 → byte-identical
|
||
default_boot_gate(p) # [R16] the flip: the no-flag boot is the full experience (all four on)
|
||
interior_budget(p)
|
||
for fl in flags:
|
||
smoke(p, fl.split('=')[0], fl)
|
||
smoke(p, 'ALL-ON combo', '&'.join(flags), is_combo=True)
|
||
smoke_stock(p) # STRICT (r8) — ?stock=real real record sleeves
|
||
smoke_weather(p) # STRICT (r8) — ?weather=rain
|
||
smoke_buy(p) # STRICT (r9) — buy-v0
|
||
smoke_patronage(p) # STRICT (r9) — peds visit open shops
|
||
smoke_booktoy(p) # STRICT (r9) — book/toy real stock
|
||
smoke_presence(p) # new (warn) — interior presence (browsers)
|
||
smoke_shelfbuy(p) # new (warn) — buy-anywhere (book/toy shelves)
|
||
smoke_tram(p) # new (warn) — tram (auto-skips if not landed)
|
||
smoke_audio(p) # R11 — audio house-law (silent-happy · pre-gesture · mute · noassets · leak)
|
||
smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets)
|
||
smoke_continuity(p) # R15 — identity continuity (the ped you followed is in the crowd · settled tris)
|
||
smoke_glance(p) # R29 — Spike 1: v6's first clip in the game (classic zero-fetch)
|
||
smoke_sell_routing(p) # R30 — the E-key sell routing (§9.4, the held join, live loop)
|
||
smoke_save_determinism(p) # R30 gate a — export → fresh boot → import byte-equal, world untouched
|
||
smoke_nopump(p) # R30 gate b — 5 real round trips monotonic loss + C's negative control
|
||
smoke_classic_purity(p) # R30 gate c — instrumented Storage zero + zero game DOM + positive control
|
||
smoke_rotation(p) # R30 gate d — vertex-data rotation determinism (the wave-3 binding)
|
||
smoke_tier2(p) # R27 — the kill-the-server gate (charter law #1)
|
||
smoke_real_crate(p) # R24 — THE REAL CRATE (per-shop atlas identity · soft fall · no-op off the path)
|
||
finally:
|
||
if srv: srv.terminate()
|
||
|
||
head('VERDICT')
|
||
print(json.dumps({'fails': len(fails), 'warns': len(warns)}, indent=0))
|
||
if fails:
|
||
print(f"\033[31m● flags_check RED\033[0m — {len(fails)} failure(s), {len(warns)} warning(s).")
|
||
for f in fails: print(' FAIL:', f)
|
||
sys.exit(1)
|
||
print(f"\033[32m● flags_check GREEN\033[0m — {len(warns)} warning(s).")
|
||
for w in warns: print(' warn:', w)
|
||
sys.exit(0)
|
||
|
||
if __name__ == '__main__':
|
||
main()
|