fix(@projects/@magic-civilization): 🐛 update forgejo runner installation logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 12:35:54 -07:00
parent 3ee2b60b11
commit ad4fb44390
7 changed files with 118 additions and 529 deletions

View file

@ -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": [

View file

@ -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"
}

View file

@ -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
}

View file

@ -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" "$@"
}

View file

@ -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

View file

@ -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))

View file

@ -714,10 +714,12 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"mc-core",
"pollster",
"rayon",
"serde",
"serde_json",
"thiserror 1.0.69",
"wgpu",
]
[[package]]