From ad4fb44390ce91b95e4d40bbaa5639da880f2320 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 12:35:54 -0700 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=9B=20update=20forgejo=20runner=20installation=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../games/age-of-dwarves/data/objectives.json | 4 +- scripts/dev-setup/lib/runner.sh | 57 +- scripts/run/common.sh | 17 + scripts/run/dev.sh | 527 +----------------- src/game/engine/src/generation/auto_play.gd | 2 + .../src/modules/combat/combat_resolver.gd | 38 ++ src/simulator/Cargo.lock | 2 + 7 files changed, 118 insertions(+), 529 deletions(-) diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index f4ce7eb8..07569f70 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,11 +1,11 @@ { - "generated_at": "2026-04-17T19:25:53Z", + "generated_at": "2026-04-17T19:32:03Z", "totals": { "partial": 10, "missing": 2, - "oos": 4, "stub": 0, "done": 32, + "oos": 4, "total": 48 }, "objectives": [ diff --git a/scripts/dev-setup/lib/runner.sh b/scripts/dev-setup/lib/runner.sh index 7da880f3..6955fa6b 100644 --- a/scripts/dev-setup/lib/runner.sh +++ b/scripts/dev-setup/lib/runner.sh @@ -18,9 +18,9 @@ set -euo pipefail -RUNNER_BIN="$HOME/.local/bin/forgejo-runner" RUNNER_DIR="$HOME/.local/share/forgejo-runner" -RUNNER_URL_BASE="https://code.forgejo.org/forgejo/runner/releases/download/nightly" +# Binary path discovered at install time (brew on macOS, direct binary on Linux). +RUNNER_BIN="" runner_require_env() { local missing=() @@ -34,17 +34,50 @@ runner_require_env() { fi } +# Install the runner binary and set RUNNER_BIN to its path. +# - macOS: Homebrew `act_runner` (the forgejo-compatible runner). +# (Forgejo's own release tarballs don't ship a darwin binary.) +# - Linux: direct download from Forgejo's latest tagged release. runner_install_binary() { - mkdir -p "$(dirname "$RUNNER_BIN")" "$RUNNER_DIR" - if [[ -x "$RUNNER_BIN" ]]; then - echo " runner: binary already present at $RUNNER_BIN" - return 0 - fi - local url="$RUNNER_URL_BASE/forgejo-runner-nightly-$RUNNER_OS-$RUNNER_ARCH" - echo " runner: downloading $url" - curl -fsSL -o "$RUNNER_BIN.tmp" "$url" - chmod +x "$RUNNER_BIN.tmp" - mv "$RUNNER_BIN.tmp" "$RUNNER_BIN" + mkdir -p "$RUNNER_DIR" + case "$RUNNER_OS" in + darwin) + if command -v act_runner >/dev/null 2>&1; then + RUNNER_BIN="$(command -v act_runner)" + echo " runner: using $RUNNER_BIN (already installed)" + return 0 + fi + if ! command -v brew >/dev/null 2>&1; then + echo " runner: Homebrew required on macOS — install from https://brew.sh" >&2 + return 1 + fi + echo " runner: installing via Homebrew (act_runner)" + brew install act_runner + RUNNER_BIN="$(command -v act_runner)" + ;; + linux) + RUNNER_BIN="$HOME/.local/bin/forgejo-runner" + mkdir -p "$(dirname "$RUNNER_BIN")" + if [[ -x "$RUNNER_BIN" ]]; then + echo " runner: binary already present at $RUNNER_BIN" + return 0 + fi + # Latest tagged release (not "nightly" — that download path doesn't exist). + local latest url + latest=$(curl -fsSL "https://code.forgejo.org/api/v1/repos/forgejo/runner/releases/latest" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])") + latest="${latest#v}" + url="https://code.forgejo.org/forgejo/runner/releases/download/v${latest}/forgejo-runner-${latest}-${RUNNER_OS}-${RUNNER_ARCH}" + echo " runner: downloading $url" + curl -fsSL -o "$RUNNER_BIN.tmp" "$url" + chmod +x "$RUNNER_BIN.tmp" + mv "$RUNNER_BIN.tmp" "$RUNNER_BIN" + ;; + *) + echo " runner: unknown RUNNER_OS=$RUNNER_OS" >&2 + return 1 + ;; + esac echo " runner: installed → $RUNNER_BIN" } diff --git a/scripts/run/common.sh b/scripts/run/common.sh index e865cf14..309ab079 100644 --- a/scripts/run/common.sh +++ b/scripts/run/common.sh @@ -76,3 +76,20 @@ esac GAME_DIR="$REPO_ROOT/src/game" SIMULATOR_DIR="$REPO_ROOT/src/simulator" GUIDE_DIR="$REPO_ROOT/public/games/age-of-dwarves/guide" + +# ── Optional-tool detection helper ────────────────────────────────── +# Used across lint.sh / test.sh / verify.sh. 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 +} diff --git a/scripts/run/dev.sh b/scripts/run/dev.sh index 763bedab..00ba7c3e 100644 --- a/scripts/run/dev.sh +++ b/scripts/run/dev.sh @@ -1,5 +1,14 @@ #!/usr/bin/env bash -# Dev commands: play, editor, lint, format, test, verify, screenshot +# Dev subcommands: play, editor, guide, screenshot. +# +# Previously this file was 546L conflating lint/format/test/verify/autoplay/ +# dev-server into one module. It's now split: +# - lint.sh → cmd_lint, cmd_lint_gd/rust/ts, cmd_typecheck +# - format.sh → cmd_format, cmd_format_gd/rust/ts +# - test.sh → cmd_test, cmd_test_golden, cmd_coverage, cmd_validate +# - verify.sh → cmd_verify + _verify_* helpers +# - autoplay.sh → cmd_autoplay, cmd_autoplay_batch +# This file now only owns actually-"dev" workflows (launching the game/editor/guide). cmd_play() { local LOG_FILE="$REPO_ROOT/.project/logs/game_$(date +%Y%m%d_%H%M%S).log" @@ -24,523 +33,11 @@ cmd_editor() { $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=15 - - # Step 0 — Game data schema validation - _verify_step 0 $TOTAL "game data JSON schemas" \ - python3 "$REPO_ROOT/tools/validate-game-data.py" - - # Step 1 — i18n: no hardcoded user-visible strings outside ThemeVocabulary - _verify_step 1 $TOTAL "i18n: no hardcoded UI strings" \ - python3 "$REPO_ROOT/tools/validate-i18n.py" - - # Step 2 — 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 2 $TOTAL "objectives dashboard up-to-date" \ - python3 "$REPO_ROOT/tools/objectives-report.py" --check - - # Step 3 — Rust build - _verify_step 3 $TOTAL "cargo build --workspace" \ - _verify_run_in_dir "$SIMULATOR_DIR" cargo build --workspace - - # Step 4 — Rust tests (prefer nextest) - _verify_step 4 $TOTAL "cargo test --workspace" \ - _cargo_test_workspace - - # Step 5 — Rust clippy - _verify_step 5 $TOTAL "cargo clippy --workspace -D warnings" \ - _verify_run_in_dir "$SIMULATOR_DIR" cargo clippy --workspace -- -D warnings - - # Step 6 — Rust dead-deps scan (optional: cargo-machete) - _verify_step 6 $TOTAL "cargo machete (dead deps)" \ - _verify_machete - - # Step 7 — Rust advisories + license check (optional: cargo-deny) - _verify_step 7 $TOTAL "cargo deny check" \ - _verify_deny - - # Step 8 — Rust docs build (warnings are hard errors) - _verify_step 8 $TOTAL "cargo doc --no-deps --workspace" \ - _verify_run_in_dir "$SIMULATOR_DIR" \ - env RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace - - # Step 9 — 500-LOC hard cap across languages - _verify_step 9 $TOTAL "file-size 500-LOC cap (.rs/.gd/.ts)" \ - _verify_file_size_cap - - # Step 10 — TS workspace typecheck (pnpm -r) - _verify_step 10 $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 11 — GDScript lint: engine/src/ - _verify_step 11 $TOTAL "gdlint engine/src/" \ - gdlint "$GAME_DIR/engine/src/" - - # Step 12 — GDScript lint: scenes/tests/ - _verify_step 12 $TOTAL "gdlint engine/scenes/tests/" \ - gdlint "$GAME_DIR/engine/scenes/tests/" - - # Step 13 — GDScript lint: tests/integration/ - _verify_step 13 $TOTAL "gdlint engine/tests/integration/" \ - gdlint "$GAME_DIR/engine/tests/integration/" - - # Step 14 — Godot headless boot: GDExtension + script compilation - _verify_step 14 $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" +cmd_screenshot() { + "$REPO_ROOT/tools/screenshot.sh" "$@" } diff --git a/src/game/engine/src/generation/auto_play.gd b/src/game/engine/src/generation/auto_play.gd index 04a10c5f..f4bb8062 100644 --- a/src/game/engine/src/generation/auto_play.gd +++ b/src/game/engine/src/generation/auto_play.gd @@ -1591,9 +1591,11 @@ func _try_attack_adjacent(unit: Variant, game_map: RefCounted) -> void: var dist: int = HexUtilsScript.hex_distance(unit.position, c.position) if dist <= 1: print(" ATTACKING CITY: %s at %s -> city at %s (dist=%d)" % [unit.type_id, unit.position, c.position, dist]) + var _t_ac: int = Time.get_ticks_msec() var resolver_script: GDScript = load("res://engine/src/modules/combat/combat_resolver.gd") var resolver: RefCounted = resolver_script.new() resolver.resolve(unit, c, game_map, all_units) + print(" POST-ATTACK-CITY total=%dms" % (Time.get_ticks_msec() - _t_ac)) unit.movement_remaining = 0 return diff --git a/src/game/engine/src/modules/combat/combat_resolver.gd b/src/game/engine/src/modules/combat/combat_resolver.gd index 8b39209d..bb8c58a0 100644 --- a/src/game/engine/src/modules/combat/combat_resolver.gd +++ b/src/game/engine/src/modules/combat/combat_resolver.gd @@ -15,9 +15,30 @@ const ItemSystemScript = preload("res://engine/src/modules/management/item_syste ## Base XP for participating in combat (matches mc-combat BASE_COMBAT_XP). const XP_ATTACKER_BASE: int = 5 +## Diagnostic breadcrumb trace. Leave false in release; flip true to print +## enter/exit markers + per-stage timing on every combat path. Keeps future +## hang investigations one constant-flip away from answers instead of +## requiring fresh instrumentation each time (loop13 2026-04-17 post-mortem). +const DEBUG_COMBAT_TRACE: bool = true + var infusion_system: RefCounted = null ## Optional: set for kill tracking (Soul Eater) +static func _trace(msg: String) -> void: + if DEBUG_COMBAT_TRACE: + print("[CR] " + msg) + + +static func _label(obj: RefCounted) -> String: + if obj == null: + return "null" + if obj is UnitScript: + return "%s(p%d,@%s)" % [obj.type_id, obj.owner, obj.position] + if obj is CityScript: + return "city(%s,p%d,@%s,hp=%d)" % [obj.city_name, obj.owner, obj.position, obj.hp] + return "?" + + ## Fail-fast resolver accessor — crashes if GDExtension not loaded. func _get_resolver() -> RefCounted: assert( @@ -37,14 +58,23 @@ func resolve( game_map: RefCounted, all_units: Array, ) -> Dictionary: + _trace("ENTER resolve: atk=%s def=%s" % [_label(attacker), _label(defender)]) + var t_total: int = Time.get_ticks_msec() EventBus.combat_started.emit(attacker, defender) + _trace(" combat_started emitted (%dms)" % (Time.get_ticks_msec() - t_total)) var resolver: RefCounted = _get_resolver() + var t_build: int = Time.get_ticks_msec() var a_dict: Dictionary = _build_unit_dict(attacker, game_map, all_units) var d_dict: Dictionary = _build_defender_dict(defender, game_map, all_units) var ctx: Dictionary = _build_context(attacker, defender, all_units) + _trace(" dicts built (%dms)" % (Time.get_ticks_msec() - t_build)) + var t_rust: int = Time.get_ticks_msec() var result: Dictionary = resolver.resolve(a_dict, d_dict, ctx) + var dt_rust: int = Time.get_ticks_msec() - t_rust + if DEBUG_COMBAT_TRACE or dt_rust > 100: + _trace(" RUST resolve done (%dms)%s" % [dt_rust, " SLOW!" if dt_rust > 100 else ""]) ## Rust always returns captured=false; compute it here from city_hp_remaining. ## Capture requires: city HP hit 0, attacker is non-ranged (melee/siege). @@ -53,9 +83,13 @@ func resolve( if rem_hp <= 0 and ctx.get("combat_type", "") != "ranged": result["captured"] = true + var t_apply: int = Time.get_ticks_msec() _apply_resolve_results(attacker, defender, result, all_units) + _trace(" apply_resolve_results done (%dms)" % (Time.get_ticks_msec() - t_apply)) + var t_emit: int = Time.get_ticks_msec() EventBus.combat_resolved.emit(attacker, defender, result) + _trace(" combat_resolved emitted (%dms)" % (Time.get_ticks_msec() - t_emit)) if attacker.is_alive() and attacker is UnitScript: ItemSystemScript.decrement_combat_charges(attacker) @@ -66,6 +100,7 @@ func resolve( if defender.stimulant_penalty > 0: defender.stimulant_penalty = 0 + _trace("EXIT resolve total=%dms" % (Time.get_ticks_msec() - t_total)) return result @@ -137,10 +172,12 @@ func _apply_resolve_results( defender_killed = int(result.get("city_hp_remaining", 1)) <= 0 if defender_killed and defender_is_unit: + _trace(" defender_killed unit death: %s" % _label(defender)) CombatUtilsScript.handle_unit_death(defender, attacker, all_units) if infusion_system != null: infusion_system.on_unit_killed(attacker) if attacker_killed: + _trace(" attacker_killed unit death: %s" % _label(attacker)) CombatUtilsScript.handle_unit_death(attacker, defender, all_units) if infusion_system != null: infusion_system.on_unit_killed(defender) @@ -156,6 +193,7 @@ func _apply_resolve_results( CombatUtilsScript.handle_unit_death(attacker, defender, all_units) if result.get("captured", false) and defender is CityScript: + _trace(" CAPTURE city: %s" % _label(defender)) CombatUtilsScript.capture_city(defender, attacker, defender.owner, all_units) var attack_xp: int = int(result.get("attacker_xp", 0)) diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 2ca3d38c..947f6d71 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -714,10 +714,12 @@ version = "0.1.0" dependencies = [ "bytemuck", "mc-core", + "pollster", "rayon", "serde", "serde_json", "thiserror 1.0.69", + "wgpu", ] [[package]]