PROCITY/tools/qa/r40_playtest.py
m3ultra c3384b92c9 Lane F R40 §40.6: the notepad — F8, the tour, and the gate that caught the harness
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>
2026-08-07 12:29:02 +10:00

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()