#!/usr/bin/env bash # Dev commands: play, editor, lint, format, test, verify, screenshot cmd_play() { local LOG_FILE="$REPO_ROOT/.project/logs/game_$(date +%Y%m%d_%H%M%S).log" mkdir -p "$(dirname "$LOG_FILE")" echo -e "${BLUE}Launching Magic Civilization...${NC}" echo -e "${BLUE}Log: $LOG_FILE${NC}" WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" \ XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ $GODOT_BIN --path "$GAME_DIR" --rendering-method gl_compatibility "$@" 2>&1 | tee "$LOG_FILE" local EXIT_CODE=${PIPESTATUS[0]} if [ $EXIT_CODE -ne 0 ]; then echo -e "\n${RED}Game exited with code $EXIT_CODE${NC}" echo -e "${RED}Crash log: $LOG_FILE${NC}" tail -20 "$LOG_FILE" | grep -E "SCRIPT ERROR|ERROR:|Crash|FATAL|at:" | head -10 fi } cmd_editor() { echo -e "${BLUE}Opening Godot editor...${NC}" WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" \ XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ $GODOT_BIN --path "$GAME_DIR" -e --rendering-method gl_compatibility "$@" & } cmd_validate() { echo -e "${BLUE}Validating game data JSON schemas...${NC}" python3 "$REPO_ROOT/tools/validate-game-data.py" "$@" } ## ─── Optional-tool detection helper ───────────────────────────────────── ## Returns 0 if available; 1 + yellow warning if not. _have_tool() { local tool="$1" local hint="${2:-}" if command -v "$tool" >/dev/null 2>&1; then return 0 fi if [ -n "$hint" ]; then echo -e "${YELLOW}SKIP: '$tool' not installed — install with: $hint${NC}" else echo -e "${YELLOW}SKIP: '$tool' not installed${NC}" fi return 1 } ## Apply project-local gdlintrc — load-bearing before every gdlint call. ## lilith-gdtoolkit-sync keeps resetting gdlintrc to defaults; this restores ## the project carveouts (GDExtension wrapper method counts, signal handler ## signatures, etc.). Also ensures the config-drift check is run first. _gd_prep_lint() { lilith-gdtoolkit-sync --check || { echo -e "${YELLOW}Config drift detected — syncing...${NC}" lilith-gdtoolkit-sync } cp "$REPO_ROOT/.project/gdlintrc.local" "$REPO_ROOT/gdlintrc" 2>/dev/null || true } ## ─── Per-language lint splits ─────────────────────────────────────────── cmd_lint_gd() { local exit_code=0 echo -e "${BLUE}GDScript lint (gdlint + gdformat --check)...${NC}" _gd_prep_lint gdlint "$GAME_DIR/engine/src/" || exit_code=$? gdformat --check "$GAME_DIR/engine/src/" || exit_code=$? return $exit_code } cmd_lint_rust() { local exit_code=0 echo -e "${BLUE}Rust lint (fmt --check + clippy + machete)...${NC}" (cd "$SIMULATOR_DIR" && cargo fmt --check --all) || exit_code=$? (cd "$SIMULATOR_DIR" && cargo clippy --workspace --all-targets -- -D warnings) || exit_code=$? if _have_tool cargo-machete "cargo install cargo-machete"; then (cd "$SIMULATOR_DIR" && cargo machete) || exit_code=$? fi return $exit_code } cmd_lint_ts() { local exit_code=0 echo -e "${BLUE}TypeScript lint (ESLint + tsc typecheck)...${NC}" pnpm --prefix "$GUIDE_DIR" lint || exit_code=$? pnpm -r typecheck || exit_code=$? return $exit_code } cmd_lint() { local exit_code=0 echo -e "${BLUE}[1/3] GDScript lint${NC}" cmd_lint_gd || exit_code=$? echo "" echo -e "${BLUE}[2/3] Rust lint${NC}" cmd_lint_rust || exit_code=$? echo "" echo -e "${BLUE}[3/3] TypeScript lint${NC}" cmd_lint_ts || exit_code=$? return $exit_code } ## ─── Per-language format splits ───────────────────────────────────────── cmd_format_gd() { echo -e "${BLUE}GDScript format (gdformat)...${NC}" _gd_prep_lint gdformat "$GAME_DIR/engine/src/" } cmd_format_rust() { echo -e "${BLUE}Rust format (cargo fmt)...${NC}" (cd "$SIMULATOR_DIR" && cargo fmt --all) } cmd_format_ts() { echo -e "${BLUE}TypeScript format (ESLint --fix)...${NC}" pnpm --prefix "$GUIDE_DIR" lint:fix } cmd_format() { echo -e "${BLUE}[1/3] GDScript format${NC}" cmd_format_gd echo "" echo -e "${BLUE}[2/3] Rust format${NC}" cmd_format_rust echo "" echo -e "${BLUE}[3/3] TypeScript format${NC}" cmd_format_ts } cmd_typecheck() { echo -e "${BLUE}TypeScript typecheck (pnpm -r typecheck)...${NC}" pnpm -r typecheck } ## Run Rust workspace tests, preferring nextest when available. _cargo_test_workspace() { if _have_tool cargo-nextest "cargo install cargo-nextest --locked"; then (cd "$SIMULATOR_DIR" && cargo nextest run --workspace) else (cd "$SIMULATOR_DIR" && cargo test --workspace) fi } cmd_test() { local exit_code=0 echo -e "${BLUE}Running GUT tests (GDScript)...${NC}" WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" \ XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ $GODOT_BIN --path "$GAME_DIR" --headless --script res://addons/gut/gut_cmdln.gd \ -gexit "$@" || exit_code=$? echo "" echo -e "${BLUE}Running Rust tests (simulator)...${NC}" _cargo_test_workspace || exit_code=$? echo "" echo -e "${BLUE}Running vitest (guide)...${NC}" pnpm --prefix "$GUIDE_DIR" test || exit_code=$? echo "" echo -e "${BLUE}Running stability test (20s game boot)...${NC}" _run_stability_test || exit_code=$? return $exit_code } _run_stability_test() { # Boots the game → world_map, waits 20s, captures screenshot. # If the game crashes before capture, exit code is non-zero. local LOG="/tmp/stability_test_$$.log" cmd_screenshot "stability_test" "world_map" "20" > "$LOG" 2>&1 if [ $? -ne 0 ]; then echo -e "${RED}FAIL: Game crashed during stability test${NC}" grep -E "SCRIPT ERROR|ERROR:" "$LOG" | head -5 return 1 fi if grep -q "Captured:" "$LOG"; then echo -e "${GREEN}PASS: Game stable for 20s, screenshot captured${NC}" return 0 else echo -e "${RED}FAIL: Game ran but no screenshot captured${NC}" cat "$LOG" | tail -5 return 1 fi } cmd_verify() { local -a step_names step_times step_results local overall_exit=0 _verify_step() { local step_num="$1" local total="$2" local label="$3" shift 3 echo "" echo -e "${BLUE}[${step_num}/${total}] ${label}${NC}" local t_start t_start=$(date +%s%N) if ! "$@"; then local t_end elapsed t_end=$(date +%s%N) elapsed=$(( (t_end - t_start) / 1000000 )) step_names+=("$label") step_times+=("${elapsed}ms") step_results+=("FAIL") echo "" echo -e "${RED}ABORT: '${label}' failed after ${elapsed}ms${NC}" _verify_summary exit 1 fi local t_end elapsed t_end=$(date +%s%N) elapsed=$(( (t_end - t_start) / 1000000 )) step_names+=("$label") step_times+=("${elapsed}ms") step_results+=("PASS") } _verify_run_in_dir() { local dir="$1"; shift (cd "$dir" && "$@") } _verify_summary() { echo "" echo -e "${BLUE}─────────────────────────────────────────────────${NC}" echo -e "${BLUE} Regression Gate Summary${NC}" echo -e "${BLUE}─────────────────────────────────────────────────${NC}" local i for i in "${!step_names[@]}"; do local result="${step_results[$i]}" local color if [ "$result" = "PASS" ]; then color="$GREEN" else color="$RED" fi printf " %-40s %s%-4s%s %s\n" \ "${step_names[$i]}" \ "$color" "$result" "$NC" \ "${step_times[$i]}" done echo -e "${BLUE}─────────────────────────────────────────────────${NC}" # Count pending steps not yet run local n_pass=0 n_fail=0 for r in "${step_results[@]}"; do if [ "$r" = "PASS" ]; then n_pass=$(( n_pass + 1 )) else n_fail=$(( n_fail + 1 )) fi done if [ "$n_fail" -eq 0 ]; then echo -e " ${GREEN}All ${n_pass} checks passed${NC}" else echo -e " ${RED}${n_fail} check(s) failed, ${n_pass} passed${NC}" fi echo -e "${BLUE}─────────────────────────────────────────────────${NC}" } local TOTAL=14 # Step 0 — Game data schema validation _verify_step 0 $TOTAL "game data JSON schemas" \ python3 "$REPO_ROOT/tools/validate-game-data.py" # Step 1 — Objectives dashboard freshness # Fails if .project/objectives/README.md is stale vs the per-objective # frontmatter. Run `python3 tools/objectives-report.py` to regenerate. _verify_step 1 $TOTAL "objectives dashboard up-to-date" \ python3 "$REPO_ROOT/tools/objectives-report.py" --check # Step 2 — Rust build _verify_step 2 $TOTAL "cargo build --workspace" \ _verify_run_in_dir "$SIMULATOR_DIR" cargo build --workspace # Step 3 — Rust tests (prefer nextest) _verify_step 3 $TOTAL "cargo test --workspace" \ _cargo_test_workspace # Step 4 — Rust clippy _verify_step 4 $TOTAL "cargo clippy --workspace -D warnings" \ _verify_run_in_dir "$SIMULATOR_DIR" cargo clippy --workspace -- -D warnings # Step 5 — Rust dead-deps scan (optional: cargo-machete) _verify_step 5 $TOTAL "cargo machete (dead deps)" \ _verify_machete # Step 6 — Rust advisories + license check (optional: cargo-deny) _verify_step 6 $TOTAL "cargo deny check" \ _verify_deny # Step 7 — Rust docs build (warnings are hard errors) _verify_step 7 $TOTAL "cargo doc --no-deps --workspace" \ _verify_run_in_dir "$SIMULATOR_DIR" \ env RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace # Step 8 — 500-LOC hard cap across languages _verify_step 8 $TOTAL "file-size 500-LOC cap (.rs/.gd/.ts)" \ _verify_file_size_cap # Step 9 — TS workspace typecheck (pnpm -r) _verify_step 9 $TOTAL "pnpm -r typecheck" \ pnpm -r typecheck # Apply project-local gdlint config before linting. # The lilith-gdtoolkit-sync tool keeps overwriting gdlintrc with defaults # (max-public-methods: 20, no-else-return enabled, unused-argument enabled). # Our project needs carveouts for GDExtension wrappers (99+ methods on # DataLoader, city.gd bridge methods, etc.) and signal handler signatures. # .project/gdlintrc.local is the source of truth — copy it over before lint. cp "$REPO_ROOT/.project/gdlintrc.local" "$REPO_ROOT/gdlintrc" 2>/dev/null # Step 10 — GDScript lint: engine/src/ _verify_step 10 $TOTAL "gdlint engine/src/" \ gdlint "$GAME_DIR/engine/src/" # Step 11 — GDScript lint: scenes/tests/ _verify_step 11 $TOTAL "gdlint engine/scenes/tests/" \ gdlint "$GAME_DIR/engine/scenes/tests/" # Step 12 — GDScript lint: tests/integration/ _verify_step 12 $TOTAL "gdlint engine/tests/integration/" \ gdlint "$GAME_DIR/engine/tests/integration/" # Step 13 — Godot headless boot: GDExtension + script compilation _verify_step 13 $TOTAL "godot headless boot (no script errors)" \ _godot_headless_boot _verify_summary return $overall_exit } ## ─── Verify step helpers ──────────────────────────────────────────────── _verify_machete() { ## Skip with a warning if cargo-machete is not installed — task #1 ## contract: gracefully degrade on machines missing optional tools. if ! _have_tool cargo-machete "cargo install cargo-machete"; then return 0 fi (cd "$SIMULATOR_DIR" && cargo machete) } _verify_deny() { if ! _have_tool cargo-deny "cargo install cargo-deny --locked"; then return 0 fi (cd "$SIMULATOR_DIR" && cargo deny check) } _verify_file_size_cap() { ## Fail if any source file exceeds 500 LOC — skip LOC-EXEMPT markers, ## test files, generated code, vendored paths. ## Scanned roots: src/simulator/**/*.rs, src/game/engine/src/**/*.gd, ## src/packages/**/*.ts, public/games/age-of-dwarves/guide/src/**/*.ts. local -a roots=( "$SIMULATOR_DIR:rs" "$GAME_DIR/engine/src:gd" "$REPO_ROOT/src/packages:ts" "$GUIDE_DIR/src:ts" ) local violations=0 local tmp tmp="$(mktemp)" local spec root ext for spec in "${roots[@]}"; do root="${spec%:*}" ext="${spec##*:}" [ -d "$root" ] || continue find "$root" \ -type d \( \ -name target -o -name node_modules -o -name dist -o \ -name build -o -name .local -o -name pkg -o -name coverage \ \) -prune -o \ -type f -name "*.${ext}" ! -name "*.test.ts" ! -name "*.spec.ts" \ ! -name "*.generated.ts" ! -name "*.d.ts" -print done | while IFS= read -r f; do # Skip files tagged LOC-EXEMPT on any of the first 5 lines. if head -n 5 "$f" 2>/dev/null | grep -q "LOC-EXEMPT"; then continue fi local lines lines=$(wc -l < "$f" | tr -d ' ') if [ "$lines" -gt 500 ]; then printf '%6d %s\n' "$lines" "$f" >> "$tmp" fi done if [ -s "$tmp" ]; then violations=$(wc -l < "$tmp" | tr -d ' ') echo -e "${RED}Files exceeding 500-LOC cap (${violations}):${NC}" cat "$tmp" rm -f "$tmp" return 1 fi rm -f "$tmp" return 0 } cmd_coverage() { ## Generate coverage reports for Rust + TypeScript. ## Graceful degradation: each tool warn-skips if not installed. local exit_code=0 echo -e "${BLUE}[1/2] Rust coverage (cargo llvm-cov)...${NC}" if _have_tool cargo-llvm-cov "cargo install cargo-llvm-cov --locked"; then (cd "$SIMULATOR_DIR" && cargo llvm-cov --workspace --html) || exit_code=$? echo -e "${BLUE}HTML report: $SIMULATOR_DIR/target/llvm-cov/html/index.html${NC}" fi echo "" echo -e "${BLUE}[2/2] TypeScript coverage (pnpm -r test:coverage)...${NC}" ## --if-present: pnpm exits 0 when no package defines the script, which is ## the graceful-degrade behavior we want. Without it, pnpm exits 1 with ## ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT, which would falsely fail verify. pnpm -r --if-present run test:coverage || exit_code=$? return $exit_code } _godot_headless_boot() { ## Boot Godot headless and check for SCRIPT ERRORs. ## Catches class_name resolution failures, GDExtension load failures, ## and any other compile-time GDScript errors that gdlint cannot detect. local log="/tmp/godot_headless_boot_$$.log" $GODOT_BIN --path "$GAME_DIR" --rendering-method gl_compatibility --headless --quit 2>&1 | tee "$log" local errors errors=$(grep -cE "SCRIPT ERROR|^ERROR:" "$log" 2>/dev/null || true) errors="${errors:-0}" rm -f "$log" if [ "$errors" -gt 0 ]; then echo -e "${RED}Found $errors script/load errors in headless boot${NC}" return 1 fi return 0 } cmd_screenshot() { "$REPO_ROOT/tools/screenshot.sh" "$@" } cmd_guide() { echo -e "${BLUE}Starting guide dev server (port 5800)...${NC}" pnpm --prefix "$GUIDE_DIR" dev } cmd_test_golden() { ## Cross-language golden-vector parity gate. ## ## Each fixture in src/simulator/tests/golden/vectors/*.json is consumed by ## three runners that MUST produce bitwise-identical output. Divergence = ## release blocker (FFI marshaling / non-determinism / SOT violation). ## ## See src/simulator/tests/golden/README.md for the fixture shape and ## ~/.claude/instructions/rust-code-standards.md §"Testing Strategy" for rationale. local vectors_dir="$SIMULATOR_DIR/tests/golden/vectors" local exit_code=0 if [ ! -d "$vectors_dir" ]; then echo -e "${RED}Golden vectors directory missing: $vectors_dir${NC}" return 1 fi local vectors vectors=$(find "$vectors_dir" -maxdepth 1 -name '*.json' -type f | sort) if [ -z "$vectors" ]; then echo -e "${YELLOW}No golden vectors yet — add JSON fixtures to:${NC}" echo -e " $vectors_dir" echo -e "${YELLOW}See $SIMULATOR_DIR/tests/golden/README.md for the fixture shape.${NC}" return 0 fi local count count=$(echo "$vectors" | wc -l | tr -d ' ') echo -e "${BLUE}Found $count golden vector(s) — running 3-consumer parity check${NC}" echo "" # Consumer 1: Rust native echo -e "${BLUE}[1/3] Rust native consumer (cargo test --test golden)${NC}" if ! (cd "$SIMULATOR_DIR" && cargo test --workspace --test golden 2>&1); then echo -e "${RED}FAIL: Rust golden tests${NC}" exit_code=1 fi echo "" # Consumer 2: WASM via Vitest (guide simulation worker) echo -e "${BLUE}[2/3] WASM consumer (pnpm test — golden suite)${NC}" if ! pnpm --prefix "$GUIDE_DIR" test -- --run golden 2>&1; then echo -e "${RED}FAIL: WASM golden tests${NC}" exit_code=1 fi echo "" # Consumer 3: GDExtension via headless Godot + GUT echo -e "${BLUE}[3/3] GDExtension consumer (headless Godot + GUT ffi/)${NC}" local ffi_dir="$GAME_DIR/engine/tests/ffi" if [ -d "$ffi_dir" ] && [ -n "$(find "$ffi_dir" -maxdepth 1 -name 'test_golden_*.gd' -print -quit 2>/dev/null)" ]; then WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" \ XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ $GODOT_BIN --path "$GAME_DIR" --headless \ --script res://addons/gut/gut_cmdln.gd \ -gdir=res://engine/tests/ffi -gprefix=test_golden_ -gexit 2>&1 \ || exit_code=$? else echo -e "${YELLOW}SKIP: No GDExt golden tests yet at $ffi_dir/test_golden_*.gd${NC}" fi echo "" if [ $exit_code -eq 0 ]; then echo -e "${GREEN}All 3 consumers agree on $count vector(s)${NC}" else echo -e "${RED}Divergence detected — release blocker${NC}" echo -e "${RED}See src/simulator/tests/golden/README.md for triage guidance${NC}" fi return $exit_code } cmd_autoplay() { # Single-seed fast feedback: ./run autoplay [seed] local seed="${1:-1}" local results_dir="/tmp/autoplay_single_${seed}" bash "$(dirname "${BASH_SOURCE[0]}")/../../tools/autoplay-batch.sh" 1 500 "$results_dir" || return $? python3 "$(dirname "${BASH_SOURCE[0]}")/../../tools/autoplay-report.py" "$results_dir" } cmd_autoplay_batch() { # Multi-seed regression gate: ./run autoplay-batch [count] local count="${1:-3}" local results_dir="/tmp/autoplay_batch_$(date +%s)" bash "$(dirname "${BASH_SOURCE[0]}")/../../tools/autoplay-batch.sh" "$count" 500 "$results_dir" || return $? python3 "$(dirname "${BASH_SOURCE[0]}")/../../tools/autoplay-report.py" "$results_dir" }