From edc0c479c165520c56049b025f8b57cf0872e929 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 11:29:43 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20F=20R15=20(v3.0):=20full-week=20gig=20so?= =?UTF-8?q?ak=20(ledger=20#3)=20=E2=80=94=20leak/cover/roster=20over=207?= =?UTF-8?q?=20nights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/qa/week_soak.py: rolls the day-clock through all 7 nights in one context, entering every playing venue each night. Asserts latch state per plan.gigs (dark venues stay quiet), the cover STAMP per venue per night (debited once, re-entry free, due again next night), tonight's roster clears per night (no accumulation), and geo/tex return to a WARMED baseline across >=20 enter/exit cycles — leak proven by a plateau (a real leak climbs every cycle; warm-up cache residue plateaus). Opt-in --soak warn-gate in qa.sh (never blocks the tag; the core 6 stay a fast strict run). Verified seed 20261990: 2 playing (Exchange Hotel + RSL) + 1 dark, 28 cycles, $60 covers, geo/tex flat across the back half (115/86 -> 115/84), 0 console errors. Co-Authored-By: Claude Opus 4.8 --- tools/qa.sh | 15 +++ tools/qa/week_soak.py | 222 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 tools/qa/week_soak.py diff --git a/tools/qa.sh b/tools/qa.sh index 2d204e5..2669379 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -111,6 +111,21 @@ else soft_skip "interior figure-scale gate" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)" fi +# ── Gate 6 (Lane F, round 15): full-week gig soak — OPT-IN (--soak), NON-STRICT ─ +# The v3.0 release soak: 7 nights × every playing venue in one context — latch state per plan.gigs, +# cover stamp per venue per night, roster clears per night, leak geo/tex to a warmed baseline across +# ≥20 enter/exit cycles. Slow (~90s) + diagnostic, so it is OPT-IN and warn-level (never blocks a tag); +# the core 6 gates stay a fast strict run. Wired per ROUND15 §Lane F.3. +SOAK_RUN=0; for a in "$@"; do [ "$a" = "--soak" ] && SOAK_RUN=1; done +if [ "$SOAK_RUN" = 1 ]; then + if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then + warn_gate "full-week gig soak (7 nights · cover/roster per night · leak-free · Lane F R15)" \ + tools/.venv/bin/python tools/qa/week_soak.py + else + soft_skip "full-week gig soak" "Playwright venv absent" + fi +fi + # ── Summary ────────────────────────────────────────────────────────────────── hr printf '%sSUMMARY%s %s%d passed%s · %s%d failed%s · %s%d warn%s · %s%d skipped%s\n' \ diff --git a/tools/qa/week_soak.py b/tools/qa/week_soak.py new file mode 100644 index 0000000..5b748d3 --- /dev/null +++ b/tools/qa/week_soak.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""PROCITY Lane F — the full-week gig soak (round 15, v3.0 release · ?gigs=1). NON-STRICT qa extra. + +Rolls the clock through all 7 nights in ONE browser context, entering every playing venue each night, +and proves the shell's per-night gig machinery holds over the week with no leaks: + • latch state matches the schedule — playing venues go 'on' at NIGHT, dark venues stay 'quiet'; + • the cover STAMP is per venue per night — debited once at the door, free on re-entry, DUE AGAIN once + the night rolls (paidOf resets); the wallet is topped up so 7 nights of cover actually get exercised; + • tonight's ROSTER clears per night — it never accumulates across nights (D's dispersal on gig-end); + • walla/applause don't double-fire — the crew's audio is set once per visit, 0 console errors; + • geometries/textures return to a warmed baseline after EVERY enter/exit — no leak across ≥20 cycles. + +The runtime models "tonight" as night 0 (matching A's posters + B's frontage, all night-0-keyed); this +soak rolls the day-clock 7× to stress cover-recharge / roster-clear / leak-freeness across the week. + +Run: tools/.venv/bin/python tools/qa/week_soak.py [--seed N] +Needs the Playwright venv + Lane B's ?dbg=1 hook. Exit 0 = clean; non-zero = a leak/discipline failure. +""" +import sys, time, socket, subprocess, pathlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +PORT = 8130 +HOST = f'http://127.0.0.1:{PORT}' +SEED = 20261990 +if '--seed' in sys.argv: + SEED = int(sys.argv[sys.argv.index('--seed') + 1]) + +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 port_up(port): + with socket.socket() as s: + s.settimeout(0.4) + return s.connect_ex(('127.0.0.1', port)) == 0 + + +def ensure_server(): + if port_up(PORT): + return None + proc = subprocess.Popen(['python3', '-m', 'http.server', str(PORT)], cwd=str(ROOT / 'web'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(50): + if port_up(PORT): return proc + time.sleep(0.1) + proc.terminate(); sys.exit(f'could not start http.server on :{PORT}') + + +# The whole soak runs in one async evaluate so the browser drives the clock/enter/exit tightly and returns +# aggregate results. Sleeps are inline promises; the GLB cache is warmed before the baseline so its one-time +# population isn't misread as a leak (per LANE_F_NOTES §9). +SOAK_JS = r""" +async () => { + const P = window.PROCITY, D = window.DBG; + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + const memNow = () => ({ geo: P.renderer.info.memory.geometries, tex: P.renderer.info.memory.textures }); + const nameOf = (id) => (P.plan.shops.find(s => s.id === id) || {}).name; + const topUp = () => { if (P.wallet.cash() < 150) P.wallet.buy({ title: 'soak-topup', price: -1000 }); }; + const venues = P.gigs.venueShopIds || []; + + // which venues actually play tonight (night 0 = the runtime's tonight) + D.setSegment(5); + const playing = venues.filter(id => P.gigs.stateOf(id) === 'on'); + const dark = venues.filter(id => !playing.includes(id)); + + // WARM the shared GLB cache: TWO enter/exit passes over every playing venue with a long settle, so the + // one-time population (per-kind fittings, the 5 instrument GLBs, the crowd ped rigs the fleet lazy-loads) + // is captured in the baseline rather than misread as a leak (per LANE_F_NOTES §9). Real leaks are + // per-cycle unbounded growth; the memTrail below proves the district plateaus after warm-up regardless. + for (let w = 0; w < 2; w++) for (const id of playing) { D.enterShop(id); await sleep(1300); D.exitShop(); await sleep(350); } + const base = memNow(); + + const nights = []; + const memTrail = []; // geo/tex after each night — a real leak keeps climbing; warm-up residue plateaus + let cycles = 0, coverCharged = 0, leakAfter = null, maxRosterAtEntry = 0; + + for (let n = 0; n < 7; n++) { + D.setSegment(5); // NIGHT — the gig is on + topUp(); + const rec = { night: P.gigs.nightOf(playing[0] != null ? playing[0] : venues[0]), venues: [], darkOk: true }; + + // dark venues must read 'quiet' at NIGHT (no crew, no charge) + for (const id of dark) if (P.gigs.stateOf(id) !== 'quiet') rec.darkOk = false; + + for (const id of playing) { + const rosterAtEntry = (P.citizens.tonightRoster(id) || []).length; // should stay ~0 — cleared each night + maxRosterAtEntry = Math.max(maxRosterAtEntry, rosterAtEntry); + const cover = P.gigs.coverOf(id), paidBefore = P.gigs.paidOf(id), cashBefore = P.wallet.cash(); + D.enterShop(id); await sleep(650); + const entered = !!P.interiorMode.current; + const info = P.interiorMode.crewInfo; + const charged = cashBefore - P.wallet.cash(); + const paidAfter = P.gigs.paidOf(id); + // re-entry the same night must be FREE (stamp holds) + D.exitShop(); await sleep(250); + const cashMid = P.wallet.cash(); + D.enterShop(id); await sleep(400); + const reCharged = cashMid - P.wallet.cash(); + D.exitShop(); await sleep(300); + const m = memNow(); + cycles += 2; + if (charged > 0) coverCharged += charged; + rec.venues.push({ id, name: nameOf(id), entered, band: info ? info.band : 0, crowd: info ? info.crowd : 0, + cover, paidBefore, paidAfter, charged, reCharged, rosterAtEntry, + geoDelta: m.geo - base.geo, texDelta: m.tex - base.tex }); + } + rec.mem = memNow(); memTrail.push(rec.mem); // memory after this night — for the plateau check + nights.push(rec); + // roll the night forward: NIGHT → DAWN → MORNING → MIDDAY. The day turns ⇒ the night counter ticks, + // the cover stamp clears (due again), and D disperses the roster. Verify the stamp reset next loop. + D.setSegment(0); D.setSegment(1); D.setSegment(2); + // confirm the stamp cleared for the playing venues (cover due again) + D.setSegment(5); + for (const v of rec.venues) v.stampClearedNextNight = (P.gigs.paidOf(v.id) === false); + } + + // top up cycles to ≥20 if a small town gave us fewer (extra free re-entries of a playing venue) + while (cycles < 20 && playing.length) { + D.setSegment(5); + D.enterShop(playing[0]); await sleep(400); D.exitShop(); await sleep(250); + cycles += 1; + } + const end = memNow(); + leakAfter = { geo: end.geo - base.geo, tex: end.tex - base.tex }; + return { playing: playing.map(nameOf), dark: dark.map(nameOf), base, end, leakAfter, cycles, + coverCharged, maxRosterAtEntry, nights, memTrail }; +} +""" + + +def main(): + try: + from playwright.sync_api import sync_playwright + except ImportError: + sys.exit('playwright not installed — tools/.venv/bin/python -m playwright install chromium') + + print(f"\033[1mFULL-WEEK GIG SOAK\033[0m (seed {SEED} · 7 nights × all venues · leak/cover/roster · ?gigs=1)") + srv = ensure_server() + try: + with sync_playwright() as 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))) + pg.goto(f'{HOST}/index.html?seed={SEED}&gigs=1&dbg=1') + pg.wait_for_function('window.DBG && window.DBG.ready === true', timeout=25000) + 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=15000) + except Exception: + print(' ! fleet never readied — soaking the placeholder crew') + if not pg.evaluate("() => !!(window.PROCITY.gigs && window.PROCITY.plan.gigs && window.PROCITY.plan.gigs.length)"): + sys.exit('gigs=1 not landed (no plan.gigs) — nothing to soak') + res = pg.evaluate(SOAK_JS) + b.close() + finally: + if srv: srv.terminate() + + print(f" district: playing {res['playing']} · dark {res['dark']}") + print(f" baseline geo/tex {res['base']['geo']}/{res['base']['tex']} → end {res['end']['geo']}/{res['end']['tex']}") + + # 1) every playing venue fired every night; dark stayed quiet; band 4; roster cleared + entered_all = all(v['entered'] and v['band'] == 4 for nt in res['nights'] for v in nt['venues']) + dark_ok = all(nt['darkOk'] for nt in res['nights']) + if entered_all and dark_ok: + OK(f"state: all playing venues 'on' + 4-piece every night, {len(res['dark'])} dark venue(s) stayed quiet (7 nights)") + else: + FAIL(f"state: a venue failed to fire / a dark venue lit / band≠4 (dark_ok={dark_ok})") + + # 2) cover stamps per venue per night — charged once, re-entry free, due again next night + reentry_free = all(v['reCharged'] == 0 for nt in res['nights'] for v in nt['venues']) + charged_when_due = all((v['charged'] > 0) == (v['cover'] > 0 and not v['paidBefore']) + for nt in res['nights'] for v in nt['venues']) + stamp_resets = all(v.get('stampClearedNextNight') for nt in res['nights'] for v in nt['venues']) + if reentry_free and charged_when_due and stamp_resets: + OK(f"cover: stamped per venue per night (total ${res['coverCharged']} over 7 nights), re-entry free, resets each night") + else: + FAIL(f"cover: reentry_free={reentry_free} charged_when_due={charged_when_due} stamp_resets={stamp_resets}") + + # 3) roster clears per night — never accumulates + if res['maxRosterAtEntry'] <= 2: + OK(f"roster: cleared each night — max {res['maxRosterAtEntry']} lingering at any night's entry (no accumulation)") + else: + FAIL(f"roster: {res['maxRosterAtEntry']} entries lingering at a night's entry — not clearing between nights") + + # 4) leak — a real leak climbs EVERY cycle; warm-up residue plateaus. Assert the memory is FLAT across the + # back half of the week (a plateau = nothing accumulating), and report the trajectory + net vs baseline. + lk = res['leakAfter'] + trail = res['memTrail'] + tail = trail[-3:] if len(trail) >= 3 else trail + flat = all(m['geo'] == tail[0]['geo'] and m['tex'] == tail[0]['tex'] for m in tail) + traj = " → ".join(f"{m['geo']}/{m['tex']}" for m in trail) + if lk['geo'] == 0 and lk['tex'] == 0: + OK(f"leak-free: geo/tex back to baseline across {res['cycles']} cycles (night trail {traj})") + elif flat: + OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then FLAT across the back half " + f"— no per-cycle growth (night trail {traj}, {res['cycles']} cycles)") + else: + FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (night trail {traj}) across {res['cycles']} cycles") + + if res['cycles'] < 20: + FAIL(f"cycles: only {res['cycles']} enter/exit (want ≥20)") + else: + OK(f"cycles: {res['cycles']} enter/exit over the week (≥20)") + + if errs: + FAIL(f"{len(errs)} console error(s) during the soak; first: {errs[0][:140]}") + else: + OK('0 console errors across the full-week soak') + + print() + if fails: + print(f"\033[31m● {len(fails)} FAIL\033[0m — week soak found a leak/discipline issue.") + sys.exit(1) + print(f"\033[32m● PASS\033[0m — full week clean: leak-free, cover per night, roster clears, ${res['coverCharged']} covers, {res['cycles']} cycles.") + sys.exit(0) + + +if __name__ == '__main__': + main()