FIVE GATES, FIVE CONTROLS, each demonstrated in the same run. New F-owned tools wired into qa.sh as Gate 8: r39_address.mjs · r39_transmission.py · r39_runtime.py · r39_shots.py (--no-r39 skips the browser half). Lane A's selfcheck reproduces exactly: 157,647/157,647 ALL GREEN, fingerprint 0x5f76e76 — ZERO GOLDENS MOVED, in a round that added an address layer, a fog ledger, a spawn move and a new interior fitting. THE ROUND'S BIGGEST NUMBER IS NOT THE ONE THE LEDGER ASKED FOR. Two port-isolated no-store roots, the same inode for every byte except one file — the shipped bookshelf GLB vs a copy with transmissionFactor:1 re-inserted (BIN sha1 IDENTICAL, only the JSON chunk moved) — both through interior_test.html's own drawSweep: GLB-on worst 329 (opshop/hall) -> 188 (dept/auto) -141 GLB-off worst 162 (opshop/hall) -> 162 (opshop/hall) 0 — the control's own control margin vs <=350 21 -> 162 +141 opshop 329->174 (-47%) · book 247->125 (-49%) · every other type 0, exactly the shape Lane C predicted from an isolated room, at sweep scale, by a harness C did not write. The worst interior room in the game is now dept/auto — a room with no bookshelf in it. The default boot fetches these GLBs from the public depot, so all four R39 GLBs were fetched off digalot.fyi and scanned: sha1 identical to the local tree, 0 transmissive materials, 4/4. Binding fact #3's third and final value. 39.1 ADDRESS — GREEN. 1,192/1,219 shops (97.8%), 23/23 'ways', 857/857 membership agree / 0 wrong (and the control is asserted to have CHECKED 857 edges). No cache -> supplier 'district', 0 names, still labels 72/72 from district+block. RED ARM: a 3 m shift error RAISES adelaide 89 -> 98 resolved shops (Lane A's number to the digit) while INVENTING 9 names; stats().shiftCheck reads DISAGREE on all 72 perturbations. And the honest limit: at 3 m the membership control reads 0 disagreements, because those shops front ways OSM leaves unnamed — only the shift cross-derivation catches it. 39.2 FOG — GREEN, both arms on one boot. HIDES 4/72 at spawn, 0 of 61 distant shops known. REVEALS 2,125 m -> 58 shops. FRONT TEST: probe from 5 m BEHIND each of 59 unknown shopfronts, 0 learned. +0 DRAWS AND +0 TRIS, four freshly launched browsers, both orders, drained, 3 reads each, with the arms asserted distinguishable first. 8/8 corrupt saves rejected, state untouched. Null store on classic/game=0/fog=0; game=0 and fog=0 pixel-identical, default differs. 39.3 THE SIGN — GREEN. Shops in front of the player at spawn: 4/4 · 4/4 · 4/4 · 1/1, and ZERO on all four with the pre-R39 sign re-derived on the same tree. The live spawn is asserted equal to the +sin derivation, so the shell is what is scored. Re-baselined WITHOUT falsifying a release record: the v7 tour and v7-beta frames are kept as history; docs/shots/laneF_r39/ ships the new pair with its own BEFORE. The R35 gate itself is pose-independent and was green either side. 39.4 RUMMAGE BIN — GREEN. 120 rooms: tub 60/60 both arms, kind:'bin' 0/60 default vs 60/60 armed, 0 carves, 60/60 deterministic, reachable 0.80-1.03 m by a flood fill written here. THE CONTENTS DID NOT SHIP: on the default street boot the tub is kind:'prop', 0 dig targets, E opens nothing. 39.5 TRANSMISSION VALIDATOR — GREEN and it FIRES: hard-fails rc 1 on the fabricated glass asset AND passes on the shipped tree. THREE CORRECTIONS, ALL MEASURED. A's "a 30 m shift returns 0 street names" — it returns 40/12/47 wrong ones; a big shift lands you on the next street, it does not degrade to null. B's "a fresh export has no `known` key" — true of ?fog=0, no longer of a default boot, because B's own +sin fix means you spawn in front of four shopfronts. And my own: I first aimed the transmission control at web/assets/models/ when validate_manifest.py reads pipeline/_normalized/, and nearly filed a defect against Lane E on the strength of it. Aim the control at the code path, not the plausible path. HELD -> LANE E: check_transmission() skips any GLB with no local _normalized copy and never scans web/assets/models/. Demonstrated with a live control — remove the local copy and the same glass asset passes rc 0. A depot-only transmissive GLB (the exact shape of longbench and streetlight) would ship unseen. No tag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
688 lines
42 KiB
Python
688 lines
42 KiB
Python
#!/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())
|