292 lines
12 KiB
Bash
Executable file
292 lines
12 KiB
Bash
Executable file
#!/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 ]
|