PROCITY/tools/qa/week_soak.py
m3ultra edc0c479c1 Lane F R15 (v3.0): full-week gig soak (ledger #3) — leak/cover/roster over 7 nights
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 <noreply@anthropic.com>
2026-07-16 11:29:43 +10:00

223 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()