PROCITY/tools/qa/week_soak.py
m3ultra 9277238462 Lane F R16 (v3.1 THE FLIP) — close: default-boot tour + week soak + docs
The closing half of F's bookend. All gates green on the flipped tree (qa.sh --strict --soak: classic
regression + default-boot gate + smokes + no-giants incl. the seated drummer + the full-week soak).

- tour_shots.py -> docs/shots/release_v3_1/: the money shots on the DEFAULT boot (no per-shot flag juggling
  now the flip covers it) + a ?classic=1 before/after ("the town before v3"). 7 shots, settled, 0 page errors.
- week_soak.py: re-pointed to the default boot; the leak check now treats an at/below-baseline OR non-climbing
  trail as leak-free (a decreasing tex trail is GC/atlas-settle, not a leak — it was false-failing at warn-level).
- docs: README v3.1 flags table (defaults on + ?classic=1), CITY_SPEC v3.1 amendment (the rewritten prime
  law + classic covenant, A's deferred edit), LANE_F_NOTES §16, F-progress R16.

Numbers (seed 20261990): default @ venue block 124 draws / 57k tris (<=300/200k); ?classic 161 draws / 24k
tris (the v2 baseline); seated drummer 1.32 m stature; week soak 42 cycles leak-free (net +0/-6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:32:22 +10:00

225 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→16, on the DEFAULT boot (v3.1 flip)). 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 · default boot)")
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}&dbg=1') # [R16] the DEFAULT boot now IS the gig boot
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
# a real leak CLIMBS every cycle; a flat OR DECREASING back half (GC / atlas settle) is not a leak, and
# ending AT OR BELOW the warmed baseline can't be one either. Only monotone growth into the tail fails.
climbing = (tail[-1]['geo'] > tail[0]['geo']) or (tail[-1]['tex'] > tail[0]['tex'])
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 ended at/below the warmed baseline (net {lk['geo']:+d}/{lk['tex']:+d}) across {res['cycles']} cycles (trail {traj})")
elif not climbing:
OK(f"leak-free: warm-up residue geo {lk['geo']:+d}/tex {lk['tex']:+d}, then flat/settling across the back half "
f"— no per-cycle growth (trail {traj}, {res['cycles']} cycles)")
else:
FAIL(f"LEAK: geo {lk['geo']:+d}/tex {lk['tex']:+d} still climbing (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()