guts/tools/qa.sh
type-two 4e4311b568 [lane E+F] The game remembers you: a save file, a level select and an ending
Audit items 9, 10 and 11 — three features whose backends were finished and whose front ends did
not exist.

DIFFICULTY WAS UNREACHABLE. levels/difficulty.js has carried three fully specified tiers since
round 2 — Endoscopy, Clinical, Terminal, with per-tier assists and a note explaining each — and
every single call site in the codebase took the default. `easy` and `hard` were a table nobody
could select. getLevel now takes the saved difficulty (?diff= still wins, so a test URL stays
absolute), and the title has the switch. Verified live: the world boots at 11 spawns on Terminal
against 8 on Clinical.

THE CAMPAIGN WAS SIX LEVELS WITH ONE WAY IN. Reaching anything but L1 meant hand-editing ?lvl=
in the address bar. The title now lists the campaign, unlocking each segment as the one before
it is filed, showing the medal you hold on each. `LS_` is C's secret prefix, so the appendix
stays a row of question marks until you have earned your way in — naming it in the menu would
have given away the one thing in this game you have to discover.

THE CAMPAIGN HAD NO ENDING. C's anal_verge gate carries `to: null` — the only gate in the game
that does, its own note calling it "the end of the campaign" — and boot's ui:continue handler
opened with `if (!e.to) return`. Finishing GUTS parked you on a medal card forever with no way
out but the address bar, and the card said "any key", the same words it uses when it has nothing
to tell you. Now the clear is filed and counted, the card says you are out, and the title carries
CANAL CLEARED.

New core/save.js is the whole persistence layer: one localStorage key, one flat object, best
time / best score / best medal kept per column (they need not come from the same run) plus an
attempt count. Every entry point is wrapped — Safari private mode throws on setItem, and a save
file must never be able to stop the game booting. Its selfcheck (`node web/js/core/save.js
--selfcheck`, now in qa.sh) covers the best-of comparisons and feeds it corrupt, future-versioned
and malformed saves.

cards.js records rather than boot: this file computes the grade, so routing it through the bus
for boot to re-derive would be two copies of the medal table waiting to disagree.

One bug found by clicking rather than reading: the first cut of the chips had no
pointer-events:auto. #ui is pointer-events:none and it inherits — the file's own header says so
— so the buttons rendered, highlighted nothing, and passed the click through to the canvas,
which on the title starts the run. It looked like a menu and behaved like wallpaper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:02:58 +10:00

72 lines
3.3 KiB
Bash
Executable File

#!/bin/bash
# tools/qa.sh (Lane F) — the gate. Run before you finish a session; leave it GREEN.
# Dependency-free: bash + node + python3. Gates accumulate each round.
set -u
cd "$(dirname "$0")/.."
FAIL=0
red() { printf '\033[31m✗ %s\033[0m\n' "$1"; FAIL=1; }
green() { printf '\033[32m✓ %s\033[0m\n' "$1"; }
# 1. every JS module parses.
# NB: plain `node --check` exits 0 on ANY file containing import/export (ESM detection
# no-op — found by Lane B round 1, verified on node v26). Must force module parsing.
JSBAD=0
while IFS= read -r -d '' f; do
node --check --input-type=module < "$f" 2>/dev/null || { echo "SYNTAX: $f"; node --check --input-type=module < "$f"; JSBAD=1; }
done < <(find web/js -name '*.js' -print0)
[ "$JSBAD" = 0 ] && green "node --check (ESM): all web/js modules parse" || red "JS syntax errors"
# 2. determinism law: Math.random only in core/rng.js
RAND=$(grep -rn 'Math\.random' web/js --include='*.js' | grep -v 'core/rng.js' || true)
if [ -n "$RAND" ]; then red "Math.random outside core/rng.js:"; echo "$RAND"; else green "determinism: no stray Math.random"; fi
# 3. level JSONs parse + minimal keys
LVLBAD=0
for f in web/js/levels/*.json; do
[ -e "$f" ] || continue
python3 - "$f" <<'PY' || LVLBAD=1
import json, sys
d = json.load(open(sys.argv[1]))
assert d.get("id") and isinstance(d.get("seed"), int) and d.get("segments"), "missing id/seed/segments"
PY
done
[ "$LVLBAD" = 0 ] && green "level JSONs valid" || red "level JSON errors"
# 4. Lane C selfcheck (when it exists)
if [ -f web/js/levels/index.js ]; then
node web/js/levels/index.js --selfcheck && green "levels selfcheck" || red "levels selfcheck failed"
fi
# 4b. Lane A world math (headless; no three.js needed)
if [ -f web/js/world/spline.js ]; then
node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed"
fi
# 4c. save file (headless; a shimmed localStorage)
if [ -f web/js/core/save.js ]; then
node web/js/core/save.js --selfcheck >/dev/null 2>&1 && green "save selfcheck" || red "save selfcheck failed"
fi
# 5. Lane D manifest validation (when it exists)
if [ -f pipeline/validate_manifest.py ]; then
python3 pipeline/validate_manifest.py && green "manifest valid" || red "manifest errors"
fi
# 5b. Asset provenance: does batch_textures.json still record the prompt that made the pack?
# Added by Lane A (round 2) after finding 4 of 6 walls recorded the prompt from Lane D's
# REJECTED framing experiment — regenerating from provenance produced a visibly different
# texture (docs/shots/laneA/round2_provenance_probe.png). record() only runs on generation, so
# reverting a prompt while keeping its image drifts silently and forever. This is the gate.
# Stdlib only, no GPU, no network. -> F: yours to keep or drop, flagged in LANE_A_NOTES.
if [ -f pipeline/gen_textures.py ]; then
python3 pipeline/gen_textures.py --verify-provenance >/dev/null 2>&1 \
&& green "texture provenance" \
|| red "texture provenance drift — run: python3 pipeline/gen_textures.py --verify-provenance"
fi
# 6. vendor present (the game must run offline)
[ -f web/vendor/three.module.js ] && green "vendor: three r175 present" || red "web/vendor/three.module.js missing"
[ "$FAIL" = 0 ] && printf '\033[32mQA GREEN\033[0m\n' || printf '\033[31mQA RED\033[0m\n'
exit $FAIL