#!/usr/bin/env python3 """PROCITY Lane F — R39 §39.5 runtime gates: THE SIGN (B) · THE FOG (B) · THE RUMMAGE BIN (C). Three gates, one browser, each with its falsifiability control demonstrated in the same run. §39.5-SIGN index.html:428/:500 read `(-sin ry, -cos ry)` — the BACK of the building — since R8/R32. The picture-free arm Lane B specified: AT THE SPAWN, the count of shops within 25 m that are IN FRONT of the player must be > 0. It was 0 on 4 of 4 towns before. The red arm is computed here from the plan by re-deriving the OLD spawn with the old sign and scoring the same metric at it — so the gate carries its own "before" on every run. §39.5-FOG BOTH ARMS OR IT IS VACUOUS: unwalked shops hidden AND walked shops revealed, measured on the same boot. Plus zero draws (default vs ?fog=0 at one pose, sim quiet), the save round-trip and its rejects, and the null store on ?classic=1 / ?game=0 / ?fog=0. §39.5-BIN The op shop's rummage bin: present and dug-able when armed, and CONTENTS UNARMED on the boot every player gets (Lane C held the contents for John). Both arms, or the gate only proves that a tub exists. Run: tools/.venv/bin/python tools/qa/r39_runtime.py [--only sign,fog,bin] [--json OUT] """ import sys, os, json, time, socket, subprocess, pathlib ROOT = pathlib.Path(__file__).resolve().parent.parent.parent PORT = int(os.environ.get('PROCITY_R39_RT_PORT', '8751')) HOST = f'http://127.0.0.1:{PORT}' SEED = 20261990 FOG_R = 25.0 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 note(m): print(f" \033[33m·\033[0m {m}") NOSTORE = r''' import sys, http.server, functools class H(http.server.SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate') super().end_headers() def log_message(self, *a): pass http.server.HTTPServer(('127.0.0.1', int(sys.argv[1])), functools.partial(H, directory=sys.argv[2])).serve_forever() ''' 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}) 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=30000) 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 # ══════════════════════════════════════════════════════════════════════════════════════════════════ # THE SIGN # ══════════════════════════════════════════════════════════════════════════════════════════════════ # Both spawn algorithms, re-derived from the LIVE plan in the page: `sgn = +1` is index.html:506 as # shipped after Lane B's fix, `sgn = -1` is what it read from R32 until this round. Then one scorer # for both poses. Nothing is monkey-patched: the shipped pose is READ off the live player and asserted # equal to the +1 derivation, which is what makes the -1 derivation a fair "before". JS_SIGN = r""" (FOG_R) => { const P = window.PROCITY, plan = P.plan; const lotById = new Map(plan.lots.map(l => [l.id, l])); const spawnWith = (sgn) => { const msBlocks = new Set((plan.blocks || []).filter(b => b.kind === 'mainstreet').map(b => b.id)); const doorOf = (s) => { const l = lotById.get(s.lot); if (!l) return null; const ry = l.ry || 0; return { x: l.x + sgn * Math.sin(ry) * (l.d / 2 + 2.6), z: l.z + sgn * Math.cos(ry) * (l.d / 2 + 2.6), block: l.block }; }; let doors = plan.shops.map(doorOf).filter(Boolean); const ms = doors.filter(d => msBlocks.has(d.block)); if (ms.length >= 2) doors = ms; if (doors.length < 2) return null; let cx = 0, cz = 0; for (const d of doors) { cx += d.x; cz += d.z; } cx /= doors.length; cz /= doors.length; let vx = 0, vz = 0; for (const d of doors) { vx += (d.x - cx) ** 2; vz += (d.z - cz) ** 2; } const ax = vx >= vz ? 'x' : 'z'; let stand = doors[0], bd = Infinity; for (const d of doors) { const dd = (d.x - cx) ** 2 + (d.z - cz) ** 2; if (dd < bd) { bd = dd; stand = d; } } let lo = Infinity, hi = -Infinity; for (const d of doors) { lo = Math.min(lo, d[ax]); hi = Math.max(hi, d[ax]); } const dir = (hi - stand[ax] >= stand[ax] - lo) ? 1 : -1; return { x: stand.x, z: stand.z, yaw: ax === 'x' ? Math.atan2(-dir, 0) : Math.atan2(0, -dir) }; }; // the shopfront points the fog probe uses: facade centre + its outward normal (buildings.js's +Z) const fronts = []; for (const s of plan.shops) { const l = lotById.get(s.lot); if (!l) continue; const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); fronts.push({ id: s.id, x: l.x + nx * (l.d / 2), z: l.z + nz * (l.d / 2), nx, nz }); } const score = (pos) => { if (!pos) return null; let near = 0, front = 0; for (const f of fronts) { const dx = pos.x - f.x, dz = pos.z - f.z; if (dx * dx + dz * dz > FOG_R * FOG_R) continue; near++; if (dx * f.nx + dz * f.nz > 0) front++; } return { near, front }; }; const live = { x: P.player.position.x, z: P.player.position.z }; const fixed = spawnWith(1), old = spawnWith(-1); return { town: P.townKey || 'synthetic', shops: plan.shops.length, live, fixed, old, liveMatchesFixed: !!fixed && Math.hypot(live.x - fixed.x, live.z - fixed.z) < 0.01, scoreLive: score(live), scoreFixed: score(fixed), scoreOld: score(old), discovered: P.discovery ? P.discovery.store.counts().shops : null, }; } """ def gate_sign(p, result): head('GATE R39 §39.5-SIGN — at the spawn, are there shopfronts in front of you?') towns = [('synthetic', ''), ('katoomba_real', 'plansrc=osm&town=katoomba_real'), ('bowral_real', 'plansrc=osm&town=bowral_real'), ('fitzroy_real', 'plansrc=osm&town=fitzroy_real')] rows = {} b, pg, errs = new_page(p) try: for name, q in towns: boot(pg, q) r = pg.evaluate(JS_SIGN, FOG_R) rows[name] = r print(f" {name:16s} shipped(+sin) at ({r['fixed']['x']:.1f}, {r['fixed']['z']:.1f}): " f"{r['scoreFixed']['near']} shops within 25 m, \033[1m{r['scoreFixed']['front']} in front\033[0m" f" · old(-sin) at ({r['old']['x']:.1f}, {r['old']['z']:.1f}): " f"{r['scoreOld']['near']} within 25 m, \033[31m{r['scoreOld']['front']} in front\033[0m") finally: b.close() result['sign'] = rows live_ok = all(r['liveMatchesFixed'] for r in rows.values()) if live_ok: OK(f"the LIVE spawn equals the +sin derivation on {len(rows)}/{len(rows)} towns — the shipped shell is the thing being scored, not a re-implementation") else: FAIL(f"the live spawn does not match the +sin derivation: {[(k, v['live'], v['fixed']) for k, v in rows.items() if not v['liveMatchesFixed']]}") good = [k for k, r in rows.items() if r['scoreLive']['front'] > 0] if len(good) == len(rows): OK(f"THE ARM: shops in front of the player at spawn > 0 on {len(good)}/{len(rows)} towns — " + ' · '.join(f"{k} {r['scoreLive']['front']}/{r['scoreLive']['near']}" for k, r in rows.items())) else: FAIL(f"spawn faces nothing on {[k for k in rows if k not in good]} — the sign has flipped back") bad = [k for k, r in rows.items() if r['scoreOld']['front'] == 0] if len(bad) == len(rows): OK(f"THE RED ARM, on this same tree: with the pre-R39 sign the identical metric reads ZERO in front on {len(bad)}/{len(bad)} towns — the gate discriminates") else: FAIL(f"the red arm does not go red on {[k for k in rows if k not in bad]} — this gate cannot fail, so it is not a gate") disc = {k: r['discovered'] for k, r in rows.items()} OK(f"…and the shell's own probe agrees at boot: shops discovered at spawn {disc}") if errs: WARN(f"{len(errs)} console error(s) across the sign boots; first: {errs[0][:140]}") else: OK('0 console errors across all four boots') # ══════════════════════════════════════════════════════════════════════════════════════════════════ # THE FOG # ══════════════════════════════════════════════════════════════════════════════════════════════════ # Walk the town by driving the SHIPPED probe (`discovery.probe`) from stations along the street graph, # rather than trusting a throttled rAF loop in a headless browser to cover ground. The cadence itself # is checked separately (a plain teleport-and-wait must also learn something). JS_WALK = r""" (n) => { const P = window.PROCITY; const plan = P.plan, nodes = new Map(plan.streets.nodes.map(x => [x.id, x])); // A SHOPPER'S WALK, not the longest roads: take the edges the most shops front. On a real town the // longest edges are rural arterials with nothing on them (katoomba: 5.8 km of them reveals 1 shop), // which measures the walker, not the fog. const lotById = new Map(plan.lots.map(l => [l.id, l])); const perEdge = new Map(); for (const s of plan.shops) { const l = lotById.get(s.lot); if (!l) continue; perEdge.set(l.frontEdge, (perEdge.get(l.frontEdge) || 0) + 1); } const rank = new Map([...perEdge].sort((a, b) => b[1] - a[1]).slice(0, n).map(([id], i) => [id, i])); const edges = plan.streets.edges.filter(e => rank.has(e.id)).map(e => { const a = nodes.get(e.a), b = nodes.get(e.b); return a && b ? { a, b, len: Math.hypot(b.x - a.x, b.z - a.z) } : null; }).filter(Boolean); let metres = 0, learned = 0, probes = 0; for (const e of edges) { const steps = Math.max(2, Math.ceil(e.len / 4)); for (let i = 0; i <= steps; i++) { const t = i / steps; const pos = { x: e.a.x + (e.b.x - e.a.x) * t, z: e.a.z + (e.b.z - e.a.z) * t }; learned += P.discovery.probe(pos); probes++; } metres += e.len; } const c = P.discovery.store.counts(); return { metres: Math.round(metres), probes, learned, shops: c.shops, edges: c.edges, stats: P.discovery.stats, view: P.minimap.view }; } """ # count pixels on the minimap canvas that EXACTLY match one of minimap.js's ten shop-type colours. # An exact palette match, not a threshold: the shipped instrument for "is a shop drawn at all". JS_MAPPX = r""" () => { const P = window.PROCITY; const PAL = ['#8a5aa8','#8a7a5a','#c86aa0','#7a6a3a','#4a6ab0','#c4a028','#c4863a','#5a6a7a','#5a8a6a','#6b5a48']; const want = new Set(PAL.map(h => ((parseInt(h.slice(1), 16) << 8) | 255) >>> 0)); const cv = document.querySelector('canvas#minimap, canvas.minimap') || [...document.querySelectorAll('canvas')].find(c => c !== P.renderer.domElement && c.width >= 600); if (!cv) return { err: 'no minimap canvas' }; const g = cv.getContext('2d'); const d = g.getImageData(0, 0, cv.width, cv.height).data; let n = 0, fnv = 0x811c9dc5 >>> 0; for (let i = 0; i < d.length; i += 4) { const v = (((d[i] << 16) | (d[i + 1] << 8) | d[i + 2]) << 8 | d[i + 3]) >>> 0; if (want.has(v)) n++; fnv = Math.imul((fnv ^ d[i]) >>> 0, 0x01000193) >>> 0; fnv = Math.imul((fnv ^ d[i + 1]) >>> 0, 0x01000193) >>> 0; fnv = Math.imul((fnv ^ d[i + 2]) >>> 0, 0x01000193) >>> 0; } return { px: n, w: cv.width, h: cv.height, fnv: fnv >>> 0 }; } """ JS_OPENMAP = r""" () => { window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyM' })); } """ def gate_fog(p, result): head('GATE R39 §39.5-FOG — hides what you have not walked, reveals what you have') out = {} for town, q in (('katoomba_real', 'plansrc=osm&town=katoomba_real'), ('synthetic', '')): b, pg, errs = new_page(p) try: boot(pg, q) fresh = pg.evaluate("""() => { const P = window.PROCITY; return { fog: !!P.knownStore, key: P.fogKey, total: P.plan.shops.length, known: P.knownStore ? P.knownStore.counts() : null, view: P.minimap.view, addr: P.addresses ? P.addresses.stats().supplier : null, exportHasKnown: 'known' in JSON.parse(P.game.export()) }; }""") pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400) px_fresh = pg.evaluate(JS_MAPPX) # which shops are FAR from the spawn? they must be unknown. far = pg.evaluate("""() => { const P = window.PROCITY, p = P.player.position; const lot = new Map(P.plan.lots.map(l => [l.id, l])); let farTotal = 0, farKnown = 0; for (const s of P.plan.shops) { const l = lot.get(s.lot); if (!l) continue; if (Math.hypot(l.x - p.x, l.z - p.z) < 120) continue; farTotal++; if (P.knownStore.hasShop(s.id)) farKnown++; } return { farTotal, farKnown }; }""") walk = pg.evaluate(JS_WALK, 12) pg.wait_for_timeout(400) px_walk = pg.evaluate(JS_MAPPX) save = pg.evaluate("""() => { const P = window.PROCITY, g = P.game; const blob = g.export(); const obj = JSON.parse(blob); const before = P.knownStore.counts(); const ids = P.knownStore.shopIds().slice(0, 5); // round trip const rt = g.import(blob); const after = P.knownStore.counts(); // rejects — each must leave live state untouched const rej = []; const mut = (f) => { const o = JSON.parse(blob); f(o); return JSON.stringify(o); }; const cases = { 'string id': mut(o => { o.known[P.fogKey].s[0] = 'x'; }), 'negative id': mut(o => { o.known[P.fogKey].s[0] = -1; }), 'float id': mut(o => { o.known[P.fogKey].s[0] = 1.5; }), 'town = array':mut(o => { o.known[P.fogKey] = [1,2,3]; }), 'known = array':mut(o => { o.known = [1,2,3]; }), '8193 ids': mut(o => { o.known[P.fogKey].s = Array.from({length: 8193}, (_, i) => i); }), '65 towns': mut(o => { for (let i = 0; i < 65; i++) o.known['t' + i] = { s: [1], e: [] }; }), 'empty key': mut(o => { o.known[''] = { s: [1], e: [] }; }), }; for (const [why, blb] of Object.entries(cases)) { const took = g.import(blb); const now = P.knownStore.counts(); rej.push({ why, took, intact: now.shops === after.shops && now.edges === after.edges }); } return { keys: Object.keys(obj), hasKnown: 'known' in obj, ids: obj.known ? obj.known[P.fogKey].s.length : 0, edgeIds: obj.known ? obj.known[P.fogKey].e.length : 0, bytes: blob.length, before, after, roundTrip: rt, sample: ids, rejects: rej }; }""") # THE SIDE TEST'S RED ARM, on the same boot: stand 5 m BEHIND each unknown shopfront and # probe. A radius-only fog hands every one of them over through the back wall. behind = pg.evaluate("""() => { const P = window.PROCITY; const lot = new Map(P.plan.lots.map(l => [l.id, l])); let tried = 0, learned = 0; const b0 = P.discovery.stats.rejectedBehind; for (const s of P.plan.shops) { if (P.knownStore.hasShop(s.id)) continue; const l = lot.get(s.lot); if (!l) continue; const ry = l.ry || 0, nx = Math.sin(ry), nz = Math.cos(ry); const fx = l.x + nx * (l.d / 2), fz = l.z + nz * (l.d / 2); tried++; P.discovery.probe({ x: fx - nx * 5, z: fz - nz * 5 }); // 5 m behind its own facade if (P.knownStore.hasShop(s.id)) learned++; } return { tried, learned, rejected: P.discovery.stats.rejectedBehind - b0 }; }""") out[town] = {'fresh': fresh, 'far': far, 'walk': walk, 'save': save, 'behind': behind, 'pxFresh': px_fresh, 'pxWalk': px_walk, 'errs': len(errs)} finally: b.close() # THE DELTA LAW, taken where it can actually be taken — and in its OWN browser, because a # save written by the walk above lives in localStorage and would be re-exported verbatim # (my first cut read `known` off a ?fog=0 boot that had simply LOADED the fogged save). # Since the +sin spawn fix the DEFAULT boot discovers 4 shops before the player touches a key, # so LANE_B_NOTES §39's "a fresh export has no `known` key" is now true of ?fog=0, not of a # default boot. The claim the law is actually about — a boot that learns NOTHING writes the # bytes it always wrote — is what this measures. b, pg, _ = new_page(p) try: boot(pg, q + ('&' if q else '') + 'fog=0') out[town]['nofog'] = pg.evaluate("""() => { const P = window.PROCITY; const o = JSON.parse(P.game.export()); return { store: !!P.knownStore, keys: Object.keys(o).sort(), hasKnown: 'known' in o }; }""") finally: b.close() result['fog'] = out for town, r in out.items(): f, w, s = r['fresh'], r['walk'], r['save'] print(f"\n \033[1m{town}\033[0m {f['total']} shops · fogKey {f['key']}") print(f" fresh boot : {f['known']['shops']} shops / {f['known']['edges']} segments known · " f"map frame {w['view']['span']:.0f} m at walk-end (fresh {f['view']['span']:.0f} m) · " f"lot {f['view']['lotPx']['w']}x{f['view']['lotPx']['d']} px (full-plan {f['view']['lotPxFull']['w']}x{f['view']['lotPxFull']['d']} px) · " f"{r['pxFresh']['px']} px of shop colour on the map") print(f" after {w['metres']:>5} m walked ({w['probes']} probes): {w['shops']} shops / {w['edges']} segments · " f"{r['pxWalk']['px']} px of shop colour · {w['stats']['rejectedBehind']} in-radius rejections through a back wall " f"({w['stats']['behindOnly']} shops refused and never learned from the front)") # ARM 1 — HIDES if f['known']['shops'] < f['total'] and r['far']['farKnown'] == 0 and r['far']['farTotal'] > 0: OK(f"{town} ARM 1 (HIDES): {f['known']['shops']}/{f['total']} known at spawn, and 0 of the {r['far']['farTotal']} shops more than 120 m away are known") else: FAIL(f"{town} ARM 1: the fog is not hiding — fresh {f['known']['shops']}/{f['total']}, far known {r['far']}") # ARM 2 — REVEALS if w['shops'] > f['known']['shops'] and w['edges'] > f['known']['edges']: OK(f"{town} ARM 2 (REVEALS): walking {w['metres']} m took it to {w['shops']} shops / {w['edges']} segments (+{w['shops'] - f['known']['shops']} / +{w['edges'] - f['known']['edges']})") else: FAIL(f"{town} ARM 2: walking revealed nothing — {f['known']['shops']} → {w['shops']} shops") # ARM 3 — the SIDE TEST's red arm: a radius-only fog would hand these over through the wall bh = r['behind'] if bh['tried'] > 0 and bh['learned'] == 0 and bh['rejected'] > 0: OK(f"{town} ARM 3 (the FRONT test): standing 5 m BEHIND each of the {bh['tried']} still-unknown shopfronts and probing learns " f"\033[1m{bh['learned']}\033[0m of them — {bh['rejected']} refusals. A radius-only fog would have handed over all {bh['tried']}.") else: FAIL(f"{town} ARM 3: the side test does not refuse through a back wall — {bh}") note(f"{town} on the shopper's walk itself the side test refused {w['stats']['rejectedBehind']} times " f"({w['stats']['behindOnly']} shops never learned from the front) — free where a shopper stands, decisive behind the buildings") # the map draws the fog if r['pxWalk']['px'] != r['pxFresh']['px']: OK(f"{town} the MAP moves with the ledger: {r['pxFresh']['px']} → {r['pxWalk']['px']} px of shop colour (the frame grows too, so this is a change, not a monotone)") else: FAIL(f"{town} the map drew the same pixels before and after the walk — it is not reading the ledger") # save — the delta law if not r['nofog']['hasKnown'] and not r['nofog']['store']: OK(f"{town} THE DELTA LAW: a boot that learns nothing (?fog=0) exports {r['nofog']['keys']} — no `known` key, the pre-R39 bytes") else: FAIL(f"{town} ?fog=0 wrote a `known` key: {r['nofog']}") if f['exportHasKnown']: note(f"{town} on the DEFAULT boot the export DOES carry `known` from the first frame — the +sin spawn now puts you in front of " f"{f['known']['shops']} shopfronts, so LANE_B_NOTES §39's \"a fresh export has no `known` key\" is true of ?fog=0 and no longer of a default boot") if s['hasKnown'] and s['ids'] == w['shops'] and s['edgeIds'] == w['edges'] and s['roundTrip'] and s['after'] == s['before']: OK(f"{town} save round-trip: {s['ids']} shop ids + {s['edgeIds']} edge ids, {s['bytes']:,} B, export→import restores the same counts") else: FAIL(f"{town} save round-trip wrong: {s}") bad = [x for x in s['rejects'] if x['took'] or not x['intact']] if not bad: OK(f"{town} all {len(s['rejects'])} corrupt-save arms REJECTED and live state untouched: " + ', '.join(x['why'] for x in s['rejects'])) else: FAIL(f"{town} corrupt saves accepted or state clobbered: {bad}") # ── the null store: classic / game=0 / fog=0 ────────────────────────────────────────────────── head('the null arm — ?classic=1 / ?game=0 / ?fog=0 are the pre-R39 map by construction') nulls = {} b, pg, errs = new_page(p) try: for q, name in (('classic=1', 'classic'), ('game=0', 'game0'), ('fog=0', 'fog0'), ('', 'default')): boot(pg, 'plansrc=osm&town=katoomba_real' + (('&' + q) if q else '')) pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400) nulls[name] = pg.evaluate("""() => { const P = window.PROCITY; return { store: !!P.knownStore, fog: P.minimap.view.fog, flagFog: P.flags.fog, span: P.minimap.view.span, lot: P.minimap.view.lotPx }; }""") nulls[name]['map'] = pg.evaluate(JS_MAPPX) finally: b.close() result['fogNull'] = nulls off = [k for k in ('classic', 'game0', 'fog0') if not nulls[k]['store'] and not nulls[k]['fog']] if len(off) == 3: OK(f"null store on all three ({', '.join(off)}) — minimap.js sees `known === null`, which is the pre-R39 map with no flag test in the file") else: FAIL(f"a fog store leaked into a null arm: {nulls}") if nulls['game0']['map']['fnv'] == nulls['fog0']['map']['fnv']: OK(f"?game=0 and ?fog=0 draw a PIXEL-IDENTICAL map (fnv {nulls['fog0']['map']['fnv']:#010x}) — two different routes to the same null store") else: FAIL(f"?game=0 and ?fog=0 draw different maps: {nulls['game0']['map']['fnv']:#010x} vs {nulls['fog0']['map']['fnv']:#010x}") if nulls['default']['map']['fnv'] != nulls['fog0']['map']['fnv']: OK(f"…and the DEFAULT boot's map differs from both (fnv {nulls['default']['map']['fnv']:#010x}) — the hash is sensitive to the map's content, so the equality above means something") else: FAIL('the fogged map hashes the same as the un-fogged one — the pixel comparison is vacuous') note(f"katoomba at the shipped full-plan transform draws {nulls['fog0']['map']['px']} px of shop colour " f"(lot {nulls['fog0']['lot']['w']}x{nulls['fog0']['lot']['d']} px) against the fogged map's " f"{nulls['default']['map']['px']} px at lot {nulls['default']['lot']['w']}x{nulls['default']['lot']['d']} px") # ── zero draws ──────────────────────────────────────────────────────────────────────────────── head('zero draws — the fog is information design and must cost nothing on the street') # Each arm gets its OWN freshly launched browser, and the pair is run in BOTH orders. My first cut # booted default-then-?fog=0 in ONE browser and read a 12k triangle gap at identical draw counts; # order is a confound (the second boot has a warm HTTP cache, so more of the asset fleet has # resolved by sample time) and reversing it is the only way to see that. Each boot is sampled # DRAINED — teleport, wait for chunks.pending == 0, settle, re-teleport at the same pose to read — # and three times over, so the arm carries its own noise floor. def run_arm(name, pose): q = '' if name == 'default' else 'fog=0' b, pg, _ = new_page(p) try: boot(pg, 'roster=v1&pop=0&gigs=0' + (('&' + q) if q else '')) pg.evaluate("() => window.DBG.setSegment(2)") if pose is None: pose = pg.evaluate("() => [window.PROCITY.player.position.x, window.PROCITY.player.position.z]") def settled(x, z, yaw, n=3): pg.evaluate("([x, z, y]) => window.DBG.teleport(x, z, y)", [x, z, yaw]) try: pg.wait_for_function("() => window.PROCITY.chunks.pending === 0", timeout=25000) except Exception: pass reads = [] for _ in range(n): pg.wait_for_timeout(1800) reads.append(pg.evaluate("""([x, z, y]) => { window.DBG.teleport(x, z, y); const i = window.DBG.info(); return { d: i.drawCalls, t: i.tris, chunks: i.chunks }; }""", [x, z, yaw])) last = reads[-1] return {**last, 'dRange': [min(r['d'] for r in reads), max(r['d'] for r in reads)], 'tRange': [min(r['t'] for r in reads), max(r['t'] for r in reads)]} got = {'a': settled(pose[0], pose[1], 0), 'b': settled(pose[0], pose[1], 3.14159265 / 2)} # non-vacuity: prove the two arms really are two arms before comparing their counters got['fogOn'] = pg.evaluate("() => ({ flag: window.PROCITY.flags.fog, store: !!window.PROCITY.knownStore })") pg.evaluate(JS_OPENMAP); pg.wait_for_timeout(400) got['mapMode'] = pg.evaluate("() => { const i = window.DBG.info(); return { d: i.drawCalls, t: i.tris, mode: i.mode }; }") return got, pose finally: b.close() zero, pose = {}, None for run, order in enumerate((('default', 'fog0'), ('fog0', 'default'))): for name in order: zero[f'{name}#{run}'], pose = run_arm(name, pose) result['fogZero'] = zero arms_differ = all(zero[f'default#{r}']['fogOn']['flag'] and zero[f'default#{r}']['fogOn']['store'] and not zero[f'fog0#{r}']['fogOn']['flag'] and not zero[f'fog0#{r}']['fogOn']['store'] for r in (0, 1)) if arms_differ: OK('the two arms ARE two arms: fog flag on + store present on the default boots, off + null on the ?fog=0 boots (without this, "identical counters" is two identical boots)') else: FAIL(f"the zero-draw arms are not distinguishable: {[(k, v['fogOn']) for k, v in zero.items()]}") draws_same, tris_same = True, True for run in (0, 1): d0, d1 = zero[f'default#{run}'], zero[f'fog0#{run}'] for k, lab in (('a', 'yaw 0 '), ('b', 'yaw 90')): print(f" run {run} ({'default first' if run == 0 else '?fog=0 first '}) {lab}: " f"default {d0[k]['d']} draws {d0[k]['dRange']} / {d0[k]['t']:,} tris {d0[k]['tRange']} · " f"?fog=0 {d1[k]['d']} draws {d1[k]['dRange']} / {d1[k]['t']:,} tris {d1[k]['tRange']}") if d0[k]['d'] != d1[k]['d']: draws_same = False if d0[k]['t'] != d1[k]['t']: tris_same = False if draws_same and tris_same: OK('identical in BOTH counters, both yaws, both orders, four freshly launched browsers — the fog costs +0 draws and +0 triangles on the street') elif draws_same: OK(f"DRAWS identical in both orders at both yaws — the budget's own quantity does not move") # is the triangle gap the FLAG or the ORDER? if it follows the order, it is cache warmth. g0 = zero['default#0']['a']['t'] - zero['fog0#0']['a']['t'] g1 = zero['default#1']['a']['t'] - zero['fog0#1']['a']['t'] if g0 * g1 < 0: OK(f"…and the triangle gap FOLLOWS THE ORDER, not the flag: {g0:+,} when default boots first, {g1:+,} when ?fog=0 does. " f"The second browser has a warm HTTP cache and more of the asset fleet resolved by sample time. Not the fog.") else: WARN(f"triangles differ in the same direction in both orders ({g0:+,} / {g1:+,} at yaw 0) — not explained by cache warmth; " f"the fog touches no scene graph, so this wants a look, but it is not a draw and not the budget's quantity") else: FAIL(f"the fog moved the DRAW counter: {zero}") OK(f"map mode reads {zero['default#0']['mapMode']['d']} draws (mode '{zero['default#0']['mapMode']['mode']}') — the frame loop never calls composer.render() in the map branch, so the counter cannot move") # ══════════════════════════════════════════════════════════════════════════════════════════════════ # THE RUMMAGE BIN # ══════════════════════════════════════════════════════════════════════════════════════════════════ JS_BIN_ARMED = r""" async ([arm, glb]) => { const C = window.PROCITY_C; const ARCH = C.ARCHETYPE_KEYS; const out = { rooms: 0, withTub: 0, withBin: 0, carves: 0, pathOK: 0, deterministic: 0, binless: [], reach: [], reachDist: [], digPlaces: 0 }; const manifest = glb ? await fetch('assets/manifest.json').then(r => r.ok ? r.json() : null).catch(() => null) : null; // 4-connected flood over walkable cells from the room spawn — layout.js's own reachability, redone // here so "reachable" is a measurement and not a flag the builder set about itself. const nearestReachable = (dbg, x, z) => { const g = dbg.grid; const idx = (cx, cz) => cz * g.cols + cx; const start = Array.isArray(dbg.spawnCell) ? idx(dbg.spawnCell[0], dbg.spawnCell[1]) : dbg.spawnCell; const seen = new Uint8Array(g.cols * g.rows); const st = [start]; seen[start] = 1; let best = Infinity; while (st.length) { const i = st.pop(), cx = i % g.cols, cz = (i / g.cols) | 0; const wx = (cx + 0.5) * g.cw - g.W / 2, wz = (cz + 0.5) * g.cd - g.D / 2; const d = Math.hypot(wx - x, wz - z); if (d < best) best = d; for (const [dx, dz] of [[1,0],[-1,0],[0,1],[0,-1]]) { const nx = cx + dx, nz = cz + dz; if (nx < 0 || nz < 0 || nx >= g.cols || nz >= g.rows) continue; const j = idx(nx, nz); if (seen[j] || g.occ[j] !== 0) continue; seen[j] = 1; st.push(j); } } return best; }; const opts = arm ? { fittingOpts: { rummageBin: { dig: true } } } : {}; for (const a of ARCH) { for (let s = 0; s < 6; s++) { const shop = { id: 'g' + s, type: 'opshop', seed: 1990 + s * 7717, storeys: 1 }; const base = Object.assign({ archetype: a, useGLB: !!glb }, glb ? { manifest } : {}, opts); const r = C.buildInterior(shop, C.THREE, base); const r2 = C.buildInterior(shop, C.THREE, base); out.rooms++; const tubs = (r.placement || []).filter(p => p.kind === 'rummageBin'); let bin = 0; r.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bin++; }); if (tubs.length) out.withTub++; else out.binless.push(a + '/' + s); if (bin) out.withBin++; if (r.pathOK !== false) out.pathOK++; if (r.carved) out.carves++; out.digPlaces += (r.places || []).filter(m => m.userData && m.userData.kind === 'bin').length; if (tubs.length && r._debug) out.reachDist.push(+nearestReachable(r._debug, tubs[0].x, tubs[0].z).toFixed(2)); // determinism: the placement summary is the deep-equal instrument the room already publishes if (JSON.stringify(r.placement) === JSON.stringify(r2.placement)) out.deterministic++; out.reach.push({ a, s, tub: tubs.length, bin, tubKind: tubs.length ? tubs[0].kindName : null, fw: tubs.length ? tubs[0].fw : null, fd: tubs.length ? tubs[0].fd : null }); r.dispose(); r2.dispose(); } } return out; } """ def gate_bin(p, result): head('GATE R39 §39.5-BIN — the op shop can be dug, and the contents did NOT ship') b, pg, errs = new_page(p) res = {} try: # ── (a) the DEFAULT BOOT — the one every player gets. Furniture, no contents. ───────────── boot(pg, '') res['street'] = pg.evaluate("""() => { const P = window.PROCITY, D = window.DBG; D.setSegment(2); const s = (P.plan.shops || []).find(x => x.type === 'opshop' && P.isOpen(x)); if (!s) return { why: 'no open op shop on the default boot' }; D.enterShop(s.id); const cur = P.interiorMode.current; let bins = 0; cur.group.traverse(o => { if (o.userData && o.userData.kind === 'bin') bins++; }); // the tub, identified off the room's OWN placement summary (kind is the recipe's fitting // kind) — not a bounding-box guess. Then stand in front of it and press E anyway. const spec = (cur.placement || []).find(p => p.kind === 'rummageBin'); let opened = null, world = null; if (spec) { world = cur.group.localToWorld(new P.THREE.Vector3(spec.x, 0.76, spec.z)); P.camera.position.set(world.x, 1.6, world.z + 1.3); P.camera.lookAt(world.x, world.y, world.z); P.camera.updateMatrixWorld(); window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyE' })); opened = !!P.interiorMode.digActive; } const digPlaces = (cur.places || []).filter(m => m.userData && m.userData.kind === 'bin').length; const fittings = (cur.placement || []).map(x => x.kind); D.exitShop(); return { shop: s.name, bins, tub: !!spec, tubKind: spec ? spec.kindName : null, opened, digPlaces, fittings }; }""") r = res['street'] if r.get('why'): FAIL(f"bin/default: {r['why']}") else: if r['tub'] and r['bins'] == 0 and r['digPlaces'] == 0 and r['opened'] is False: OK(f"DEFAULT BOOT (\"{r['shop']}\", fittings {r['fittings']}): the tub IS in the room and it is `kind:'{r['tubKind']}'` — " f"0 `kind:'bin'`, 0 dig targets, and standing in front of it and pressing E opens nothing. " f"THE CONTENTS DID NOT SHIP, which is how Lane C held them for John.") else: FAIL(f"bin/default: contents may have shipped — tub {r['tub']} kind '{r['tubKind']}', kind:'bin' {r['bins']}, dig targets {r['digPlaces']}, E opened {r['opened']}") finally: b.close() # ── (b) ARMED, through interior_test.html's own opt ──────────────────────────────────────────── b, pg, errs2 = new_page(p) try: for arm, glb, label in ((False, False, 'default · GLB off'), (True, False, 'ARMED · GLB off'), (False, True, 'default · GLB on'), (True, True, 'ARMED · GLB on')): pg.goto(f'{HOST}/interior_test.html?localdepot=1') pg.wait_for_function('() => !!window.PROCITY_C', timeout=30000) pg.wait_for_timeout(400) res[label] = pg.evaluate(JS_BIN_ARMED, [arm, glb]) g = res[label] rd = g['reachDist'] print(f" {label:18s} {g['rooms']} rooms: tub {g['withTub']}/{g['rooms']} · kind:'bin' {g['withBin']}/{g['rooms']} · " f"dig targets {g['digPlaces']} · pathOK {g['pathOK']}/{g['rooms']} · carves {g['carves']} · " f"deterministic {g['deterministic']}/{g['rooms']} · nearest reachable cell " f"{min(rd):.2f}–{max(rd):.2f} m" if rd else '') rooms = sum(res[k]['rooms'] for k in res if k.startswith(('default ·', 'ARMED'))) dd = [res['default · GLB off'], res['default · GLB on']] aa = [res['ARMED · GLB off'], res['ARMED · GLB on']] n = sum(x['rooms'] for x in aa) if all(x['withTub'] == x['rooms'] for x in dd + aa): OK(f"the tub lands in {n}/{n} armed and {n}/{n} default op-shop rooms (6 archetypes x 6 seeds x GLB on/off) — " f"Lane C's fallbackZones/`anywhere` machinery does what it says (it landed in 11 of 24 without it)") else: FAIL(f"the tub is missing from rooms — armed misses {[x['binless'] for x in aa]}, default misses {[x['binless'] for x in dd]}") if all(x['withBin'] == 0 for x in dd) and all(x['withBin'] == x['rooms'] for x in aa): OK(f"THE ARMING IS THE ONLY DIFFERENCE: 0/{n} default rooms carry a `kind:'bin'`, {n}/{n} armed ones do — same tub, one opt, and the opt is OFF in the shipped recipe") else: FAIL(f"the dig gate is not the switch: default bins {[x['withBin'] for x in dd]}, armed {[x['withBin'] for x in aa]}") if all(x['digPlaces'] == 0 for x in dd) and all(x['digPlaces'] == x['rooms'] for x in aa): OK(f"…and the AIM agrees: 0 `places` entries of kind 'bin' across the {n} default rooms, {n} across the armed ones — the dig cannot resolve to a tub that is not armed") else: FAIL(f"places/kind:'bin' wrong: default {[x['digPlaces'] for x in dd]}, armed {[x['digPlaces'] for x in aa]}") if all(x['pathOK'] == x['rooms'] and x['carves'] == 0 for x in aa + dd): OK(f"pathOK {n}/{n} on every arm and ZERO corridor carves — the tub never blocks the room it lands in") else: FAIL(f"rooms unwalkable or carved: {[(x['pathOK'], x['carves']) for x in aa + dd]}") if all(x['deterministic'] == x['rooms'] for x in aa + dd): OK(f"deterministic {n}/{n}: two builds of the same seed produce a byte-equal placement summary on both arms") else: FAIL(f"non-deterministic placement: {[x['deterministic'] for x in aa + dd]}") allrd = [d for x in aa for d in x['reachDist']] if allrd and max(allrd) < 2.5: OK(f"REACHABLE {len(allrd)}/{len(allrd)}: the nearest walkable cell flood-reachable FROM THE ROOM SPAWN sits " f"{min(allrd):.2f}–{max(allrd):.2f} m from the tub, against the dig's 2.5 m proximity / 3.2 m aimed reach") else: FAIL(f"the tub is out of reach in some rooms: {sorted(allrd)[-5:] if allrd else 'no measurements'}") finally: b.close() result['bin'] = res if errs2: WARN(f"{len(errs2)} console error(s) on the interior harness; first: {errs2[0][:140]}") else: OK('0 console errors across the bin arms') def main(): from playwright.sync_api import sync_playwright only = None if '--only' in sys.argv: only = set(sys.argv[sys.argv.index('--only') + 1].split(',')) out_json = sys.argv[sys.argv.index('--json') + 1] if '--json' in sys.argv else None result = {} proc = serve(ROOT / 'web', PORT) try: with sync_playwright() as p: if not only or 'sign' in only: gate_sign(p, result) if not only or 'fog' in only: gate_fog(p, result) if not only or 'bin' in only: gate_bin(p, result) finally: proc.terminate() if out_json: pathlib.Path(out_json).write_text(json.dumps(result, indent=1, default=str)) print() print(f"R39 runtime gates: {len(fails)} fail · {len(warns)} warn") if fails: for f in fails: print(f" \033[31m✗\033[0m {f}") return 1 print('\033[32mR39 RUNTIME GATES GREEN\033[0m') return 0 if __name__ == '__main__': sys.exit(main())