magicciv/tools/ai-arena.sh

292 lines
12 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# AI Arena: launch N concurrent 1v1 AI battles, tile them in a grid on the
# active monitor, label each window, and collect per-match JSON results.
#
# Each Godot instance runs the existing world_map scene in spectator mode
# (driven by AI_ARENA env vars consumed in main.gd / loading_screen.gd /
# turn_manager.gd / world_map.gd). When the match ends (domination, score
# at turn limit, or wall-clock timeout), the in-game writer drops a JSON
# result file in $RESULTS_DIR/<match_id>.json.
#
# Usage: ./tools/ai-arena.sh [OPTIONS]
# -n NUM Number of matches (default 4, soft cap 12)
# -t TURN_LIMIT Hard turn limit per match (default 150)
# -d DELAY Seconds between turns, controls spectating speed (default 0.3)
# -s SEEDS Comma-separated explicit seeds (default: random per match)
# -o DIR Output directory (default /tmp/ai-arena/run_<timestamp>)
# -h Print help
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
GAME_DIR="$PROJECT_DIR/src/game"
# ── Defaults ─────────────────────────────────────────────────────────
N=4
TURN_LIMIT=150
TURN_DELAY="0.3"
SEED_LIST=""
RESULTS_DIR=""
usage() {
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
while getopts "n:t:d:s:o:h" opt; do
case "$opt" in
n) N="$OPTARG" ;;
t) TURN_LIMIT="$OPTARG" ;;
d) TURN_DELAY="$OPTARG" ;;
s) SEED_LIST="$OPTARG" ;;
o) RESULTS_DIR="$OPTARG" ;;
h|*) usage ;;
esac
done
if ! [[ "$N" =~ ^[0-9]+$ ]] || [ "$N" -lt 1 ]; then
echo "ERROR: -n must be a positive integer (got '$N')" >&2
exit 2
fi
if [ "$N" -gt 12 ]; then
echo "WARNING: launching $N instances — system load may be heavy. Sleeping 3s..." >&2
sleep 1
fi
if ! [[ "$TURN_LIMIT" =~ ^[0-9]+$ ]] || [ "$TURN_LIMIT" -lt 1 ]; then
echo "ERROR: -t must be a positive integer (got '$TURN_LIMIT')" >&2
exit 2
fi
# ── Primary monitor detection ────────────────────────────────────────
# Must target the PRIMARY monitor specifically, not whichever monitor
# the mouse happens to be on. Multi-monitor setups often have the
# "focused" monitor differ from the primary. GNOME Mutter on Wayland
# also tends to place new windows at mouse focus by default — we beat
# that by computing absolute coordinates on the primary monitor and
# passing them as --position hints to every launched instance.
#
# Delegated to `tools/detect-primary-monitor.py` which reads GNOME
# Mutter's DisplayConfig DBus (preferred) with an xrandr fallback.
PRIMARY=$("$SCRIPT_DIR/detect-primary-monitor.py" 2>/dev/null)
if [ -z "$PRIMARY" ]; then
PRIMARY="3440x1440+0+0"
echo "WARNING: could not detect primary monitor, defaulting to $PRIMARY" >&2
fi
if [[ "$PRIMARY" =~ ^([0-9]+)x([0-9]+)\+([0-9]+)\+([0-9]+)$ ]]; then
SCREEN_W=${BASH_REMATCH[1]}
SCREEN_H=${BASH_REMATCH[2]}
PRIMARY_X=${BASH_REMATCH[3]}
PRIMARY_Y=${BASH_REMATCH[4]}
else
echo "ERROR: could not parse primary monitor geometry '$PRIMARY'" >&2
exit 3
fi
# ── Grid layout math ─────────────────────────────────────────────────
# Pick COLS so each cell is roughly the screen aspect ratio. ROWS = ceil(N/COLS).
# Floats kept simple via integer math: COLS ≈ round(sqrt(N * W / H)).
compute_grid() {
local n=$1 w=$2 h=$3
local cols
cols=$(awk "BEGIN { c = sqrt($n * $w / $h); printf \"%d\", (c < 1 ? 1 : c + 0.5) }")
if [ "$cols" -lt 1 ]; then cols=1; fi
if [ "$cols" -gt "$n" ]; then cols=$n; fi
local rows=$(( (n + cols - 1) / cols ))
echo "$cols $rows"
}
read -r COLS ROWS < <(compute_grid "$N" "$SCREEN_W" "$SCREEN_H")
CELL_W=$(( SCREEN_W / COLS ))
CELL_H=$(( SCREEN_H / ROWS ))
# ── Output directory ─────────────────────────────────────────────────
# IMPORTANT: must live under $HOME so that the sandboxed Godot Flatpak
# can write to it. Flatpak gives the app its own private /tmp that does
# NOT map to the host /tmp; result files written from inside the sandbox
# to /tmp/<anything> silently disappear and matches all look CRASHED.
# $HOME is shared (filesystems=host), so writes there land on the host.
if [ -z "$RESULTS_DIR" ]; then
RESULTS_DIR="$HOME/tmp/ai-arena/run_$(date +%Y%m%d_%H%M%S)"
fi
mkdir -p "$RESULTS_DIR"
cat >"$RESULTS_DIR/_metadata.json" <<EOF
{
"started_at": "$(date -Iseconds)",
"num_matches": $N,
"turn_limit": $TURN_LIMIT,
"turn_delay": $TURN_DELAY,
"screen": "${SCREEN_W}x${SCREEN_H}",
"grid": "${COLS}x${ROWS}",
"cell": "${CELL_W}x${CELL_H}"
}
EOF
# ── Dwarf name pool ──────────────────────────────────────────────────
DWARF_NAMES=(
"Ironforge" "Steelhaven" "Grimhold" "Copperdeep" "Embervault"
"Stonewatch" "Runeheim" "Hammerfall" "Anvilcrest" "Goldpeak"
"Brasskeep" "Drakestone" "Mithrilward" "Cobalthall" "Thorngate"
"Deepforge" "Crystalholm" "Ashenmount" "Frostbeard" "Granitewall"
)
# Shuffle pool deterministically per run via shuf with a seed-derived input.
mapfile -t SHUFFLED < <(printf "%s\n" "${DWARF_NAMES[@]}" | shuf)
# ── Seeds ────────────────────────────────────────────────────────────
declare -a SEEDS
if [ -n "$SEED_LIST" ]; then
IFS=',' read -ra SEEDS <<<"$SEED_LIST"
fi
for ((i = 0; i < N; i++)); do
if [ -z "${SEEDS[i]:-}" ]; then
SEEDS[i]=$(( (RANDOM * 32768 + RANDOM) % 1000000 + 1 ))
fi
done
# ── Banner ───────────────────────────────────────────────────────────
echo "============================================================"
echo "AI Arena: $N matches on primary ${SCREEN_W}x${SCREEN_H} @ (${PRIMARY_X},${PRIMARY_Y})"
echo "Grid: ${COLS}x${ROWS}, cell ${CELL_W}x${CELL_H}"
echo "Turn limit: $TURN_LIMIT | Turn delay: ${TURN_DELAY}s"
echo "Results: $RESULTS_DIR"
echo "============================================================"
# ── Launch loop ──────────────────────────────────────────────────────
declare -a PIDS
declare -a MATCH_IDS
for ((i = 0; i < N; i++)); do
ROW=$(( i / COLS ))
COL=$(( i % COLS ))
# Offset grid cells by the primary monitor's virtual-desktop origin
# so windows land on the primary regardless of the mouse-focused
# monitor. --position is a hint Wayland compositors may reinterpret,
# but on the primary's own coordinate range it's unambiguous.
POS_X=$(( PRIMARY_X + COL * CELL_W ))
POS_Y=$(( PRIMARY_Y + ROW * CELL_H ))
MATCH_ID=$(printf "match_%02d" "$i")
SEED=${SEEDS[i]}
P1=${SHUFFLED[$(( (i * 2) % ${#SHUFFLED[@]} ))]}
P2=${SHUFFLED[$(( (i * 2 + 1) % ${#SHUFFLED[@]} ))]}
echo "[launch] $MATCH_ID seed=$SEED pos=($POS_X,$POS_Y) $P1 vs $P2"
# Always run arena instances under XWayland, never native Wayland:
# - --position is a reliable X11 hint; on native Wayland, Mutter
# ignores it and places new windows at mouse focus, which ruins
# the tiled-grid arrangement
# - host-level screenshot tooling (ffmpeg x11grab) can see the
# windows via the XWayland display
# - rendering perf difference is negligible for our use case
# Flatpak manifests include --socket=wayland by default; we revoke
# it with --nosocket=wayland and grant --socket=x11 explicitly so
# the sandbox gets XWayland and only XWayland.
XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \
DISPLAY="${DISPLAY:-:0}" \
flatpak run --user \
--nosocket=wayland \
--socket=x11 \
--env=AI_ARENA=true \
--env=AI_ARENA_MATCH_ID="$MATCH_ID" \
--env=AI_ARENA_SEED="$SEED" \
--env=AI_ARENA_TURN_LIMIT="$TURN_LIMIT" \
--env=AI_ARENA_TURN_DELAY="$TURN_DELAY" \
--env=AI_ARENA_RESULTS_DIR="$RESULTS_DIR" \
--env=AI_ARENA_P1_NAME="$P1" \
--env=AI_ARENA_P2_NAME="$P2" \
--env=AI_ARENA_SCREENSHOT_TURN="${AI_ARENA_SCREENSHOT_TURN:-0}" \
org.godotengine.Godot \
--path "$GAME_DIR" \
--rendering-method gl_compatibility \
--windowed \
--resolution "${CELL_W}x${CELL_H}" \
--position "${POS_X},${POS_Y}" \
>"$RESULTS_DIR/${MATCH_ID}.log" 2>&1 &
PIDS[i]=$!
MATCH_IDS[i]=$MATCH_ID
# Stagger launches to avoid map-gen + GDExtension load thundering herd.
sleep 0.4
done
# ── Wait for completion (with safety timeout) ───────────────────────
# Action playback (camera pans + animation per turn) adds ~6s avg to each
# turn: ~15 actions/turn × 0.4s/action. Allow TURN_DELAY + 6s per turn,
# doubled for safety, plus 5min absolute buffer.
SAFETY_TIMEOUT=$(awk "BEGIN { printf \"%d\", $TURN_LIMIT * ($TURN_DELAY + 6.0) * 2 + 300 }")
echo ""
echo "[wait] Up to ${SAFETY_TIMEOUT}s for all matches to finish..."
WAIT_START=$(date +%s)
for ((i = 0; i < N; i++)); do
pid=${PIDS[i]}
elapsed=$(( $(date +%s) - WAIT_START ))
remaining=$(( SAFETY_TIMEOUT - elapsed ))
if [ "$remaining" -le 0 ]; then
kill "$pid" 2>/dev/null || true
continue
fi
# Poll until process exits or our budget runs out.
while kill -0 "$pid" 2>/dev/null; do
if [ $(( $(date +%s) - WAIT_START )) -ge "$SAFETY_TIMEOUT" ]; then
echo "[timeout] killing ${MATCH_IDS[i]} (pid $pid)"
kill "$pid" 2>/dev/null || true
sleep 0.5
kill -9 "$pid" 2>/dev/null || true
break
fi
sleep 1
done
done
# ── Collect + summarize ─────────────────────────────────────────────
echo ""
echo "============================================================"
echo "AI Arena Results"
echo "============================================================"
printf "%-12s %-14s %-14s %-14s %-6s %-12s\n" "Match" "P1" "P2" "Winner" "Turn" "Type"
echo "------------------------------------------------------------"
HAS_JQ=0
if command -v jq >/dev/null 2>&1; then
HAS_JQ=1
fi
COMPLETED=0
CRASHED=0
for ((i = 0; i < N; i++)); do
MATCH_ID=${MATCH_IDS[i]}
RESULT="$RESULTS_DIR/${MATCH_ID}.json"
if [ ! -f "$RESULT" ]; then
printf "%-12s %-14s %-14s %-14s %-6s %-12s\n" "$MATCH_ID" "?" "?" "CRASHED" "-" "-"
CRASHED=$((CRASHED + 1))
continue
fi
if [ "$HAS_JQ" -eq 1 ]; then
# A draw serializes as winner_name="" + victory_type="draw" (or
# winner_index=-1). Any of those signals should render "DRAW" in
# the Winner column — checking `.winner_name == ""` is the cheapest
# invariant; the game enforces it in _build_result_dict.
line=$(jq -r '[
.match_id,
.players[0].name,
.players[1].name,
(if (.winner_name // "") == "" then "DRAW" else .winner_name end),
.final_turn,
.victory_type
] | @tsv' "$RESULT")
IFS=$'\t' read -r mid p1 p2 win turn type <<<"$line"
printf "%-12s %-14s %-14s %-14s %-6s %-12s\n" "$mid" "$p1" "$p2" "$win" "$turn" "$type"
else
printf "%-12s %s\n" "$MATCH_ID" "(install jq for parsed view; raw at $RESULT)"
fi
COMPLETED=$((COMPLETED + 1))
done
echo "------------------------------------------------------------"
echo "Completed: $COMPLETED | Crashed/timeout: $CRASHED | Results: $RESULTS_DIR"
echo "============================================================"
[ "$CRASHED" -eq 0 ]