#!/usr/bin/env bash # PROCITY Lane F — the QA gate runner (CITY_SPEC / LANE_F acceptance). # # tools/qa.sh run every gate whose inputs have landed; skip the rest # tools/qa.sh --strict also FAIL on any still-pending lane deliverable (use at v1 sign-off) # # Design: F runs before A–E finish. A gate whose target file does not exist yet is reported # SKIPPED (yellow), not failed — so this is safe to run continuously as lanes land. Once every # lane is in, `--strict` turns the skips into hard failures: that is the v1 readiness gate. # # Gates: # 1. scaffold_check.mjs — scaffold + PRNG determinism law + lane-readiness matrix (F-owned) # 2. citygen selfcheck — node web/js/citygen/selfcheck.js (Lane A ships it) # 3. manifest validator — python3 pipeline/validate_manifest.py (Lane E ships it) # 8. the R39 (v9 wave 1) gates — tools/qa/r39_address.mjs · r39_transmission.py · r39_runtime.py # 9. the R40 gates — tools/qa/door_footpath_check.mjs · r40_lane_b.py · r40_playtest.py # 10. the R41 (THE LIBRARY LANDS) gates — pipeline/clips_verify.py · tools/qa/r41_postures.mjs · # r41_denylist.mjs · r41_citizens.py · r41_shots.py · r41_integration.py # 11. the R42 (PROVE THE LOOK) gates — tools/qa/r42_roster.mjs (period law · roster licence) · # r42_cast.py · r42_shots.py · r42_budget.py · r42_repro.py. --tour adds the proof tour. # # Browser-driven gates (determinism-PNG, 10-min soak, budget-HUD, ?noassets run) need the running # game; they live in tools/soak.md + tools/shots.md and run once Lane B's index.html lands. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" STRICT=0 [ "${1:-}" = "--strict" ] && STRICT=1 pass=0; failn=0; skip=0 c_red=$'\033[31m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_bold=$'\033[1m'; c_off=$'\033[0m' hr() { printf '%s\n' "────────────────────────────────────────────────────────"; } run_gate() { # run_gate "name" cmd... local name="$1"; shift printf '%s▶ %s%s\n' "$c_bold" "$name" "$c_off" if "$@"; then printf '%s ✓ PASS%s %s\n\n' "$c_grn" "$c_off" "$name"; pass=$((pass+1)); else printf '%s ✗ FAIL%s %s\n\n' "$c_red" "$c_off" "$name"; failn=$((failn+1)); fi } skip_gate() { # skip_gate "name" "reason" printf '%s▶ %s%s\n' "$c_bold" "$1" "$c_off" printf '%s ⊘ SKIP%s %s\n\n' "$c_yel" "$c_off" "$2" skip=$((skip+1)) } warnn=0 soft_skip() { # non-blocking skip for a warn-level gate — does NOT trip --strict readiness printf '%s▶ %s%s\n' "$c_bold" "$1" "$c_off" printf '%s ⊘ SKIP%s %s (warn-level, non-blocking)\n\n' "$c_yel" "$c_off" "$2" } warn_gate() { # warn_gate "name" cmd... — runs, but a failure is a WARN not a FAIL (round-6 policy) local name="$1"; shift printf '%s▶ %s%s (warn-level)\n' "$c_bold" "$name" "$c_off" if "$@"; then printf '%s ✓ PASS%s %s\n\n' "$c_grn" "$c_off" "$name"; pass=$((pass+1)); else printf '%s ! WARN%s %s (non-blocking this round — strict in r7)\n\n' "$c_yel" "$c_off" "$name"; warnn=$((warnn+1)); fi } printf '%sPROCITY QA GATES%s (%s)\n' "$c_bold" "$c_off" "$([ $STRICT = 1 ] && echo strict || echo lenient)" hr # ── Gate 1: scaffold + determinism + readiness (always) ────────────────────── run_gate "scaffold_check (scaffold · PRNG determinism · readiness matrix)" \ node tools/qa/scaffold_check.mjs # ── Gate 1b: cross-lane consistency (plan ↔ registry ↔ assets ↔ manifest) ──── # Self-skips (exit 0) until Lane A's plan.js + registry.js exist. run_gate "consistency (plan ↔ registry ↔ assets ↔ manifest)" \ node tools/qa/consistency_check.mjs # ── Gate 2: Lane A citygen selfcheck ───────────────────────────────────────── if [ -f web/js/citygen/selfcheck.js ]; then run_gate "citygen selfcheck (determinism · <100ms · lots/shops integrity)" \ node web/js/citygen/selfcheck.js else skip_gate "citygen selfcheck" "web/js/citygen/selfcheck.js not landed yet (Lane A)" fi # ── Gate 3: Lane E manifest validator ──────────────────────────────────────── # [Lane F R3] --depot: the 16 GLBs are published + live on the 3GOD depot (Fable, 2026-07-14), so the # gate now REQUIRES them reachable (was a soft local-only check). Override the endpoint with GOD3_DEPOT # (tailnet path) if digalot.fyi is unreachable; the validator falls back to the manifest's depot field. if [ -f pipeline/validate_manifest.py ]; then run_gate "manifest validator (files exist · footprints sane · GLBs live on depot · JSON parses)" \ python3 pipeline/validate_manifest.py --depot else skip_gate "manifest validator" "pipeline/validate_manifest.py not landed yet (Lane E)" fi # ── Gate 4 (warn-level, round 6): v2 flag enforcement harness ──────────────── # Flags-off regression (prime-law) + per-flag + all-on-combo smokes. Needs the Playwright venv + # a browser (self-contained server), auto-skips where absent. STRICT as of R7 (F2): a harness FAIL # now fails qa (flags-off regression + the four existing flags + all-on combo). New flags (stock, # weather) report at warn inside the harness, so they never trip this gate until they graduate. # Skip explicitly with --no-flags (fast determinism-only runs). FLAGS_SKIP=0; for a in "$@"; do [ "$a" = "--no-flags" ] && FLAGS_SKIP=1; done if [ "$FLAGS_SKIP" = 1 ]; then soft_skip "v2 flags harness" "--no-flags" elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then run_gate "flags harness (classic regression · default-boot gate · per-flag · smokes · STRICT · R16 flip)" \ tools/.venv/bin/python tools/flags_check.py else soft_skip "v2 flags harness" "Playwright venv absent (python3 -m venv tools/.venv && …/pip install playwright)" fi # ── Gate 5 (Lane D, round 10): interior figure-scale regression guard ──────── # Enters a sample of open shops and asserts every interior rig figure's crown is human-sized # [1.4,2.0] m AND under the room ceiling — so the R9 giant blocker (buildFigure normalised off # head-above-hips → ~2× too tall) can never pass a gate again. run_gate ⇒ a giant FAILS qa. # Needs the Playwright venv (auto-skips where absent, like the flags harness). Wired per ROUND10 §D. SCALE_SKIP=0; for a in "$@"; do [ "$a" = "--no-scale" ] && SCALE_SKIP=1; done if [ "$SCALE_SKIP" = 1 ]; then soft_skip "interior figure-scale gate" "--no-scale" elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then run_gate "interior figure-scale (no giants: crown ∈ [1.4,2.0] m, under ceiling · Lane D R10)" \ tools/.venv/bin/python tools/qa/interior_scale_check.py 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 # ── Gate 7 (Lane F, round 17): town-matrix smoke — OPT-IN (--matrix), NON-STRICT ─ # The v4 real-map scout evidence: boot / determinism / budget / district / noassets across the full town # matrix — synthetic hero seeds + the 3 osm fixtures + Lane E's 5 real AU town caches. One town × gate # table. Slow (~20 boots) + diagnostic, so OPT-IN + warn-level. Wired per ROUND17 §Lane F (ledger #7). MATRIX_RUN=0; for a in "$@"; do [ "$a" = "--matrix" ] && MATRIX_RUN=1; done if [ "$MATRIX_RUN" = 1 ]; then if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then warn_gate "town-matrix (synthetic + fixtures + real caches × 5 gates · Lane F R17)" \ tools/.venv/bin/python tools/qa/town_matrix.py else soft_skip "town-matrix" "Playwright venv absent" fi fi # ── Gate 8 (Lane F, round 39): the v9 wave-1 gates ─────────────────────────── # 39.1 THE ADDRESS LAYER (node, no browser) · 39.4 THE TRANSMISSION PRE-PASS (validator control + # the interior draw sweep against a fabricated glass asset) · 39.2 THE FOG / THE SIGN / THE RUMMAGE # BIN (browser). Each ships its falsifiability control in the same run — see LANE_F_NOTES §39. # --no-r39 skips the browser half on a fast determinism-only run; the node half always runs. run_gate "R39 address layer (>=97% corpus · degrade-to-district · the wrong-shift red arm)" \ node tools/qa/r39_address.mjs R39_SKIP=0; for a in "$@"; do [ "$a" = "--no-r39" ] && R39_SKIP=1; done if [ "$R39_SKIP" = 1 ]; then soft_skip "R39 transmission + runtime gates" "--no-r39" elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then run_gate "R39 transmission pre-pass (validator hard-fail control · interior sweep vs a glass asset)" \ tools/.venv/bin/python tools/qa/r39_transmission.py --validator run_gate "R39 runtime (THE SIGN · THE FOG both arms · THE RUMMAGE BIN, contents unarmed)" \ tools/.venv/bin/python tools/qa/r39_runtime.py else soft_skip "R39 transmission + runtime gates" "Playwright venv absent" fi # ── Gate 9 (Lane F, round 40): the R40 gates ───────────────────────────────── # [Lane F R40] Three gates, wired per each lane's §40 ask: # • D §40.5 — door→footpath (node, ~2s, zero deps): walkable-front ≥95% on all four towns, the # back-point control non-vacuous, the kerb clamp FIX-ONLY. rc 0/1. # • B §40.3 — the ?r= budget law (default 300 / diagnostic 420 + warn, measured stepwalk ≤ own # ceiling), classic's town selector (RULED R40), fog signage both arms. Own no-store server. # • F §40.6 — the playtest harness: off-boot byte-identical (no fetch, no DOM, no storage), the # F8 note round-trip (drop → export → schema-validate → reload persists → corrupt-reject), and # ?bugtour=1 advancing through every stop with zero console errors. run_gate "R40 door→footpath (D §40.5: walkable ≥95% · back-point control · fix-only clamp)" \ node tools/qa/door_footpath_check.mjs R40_SKIP=0; for a in "$@"; do [ "$a" = "--no-r40" ] && R40_SKIP=1; done if [ "$R40_SKIP" = 1 ]; then soft_skip "R40 browser gates (budget law · selector ruling · playtest harness)" "--no-r40" elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then run_gate "R40 ?r= budget law + classic selector + fog signage (B §40.3, both arms each)" \ tools/.venv/bin/python tools/qa/r40_lane_b.py run_gate "R40 playtest harness (F §40.6: off byte-identical · F8 round-trip · bugtour clean)" \ tools/.venv/bin/python tools/qa/r40_playtest.py else soft_skip "R40 browser gates (budget law · selector ruling · playtest harness)" "Playwright venv absent" fi # ── Gate 10 (Lane F, round 41): THE LIBRARY LANDS ──────────────────────────── # [Lane F R41 §41.6] Six gates, wired per each lane's §41 ask. The node/stdlib half always runs # (fast, zero deps); the browser half rides the Playwright venv like every other class here. # # • E §41.1 — pipeline/clips_verify.py: CLIP-MANIFEST INTEGRITY. Every clip id in # web/assets/motion_manifest.json resolves to a named animation in its group GLB, sizes match, # all six groups are zero-draw and Draco-free, all six carry the identical 66-node skeleton, # every channel targets a real node, every declared duration equals the keyframes in the bytes. # Ships with the library, so a renamed clip or a re-packed group fails HERE, not in the game. # • D §41.3 — r41_postures.mjs: the posture table resolves, is deterministic (two module # instances byte-equal), stream-isolated from the 12 pre-R41 keys, spread across all 10 idles, # and loop-safe. Five controls in the same run. # • F §41.6 — r41_denylist.mjs: RULING 3 AS REPO LAW. Nothing under web/ may reference OR CONTAIN # a banned name — paths AND bytes, binaries included (a GLB carries its source node names). The # control plants a real banned reference, asserts RED, removes it, asserts GREEN: this gate can # never go quietly vacuous. # • D §41.3 — r41_citizens.py: 7 browser arms (boot ledger · lazy loading · determinism incl. a # 2 s forced clip stall · the draw table both arms · ?noassets/?classic · bench binding + its # 2 m-offset control · leak · gig widening). # • D §41.3 — r41_shots.py: the acceptance shots, camera chosen by measurement + occlusion # raycast, every figure measured for stature (the R10 no-giants line). Written to a scratch # outdir by default so a gate run never rewrites the round's committed shots; set # PROCITY_SHOT_OUT=docs/shots/laneD to regenerate them deliberately. WARN-LEVEL — see the # measured reason at the call site (its browse arm can lose its browser to the sim mid-scan). # • F §41.6 — r41_integration.py: the seams F wired and the budgets F answers for — the wardrobe # both arms (ON resolves the pack, OFF is the control and is what ships default), the interior # ≤350 law on F'S OWN sweep plus the PHANTOM control that reproduces the pre-R41 instrument bug, # ?noassets=1 clean over a three-shop walk, ?clips=0 / ?classic=1 carrying no R41 cargo, and the # deny-list checked again from the network side. if [ -f pipeline/clips_verify.py ]; then run_gate "R41 clip-manifest integrity (E §41.1: 46 clips resolve · zero-draw · one skeleton · durations in the bytes)" \ python3 pipeline/clips_verify.py else skip_gate "R41 clip-manifest integrity" "pipeline/clips_verify.py not landed yet (Lane E)" fi run_gate "R41 postures (D §41.3: manifest resolution · determinism · stream isolation · pool spread · loop safety)" \ node tools/qa/r41_postures.mjs run_gate "R41 deny-list (F §41.6 / ruling 3: web/ clean by path AND bytes · plant-and-remove control)" \ node tools/qa/r41_denylist.mjs R41_SKIP=0; for a in "$@"; do [ "$a" = "--no-r41" ] && R41_SKIP=1; done if [ "$R41_SKIP" = 1 ]; then soft_skip "R41 browser gates (citizens · acceptance shots · integration)" "--no-r41" elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then run_gate "R41 citizens (D §41.3: boot ledger · lazy · determinism under a 2s stall · bench binding + control · leak)" \ tools/.venv/bin/python tools/qa/r41_citizens.py # [Lane F R42 §42.6] PROMOTED FROM warn_gate TO run_gate. R41 wired this warn-level because its # BROWSE arm scanned up to 8 shops for browsers, kept the best, then RE-ENTERED the winner 16-25 s # later — and a patronage occupant is free to walk out in the meantime, so the re-entry could spawn # 0 browsers and the arm asserted on an empty room. F filed it as deterministically red; D measured # the filing before fixing it (§42.4) and found it was FLAKY BY CONSTRUCTION, not always red — which # is the property that cannot go strict. D's fix removes the window rather than widening it: the # frame, the measurements and the JPEG are now taken INSIDE the winning room the moment a candidate # beats the incumbent, and the harness never leaves. The residual failure ("no occupied shop has # browse points with browsers at all") is real, deterministic and worth failing on. So it is strict. run_gate "R41 acceptance shots (D §41.3 + §42.4: measured camera · shot in place · R10 stature line per figure)" \ tools/.venv/bin/python tools/qa/r41_shots.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r41-shots}" run_gate "R41 integration (F §41.6: wardrobe both arms · interior ≤350 on F's sweep + phantom control · noassets · clip gates · deny-list live)" \ tools/.venv/bin/python tools/qa/r41_integration.py else soft_skip "R41 browser gates (citizens · acceptance shots · integration)" "Playwright venv absent" fi # ── Gate 11 (Lane F, round 42): PROVE THE LOOK ─────────────────────────────── # [Lane F R42 §42.6] R42 is a LOOK round and its two new laws are about WHAT SHIPS IN A BODY, so the # two cheapest gates are node/stdlib and always run. Each ships its control demonstrated. # # • F §42.6 / ruling 1 — r42_roster.mjs --gate period: NO BANNED-GENRE BODY SHIPS. PROCITY is 1990s # Australia; the R42 audit retired a purple luchador, a cartoon child, a wuxia elder, a WW2 # infantryman and — the one nobody had looked at in six rounds — an UNCLOTHED body that was in the # default crowd. The gate reads `PED_NAMES` in rigs.js (the only list the game loads from), not a # directory listing: the five files are still on disk by design, because deleting a name mid-wave # renumbers every pickRig index. Five arms: the roster, the five replacements at their own slots, # the pool shape 17/5/2, no banned name on any LOADABLE surface, and a QUARANTINE arm — every ped # GLB on disk must be shipped, a base clip, or explicitly recorded as withdrawn in credits.json, # so a sixth un-vetted body cannot appear silently. CONTROL: the R41 roster is replayed through # the same predicates and must trip all five. # • F §42.6 / ruling 3 — r42_roster.mjs --gate licence: ROSTER LICENCE COMPLETENESS. Every shipped # ped resolves to a credits.json row with a real licence; RED on any `unverified`. CONTROL: the # same predicate on a synthetic doc with R41's amber row restored must turn 20 peds red. # • D §42.4 — r42_cast.py: the recast, wired. Contract from the GLB bytes over all 54 clips, the # identity signature byte-identical across the swap, draw-neutrality measured as the crowd's own # difference (render, hide citizens.group, render), animation, and the impostor seam. # • D §42.4 — r42_shots.py: the street + pub crowd frames, every figure resolved to its body. # Scratch outdir like the R41 shot gate, so a routine run never rewrites the round's shots. # • F §42.6 — r42_budget.py: the interior ≤350 law with THE INSTRUMENT PINNED. C proved in §42.2b # that the 122-vs-123 argument was the CAMERA ASPECT (a fixed vertical fov inheriting the window's # aspect), so this sweep sets `camera.aspect = 16/9` explicitly and ships two controls: the same # sweep at 1.25 must read C's published 116, and the pinned sweep run in a 1280x1024 window must # return the SAME number as in 1280x720 — which is the actual claim that the gate can no longer # drift when someone edits an unrelated `new_page`. # • F §42.6 — r42_repro.py: THE BYTE-HASH GATE, and the R41 ruling reversed on measurement. See # F-progress §42.6 for the ruling itself. Pins one sha256 per bookmark across 3 fresh frozen # boots, asserts `DBG.freeze().stillLive` is all-false and that the second freeze removes nothing, # and carries an unfrozen CONTROL that must still be able to fail. run_gate "R42 period law (F §42.6 / ruling 1: no banned-genre body ships · quarantine arm · R41-roster control)" \ node tools/qa/r42_roster.mjs --gate period run_gate "R42 roster licence (F §42.6 / ruling 3: every shipped ped has a real credits row · unverified control)" \ node tools/qa/r42_roster.mjs --gate licence R42_SKIP=0; for a in "$@"; do [ "$a" = "--no-r42" ] && R42_SKIP=1; done if [ "$R42_SKIP" = 1 ]; then soft_skip "R42 browser gates (cast · shots · interior budget · byte-hash pin)" "--no-r42" elif [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then run_gate "R42 cast wired (D §42.4: contract from the bytes · identity byte-identical · crowd-difference draws · impostor seam)" \ tools/.venv/bin/python tools/qa/r42_cast.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r42-shots}" run_gate "R42 cast shots (D §42.4: street + pub crowd, every figure resolved to its ped GLB)" \ tools/.venv/bin/python tools/qa/r42_shots.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r42-shots}" run_gate "R42 interior budget (F §42.6: ≤350 with the aspect PINNED 16:9 + the 1.25 and viewport controls)" \ tools/.venv/bin/python tools/qa/r42_budget.py run_gate "R42 byte-hash pin (F §42.6: 1 sha256 per bookmark over 3 frozen boots · stillLive all-false · unfrozen control)" \ tools/.venv/bin/python tools/qa/r42_repro.py else soft_skip "R42 browser gates (cast · shots · interior budget · byte-hash pin)" "Playwright venv absent" fi # ── Gate 11b (Lane F, round 42): THE PROOF TOUR — OPT-IN (--tour), NON-STRICT ─ # [Lane F R42 §42.6] The round's DELIVERABLE (this round is judged by eye), not a regression gate: # six frames re-shot on the integrated tree and composited against the COMMITTED R41 originals, each # humanoid frame carrying its R10 human-sized line and its CAST line. It is opt-in for two measured # reasons: it costs ~8 minutes of browser time (the street arm HOLDS until the live crowd offers a # frame with three posture states in it — that dwell is the method, not slack), and pointed at its # real outdir it REWRITES the round's committed shots. `--tour` runs it to a scratch outdir; # PROCITY_SHOT_OUT=docs/shots/laneF_r42 regenerates the deliverable deliberately. TOUR_RUN=0; for a in "$@"; do [ "$a" = "--tour" ] && TOUR_RUN=1; done if [ "$TOUR_RUN" = 1 ]; then if [ -x tools/.venv/bin/python ] && tools/.venv/bin/python -c "import playwright" 2>/dev/null; then warn_gate "R42 proof tour (F §42.6: the paired before/after deliverable · R10 + CAST lines)" \ tools/.venv/bin/python tools/qa/r42_tour.py --outdir "${PROCITY_SHOT_OUT:-${TMPDIR:-/tmp}/procity-r42-tour}" else soft_skip "R42 proof tour" "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' \ "$c_bold" "$c_off" "$c_grn" "$pass" "$c_off" "$c_red" "$failn" "$c_off" "$c_yel" "$warnn" "$c_off" "$c_yel" "$skip" "$c_off" if [ "$failn" -gt 0 ]; then printf '%s● QA RED%s — fix the failing gate(s) above.\n' "$c_red" "$c_off"; exit 1 fi if [ "$STRICT" = 1 ] && [ "$skip" -gt 0 ]; then printf '%s● QA NOT READY%s — %d gate(s) still pending; not v1 yet.\n' "$c_yel" "$c_off" "$skip"; exit 2 fi printf '%s● QA GREEN%s — every landed gate passed.\n' "$c_grn" "$c_off"; exit 0