Wiring (F owns index.html + interior_mode.js): • Interior presence (C->D->F seam): occupancyOf closure -> interior_mode spawns browser rigs at C's room.browsePoints (one per patronage occupant, cap 3); freed by exit()'s keepers.disposeAll. Door points now carry shopId for D's occupancy truth. • Tram (Lane B, ?tram=1): createTram + update in loop (2 draws, non-blocking). • Buy-anywhere (Lane C C2): E aimed at a book-spine/toy-box buyMesh buys over the same wallet (collapseBuyItem + toast); preload book+toy packs too. Records keep the dig. Harness: graduated buy-v0 / patronage / book-toy stock warn->STRICT (katoomba golden already strict). New warn smokes: interior presence (browsers==min(occ,3), dispose clean), shelf-buy (book spine bought, cash drops), tram (present). Book-stock check now counts isStock OR buyMesh (C2 tags book spines buyMesh, not isStock). flags_check GREEN 0/0; qa.sh --strict GREEN 5/5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
503 lines
28 KiB
Python
503 lines
28 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 0x3fa36874, 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
|
|
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 = 0x3fa36874 # frozen determinism anchor (Lane A selfcheck) — UNCHANGED by the R7 flip
|
|
# 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
|
|
|
|
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 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 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 flags_off_regression(p):
|
|
head('FLAGS-OFF REGRESSION (prime-law enforcement)')
|
|
b, pg, errs = new_page(p)
|
|
try:
|
|
boot(pg, '')
|
|
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)
|
|
}""")
|
|
# 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)
|
|
hash_ok = (f'{GOLDEN_HASH:#010x}'.replace('0x', '0x') in r.stdout) or (hex(GOLDEN_HASH) in r.stdout) \
|
|
or ('3fa36874' in r.stdout)
|
|
if hash_ok and r.returncode == 0: OK(f'plan fingerprint == golden {hex(GOLDEN_HASH)} (selfcheck green)')
|
|
else: FAIL(f'plan fingerprint != golden {hex(GOLDEN_HASH)} — determinism/prime-law broken')
|
|
|
|
# winmap + dig stay OPT-IN (off by default). The streamed roster is now the shipping DEFAULT
|
|
# (R7 flip / prime-law amendment) — so it must be ON, not off, on a no-flag boot.
|
|
if state['winmap'] or state['digActive']:
|
|
FAIL(f"an opt-in v2 flag is active on default boot (winmap/dig): {state}")
|
|
else: OK('opt-in flags off on default boot (winmap/dig)')
|
|
if state['streamMode']:
|
|
OK('R7 roster flip in effect — streamed roster ACTIVE on default boot')
|
|
else: FAIL('roster flip REGRESSED — default boot is not streaming (expected streamMode:true)')
|
|
|
|
if state['draws'] <= STREET_DRAWS_MAX and state['tris'] <= STREET_TRIS_MAX:
|
|
OK(f"flags-off street view within budget (draws {state['draws']}≤{STREET_DRAWS_MAX}, tris {state['tris']}≤{STREET_TRIS_MAX})")
|
|
else: FAIL(f"flags-off 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) on default boot; first: {errs[0][:140]}")
|
|
else: OK('0 console errors on default boot')
|
|
|
|
# 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 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 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")
|
|
|
|
flags_off_regression(p)
|
|
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)
|
|
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()
|