THE PLAYTEST HARNESS, for John's bug-hunt session. F8 anywhere drops a structured note: pos, mode, shop id+name when inside, street/locality via A's address layer, seed, town, clock segment, live flags, fps, draws — plus a screenshot and a typed one-liner (Esc cancels, Enter saves). Notes persist under the delta law (bounded, survive reload, export/import, corrupt store REJECTED WHOLE and loudly) and export as one JSON of tickets, schema documented in LANE_F_NOTES §40. Zero cost when unused; classic-gated by construction. ?bugtour=1 walks the whole game in 11 stops (N advances, each with a caption of what to look at): spawn → street_day → shop_buy → dig → opshop_bin (caption says the contents are deliberately unarmed) → gig_night → tram → rain → night → fog_map → second_town. DBG loads without ?dbg=1 so the tour is one flag. THE GATE CAUGHT THE HARNESS. The first full strict run went RED on exactly one arm — R30c classic purity, because the playtest constructor read localStorage eagerly on ?game=0. Fixed (lazy store), re-verified on the targeted smoke, and the new gate now carries its own instrumented zero-calls arm so it cannot recur. Wave-1 asks, all taken: D's door_footpath_check and B's r40_lane_b wired into qa.sh · D's index.html:433-437 door nudge now derives from buildShopfront's doorRect · A's filed D2 closed (index.html:541 uses the canonical isOpen; remaining divergent call sites listed for their owners) · README's stale ?r=N 307 row and the flags_check label that still called the town selector a debt question, both corrected. qa.sh --strict: 12 passed · 0 failed · 0 warn · 0 skipped. selfcheck 157,647/157,647, 0x5f76e76 unmoved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
291 lines
15 KiB
Python
291 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY Lane F — R40 §40.6 gate: THE PLAYTEST HARNESS (F8 notes + ?bugtour=1).
|
|
|
|
Three arms, each with its non-vacuous control, per the round brief:
|
|
|
|
OFF = BYTE-IDENTICAL ?classic=1 and ?playtest=0 must never fetch playtest.js, never grow the
|
|
DOM (#pc-note / #pc-bugtour absent), never touch storage. CONTROL: the
|
|
default boot DOES fetch it and publishes PROCITY.playtest — otherwise the
|
|
absence arms prove nothing.
|
|
|
|
F8 ROUND-TRIPS The REAL key path (keyboard F8, not an API call): screenshot download
|
|
fires, the overlay opens, a typed line + Enter saves. Export JSON
|
|
schema-validates field-for-field (the ticket contract, LANE_F_NOTES §40),
|
|
a reload persists the note, Esc cancels without saving, and a corrupt /
|
|
wrong-schema blob is REJECTED WHOLE on load and on import() (delta law).
|
|
|
|
?bugtour=1 ADVANCES N steps through EVERY stop (spawn → street → shop/buy → dig → op-shop bin
|
|
(unarmed) → gig night → tram → rain → night → fog map → second town) with
|
|
ZERO console errors, the caption tracking each stop — and the shell loads
|
|
DBG under ?bugtour=1 alone (no ?dbg=1), because the tour drives it.
|
|
|
|
Run: tools/.venv/bin/python tools/qa/r40_playtest.py (own no-store server, fresh contexts)
|
|
Exit: 0 green, 1 red.
|
|
"""
|
|
import sys, os, json, time, socket, subprocess, pathlib
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
|
|
PORT = int(os.environ.get('PROCITY_R40_PT_PORT', '8974'))
|
|
HOST = f'http://127.0.0.1:{PORT}'
|
|
SEED = 20261990
|
|
SCHEMA = 'procity-playtest/1'
|
|
NOTE_FIELDS = {'id': str, 't': (int, float), 'text': str, 'shot': (str, type(None)),
|
|
'pos': dict, 'mode': str, 'shopId': (int, str, type(None)), 'shopName': (str, type(None)),
|
|
'street': (str, type(None)), 'locality': (str, type(None)), 'citySeed': int,
|
|
'town': (str, type(None)), 'seg': int, 'clock': str, 'flags': dict,
|
|
'fps': (int, float, type(None)), 'draws': (int, float), 'tris': (int, float)}
|
|
|
|
fails = []
|
|
def FAIL(m): fails.append(m); print(f" \033[31m✗ FAIL\033[0m {m}")
|
|
def OK(m): print(f" \033[32m✓\033[0m {m}")
|
|
def head(m): print(f"\n\033[1m{m}\033[0m")
|
|
def note(m): print(f" \033[33m·\033[0m {m}")
|
|
def check(cond, m):
|
|
(OK if cond else FAIL)(m)
|
|
return cond
|
|
|
|
NOSTORE = r'''
|
|
import sys, http.server, functools
|
|
class H(http.server.SimpleHTTPRequestHandler):
|
|
def end_headers(self):
|
|
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
|
|
super().end_headers()
|
|
def log_message(self, *a): pass
|
|
http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])),
|
|
functools.partial(H, directory=sys.argv[2])).serve_forever()
|
|
'''
|
|
|
|
|
|
def port_up(port):
|
|
with socket.socket() as s:
|
|
s.settimeout(0.4); return s.connect_ex(('127.0.0.1', port)) == 0
|
|
|
|
|
|
def serve(root, port):
|
|
proc = subprocess.Popen([sys.executable, '-c', NOSTORE, str(port), str(root)],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
for _ in range(80):
|
|
if port_up(port): return proc
|
|
time.sleep(0.1)
|
|
proc.terminate(); raise SystemExit(f'could not serve on :{port}')
|
|
|
|
|
|
def new_page(p):
|
|
b = p.chromium.launch()
|
|
pg = b.new_page(viewport={'width': 1280, 'height': 720}, accept_downloads=True)
|
|
errs, reqs, cons = [], [], []
|
|
pg.on('console', lambda m: (cons.append(m.text), errs.append(m.text) if m.type == 'error' else None))
|
|
pg.on('pageerror', lambda e: errs.append(str(e)))
|
|
pg.on('request', lambda r: reqs.append(r.url))
|
|
return b, pg, errs, reqs, cons
|
|
|
|
|
|
def boot(pg, query, wait_dbg=True):
|
|
pg.goto(f'{HOST}/index.html?seed={SEED}' + (('&' + query) if query else ''))
|
|
if wait_dbg:
|
|
pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=30000)
|
|
else:
|
|
pg.wait_for_function('window.PROCITY && window.PROCITY.chunks && window.PROCITY.chunks.count > 0',
|
|
timeout=30000)
|
|
pg.evaluate("() => { const o=document.getElementById('pc-start'); if(o) o.style.display='none'; }")
|
|
|
|
|
|
def gate_off(p):
|
|
head('OFF = BYTE-IDENTICAL — ?classic=1 / ?playtest=0 never fetch, never grow, never store')
|
|
for q, label in (('classic=1&dbg=1', '?classic=1'), ('playtest=0&dbg=1', '?playtest=0')):
|
|
b, pg, errs, reqs, _ = new_page(p)
|
|
boot(pg, q)
|
|
pg.wait_for_timeout(1200) # give a wrongly-wired dynamic import time to land
|
|
fetched = [u for u in reqs if 'playtest.js' in u]
|
|
dom = pg.evaluate("() => !!(document.getElementById('pc-note') || document.getElementById('pc-bugtour'))")
|
|
pub = pg.evaluate("() => window.PROCITY.playtest !== undefined")
|
|
stored = pg.evaluate("() => localStorage.getItem('procity-playtest')")
|
|
nstore = pg.evaluate("() => localStorage.length")
|
|
check(not fetched, f'{label}: playtest.js never fetched ({len(reqs)} requests swept)')
|
|
check(not dom and not pub, f'{label}: no harness DOM, PROCITY.playtest undefined')
|
|
check(stored is None, f'{label}: storage untouched (procity-playtest absent'
|
|
+ (f', localStorage empty)' if q.startswith('classic') and nstore == 0 else ')'))
|
|
if q.startswith('classic'):
|
|
check(nstore == 0, f'{label}: localStorage has 0 keys (the classic covenant)')
|
|
check(not errs, f'{label}: 0 console errors')
|
|
b.close()
|
|
# ?game=0 — the harness stays LIVE but its store is LAZY: the R30c purity law holds ?game=0 at
|
|
# ZERO Storage-prototype calls (instrumented before any page script), and an eager getItem in
|
|
# the constructor turned that gate red once (this round). Boot must make no calls; the first
|
|
# deliberate use pays exactly one read (the control that proves the instrument sees anything).
|
|
b, pg, errs, reqs, _ = new_page(p)
|
|
pg.add_init_script("""(() => { window.__pcStorageCalls = []; const P = Storage.prototype;
|
|
for (const m of ['getItem','setItem','removeItem','clear','key']) { const o = P[m];
|
|
P[m] = function(...a) { window.__pcStorageCalls.push(m + ':' + (a[0] ?? '')); return o.apply(this, a); }; } })()""")
|
|
boot(pg, 'game=0&dbg=1')
|
|
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
|
|
pg.wait_for_timeout(500)
|
|
ncalls = pg.evaluate("() => window.__pcStorageCalls.length")
|
|
check(ncalls == 0, f'?game=0: playtest LIVE with ZERO Storage calls at boot (saw {ncalls}) — R30c purity holds')
|
|
cnt = pg.evaluate("() => window.PROCITY.playtest.count")
|
|
lazy = pg.evaluate("() => window.__pcStorageCalls")
|
|
check(cnt == 0 and any(c == 'getItem:procity-playtest' for c in lazy),
|
|
'CONTROL: first use pays exactly the one lazy read (the instrument sees it)')
|
|
check(not errs, '?game=0: 0 console errors')
|
|
b.close()
|
|
|
|
# CONTROL — the absence arms are only meaningful if the default boot has the harness
|
|
b, pg, errs, reqs, _ = new_page(p)
|
|
boot(pg, 'dbg=1')
|
|
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
|
|
fetched = [u for u in reqs if 'playtest.js' in u]
|
|
check(bool(fetched), 'CONTROL: default boot fetches playtest.js')
|
|
check(pg.evaluate("() => window.PROCITY.flags.playtest === true"), 'CONTROL: flags.playtest true on default')
|
|
stored = pg.evaluate("() => localStorage.getItem('procity-playtest')")
|
|
check(stored is None, 'default boot with 0 notes still writes nothing (persist only on save)')
|
|
check(not errs, 'default boot: 0 console errors')
|
|
b.close()
|
|
|
|
|
|
def gate_f8(p):
|
|
head('F8 ROUND-TRIP — the real key path: shot download · overlay · Enter saves · export validates')
|
|
b, pg, errs, _, cons = new_page(p)
|
|
boot(pg, 'dbg=1')
|
|
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
|
|
pg.wait_for_function('() => window.DBG.exportNotes !== undefined', timeout=15000)
|
|
OK('DBG.exportNotes bound (the §40.6 DBG method)')
|
|
|
|
with pg.expect_download() as dl:
|
|
pg.keyboard.press('F8')
|
|
name = dl.value.suggested_filename
|
|
check(name.startswith('procity-note-') and name.endswith('.png'), f'F8 fired the screenshot download ({name})')
|
|
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
|
|
OK('the note overlay opened')
|
|
pg.wait_for_function("() => document.activeElement && document.activeElement.id === 'pc-note-text'", timeout=5000)
|
|
pg.type('#pc-note-text', 'the awning clips the verandah post here')
|
|
pg.press('#pc-note-text', 'Enter')
|
|
check(pg.evaluate("() => window.PROCITY.playtest.count") == 1, 'Enter saved — 1 note stored')
|
|
check(pg.evaluate("() => document.getElementById('pc-note').style.display") == 'none', 'overlay closed on save')
|
|
|
|
# Esc cancels — and cancels means NOT saved
|
|
with pg.expect_download():
|
|
pg.keyboard.press('F8')
|
|
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
|
|
pg.press('#pc-note-text', 'Escape')
|
|
check(pg.evaluate("() => window.PROCITY.playtest.count") == 1, 'Esc cancelled — still 1 note')
|
|
|
|
# export → schema-validate field-for-field (the ticket contract)
|
|
exported = pg.evaluate("() => window.DBG.exportNotes()")
|
|
try:
|
|
obj = json.loads(exported)
|
|
except Exception as e:
|
|
FAIL(f'export is not JSON: {e}'); b.close(); return
|
|
check(obj.get('schema') == SCHEMA and isinstance(obj.get('notes'), list) and len(obj['notes']) == 1,
|
|
f'export envelope: schema {SCHEMA}, 1 note')
|
|
n = obj['notes'][0]
|
|
bad = [k for k, t in NOTE_FIELDS.items() if k not in n or not isinstance(n[k], t)]
|
|
extra = [k for k in n if k not in NOTE_FIELDS]
|
|
check(not bad and not extra, f'note schema exact: {len(NOTE_FIELDS)} fields typed'
|
|
+ (f' — BAD {bad} EXTRA {extra}' if bad or extra else ''))
|
|
check(n['text'] == 'the awning clips the verandah post here', 'the typed one-liner round-tripped')
|
|
check(n['mode'] == 'street' and n['citySeed'] == SEED and n['draws'] > 0
|
|
and all(isinstance(n['pos'][k], (int, float)) for k in ('x', 'y', 'z')),
|
|
f"context captured: mode {n['mode']} · seed {n['citySeed']} · {n['draws']} draws · pos ✓ · seg {n['seg']} {n['clock']}")
|
|
check(n['shot'] == name, 'note links its screenshot filename')
|
|
note(f"street {n['street']!r} · locality {n['locality']!r} · fps {n['fps']} · flags {sum(n['flags'].values())} on")
|
|
|
|
# reload → persisted (delta law: survives, bounded, same note)
|
|
pg.reload()
|
|
boot(pg, 'dbg=1')
|
|
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
|
|
check(pg.evaluate("() => window.PROCITY.playtest.count") == 1
|
|
and pg.evaluate("() => window.PROCITY.playtest.list()[0].id") == n['id'],
|
|
'reload: the note survived, same id')
|
|
|
|
# import-reject leaves current notes untouched; corrupt store rejected WHOLE on next boot
|
|
ok_rej = pg.evaluate("() => window.PROCITY.playtest.import('{\"schema\":\"procity-playtest/1\",\"notes\":[{\"bad\":1}]}')")
|
|
check(ok_rej is False and pg.evaluate("() => window.PROCITY.playtest.count") == 1,
|
|
'import(): malformed note REJECTED, current notes untouched')
|
|
pg.evaluate("() => localStorage.setItem('procity-playtest', '{\"schema\":\"nope\",\"notes\":[]}')")
|
|
pg.reload()
|
|
boot(pg, 'dbg=1')
|
|
pg.wait_for_function('() => window.PROCITY.playtest !== undefined', timeout=15000)
|
|
check(pg.evaluate("() => window.PROCITY.playtest.count") == 0, 'corrupt store on boot: REJECTED WHOLE, empty start')
|
|
check(any('REJECTED' in c for c in cons), 'the reject is LOUD (console names it)')
|
|
|
|
# F8 INSIDE A SHOP — the interiorMode.renderFrame arm, and the no-eject guard (noteActive joins
|
|
# the digActive/sellActive unlock contract: taking a note must not walk you out the door).
|
|
pg.evaluate("() => window.DBG.setSegment(2)")
|
|
pg.evaluate("() => window.DBG.enterShop('record')")
|
|
pg.wait_for_function("() => window.PROCITY.mode === 'interior'", timeout=10000)
|
|
pg.wait_for_timeout(400)
|
|
with pg.expect_download():
|
|
pg.keyboard.press('F8')
|
|
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
|
|
pg.press('#pc-note-text', 'Enter')
|
|
ni = pg.evaluate("() => window.PROCITY.playtest.list()[0]")
|
|
check(ni['mode'] == 'interior' and bool(ni['shopName']) and ni['draws'] > 0,
|
|
f"interior note: mode {ni['mode']} · shop {ni['shopName']!r} (id {ni['shopId']}) · {ni['draws']} draws")
|
|
check(pg.evaluate("() => window.PROCITY.mode") == 'interior', 'F8 did NOT eject the player from the shop')
|
|
check(not errs or all('REJECTED' in e for e in errs), '0 unexplained console errors on the F8 arm')
|
|
b.close()
|
|
|
|
|
|
def gate_tour(p):
|
|
head('?bugtour=1 — N advances through every stop, zero console errors, DBG loads without ?dbg=1')
|
|
b, pg, errs, _, _ = new_page(p)
|
|
boot(pg, 'bugtour=1', wait_dbg=True) # no ?dbg=1 — the shell must load DBG for the tour itself
|
|
OK('DBG present under ?bugtour=1 alone')
|
|
pg.wait_for_function('() => window.PROCITY.playtest && window.PROCITY.playtest.tour', timeout=15000)
|
|
t0 = pg.evaluate("() => window.PROCITY.playtest.tour")
|
|
total = t0['total']
|
|
check(t0['active'] and t0['index'] == -1, f'tour armed at the intro caption ({total} stops)')
|
|
seen = []
|
|
for i in range(total):
|
|
pg.keyboard.press('n')
|
|
pg.wait_for_function(f"() => window.PROCITY.playtest.tour.index === {i}", timeout=20000)
|
|
st = pg.evaluate("() => window.PROCITY.playtest.tour")
|
|
cap = pg.evaluate("() => (document.getElementById('pc-bugtour')||{}).innerText || ''")
|
|
okc = st['stopId'] is not None and f'{i + 1}/{total}' in cap
|
|
seen.append(st['stopId'])
|
|
if not okc:
|
|
FAIL(f'stop {i}: id {st["stopId"]} caption {cap[:60]!r}')
|
|
pg.wait_for_timeout(150)
|
|
check(len(seen) == total and all(seen), f'advanced through all {total} stops: {" → ".join(seen)}')
|
|
for req in ('spawn', 'street_day', 'shop_buy', 'dig', 'opshop_bin', 'gig_night', 'tram', 'rain',
|
|
'night', 'fog_map', 'second_town'):
|
|
if req not in seen:
|
|
FAIL(f'required stop missing from the tour: {req}')
|
|
pg.keyboard.press('n')
|
|
pg.wait_for_function("() => window.PROCITY.playtest.tour.done === true", timeout=10000)
|
|
OK('one more N — tour completes')
|
|
# F8 works mid-tour (the point of the whole harness)
|
|
with pg.expect_download():
|
|
pg.keyboard.press('F8')
|
|
pg.wait_for_selector('#pc-note', state='visible', timeout=5000)
|
|
pg.press('#pc-note-text', 'Enter')
|
|
check(pg.evaluate("() => window.PROCITY.playtest.count") >= 1, 'F8 drops a note mid-tour')
|
|
check(not errs, f'0 console errors across the whole tour ({len(errs)} seen)'
|
|
+ (f' — {errs[:3]}' if errs else ''))
|
|
b.close()
|
|
|
|
|
|
def main():
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError:
|
|
sys.exit('playwright not installed — tools/.venv/bin/pip install playwright')
|
|
srv = serve(ROOT / 'web', PORT)
|
|
try:
|
|
with sync_playwright() as p:
|
|
gate_off(p)
|
|
gate_f8(p)
|
|
gate_tour(p)
|
|
finally:
|
|
srv.terminate()
|
|
print()
|
|
if fails:
|
|
print(f'\033[31mRED — {len(fails)} arm(s) failed\033[0m')
|
|
for f in fails: print(f' · {f}')
|
|
sys.exit(1)
|
|
print('\033[32mGREEN — the playtest harness holds (off byte-identical · F8 round-trips · the tour walks)\033[0m')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|