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>
This commit is contained in:
m3ultra 2026-07-16 11:29:43 +10:00
parent 687d23f654
commit edc0c479c1
2 changed files with 237 additions and 0 deletions

View File

@ -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' \

222
tools/qa/week_soak.py Normal file
View File

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