#!/usr/bin/env bash # Generic external-player JSON-Lines harness for Magic Civilization. # # Spawns Godot headless with the `player_api_main` scene and pipes # stdin/stdout. Any client that speaks the JSON-Lines wire protocol # documented in `src/game/engine/docs/PLAYER_API.md` can drive a game # slot through this harness — Claude Code via the # `tooling/claude-player-mcp/` adapter, an OpenSpiel/RL trainer via a # Python `subprocess.Popen`, a smoke-test shell script, etc. # # Env vars (see PLAYER_API.md for the full schema): # CP_SEED, CP_PLAYERS, CP_PLAYER_SLOT, CP_MAP_SIZE, CP_MAP_TYPE, # CP_OMNISCIENT, CP_TIMEOUT_SEC, CP_LOG_FILE. # # `CP_PLAYER_SLOT` is the env-var name the harness has used since p2-67; # it identifies the externally-controlled player slot — kept as-is for # backward compatibility with existing clients. Despite the name it is # not Claude-specific. # # Exit code 0 on clean shutdown (shutdown request received or stdin EOF). # Non-zero on protocol error or harness crash. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # Wrap godot in heavy-tests.slice on Linux so a fleet of player_api workers # (typical RL training spawns 16-64) cannot starve sshd / interactive work. # See scripts/run/heavy-prefix.sh and ~/.config/systemd/user/heavy-tests.slice. # shellcheck source=run/heavy-prefix.sh source "${SCRIPT_DIR}/run/heavy-prefix.sh" # Defaults — adapter overrides via env. : "${CP_SEED:=42}" : "${CP_PLAYERS:=2}" : "${CP_PLAYER_SLOT:=0}" : "${CP_MAP_SIZE:=duel}" : "${CP_MAP_TYPE:=continents}" : "${CP_OMNISCIENT:=0}" : "${CP_TIMEOUT_SEC:=60}" : "${CP_LOG_FILE:=}" # Pure-headless mode — no Wayland needed. JSON-Lines speaks to stdout. # # Linux uses flatpak Godot (matches the rest of the apricot pipeline). # macOS uses the locally-installed `godot` binary (Homebrew); a parallel # flatpak runtime just for this harness is silly when native Godot 4 # works directly. Env-var passthrough is automatic for the native path. export CP_SEED CP_PLAYERS CP_PLAYER_SLOT CP_MAP_SIZE CP_MAP_TYPE \ CP_OMNISCIENT CP_TIMEOUT_SEC CP_LOG_FILE CP_VICTORY_MODE \ CP_PLAYER_CONTROLLERS CP_PLAYER_SLOTS \ MC_DATA_ROOT MC_LEARNED_POLICY_PATH case "$(uname -s)" in Darwin) GODOT_BIN="${GODOT_BIN:-godot}" if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then echo "ERROR: no godot binary on PATH (set GODOT_BIN or 'brew install godot')." >&2 exit 1 fi exec "$GODOT_BIN" \ --path "$PROJECT_DIR/src/game" \ --headless \ --rendering-method gl_compatibility \ res://engine/scenes/headless/player_api_main.tscn ;; *) # Build the flatpak invocation once (DRY across the slice / no-slice # dispatch below). FLATPAK_CMD=(flatpak run --user --env=CP_SEED="$CP_SEED" --env=CP_PLAYERS="$CP_PLAYERS" --env=CP_PLAYER_SLOT="$CP_PLAYER_SLOT" --env=CP_MAP_SIZE="$CP_MAP_SIZE" --env=CP_MAP_TYPE="$CP_MAP_TYPE" --env=CP_OMNISCIENT="$CP_OMNISCIENT" --env=CP_TIMEOUT_SEC="$CP_TIMEOUT_SEC" --env=CP_LOG_FILE="$CP_LOG_FILE" --env=CP_VICTORY_MODE="${CP_VICTORY_MODE:-}" --env=CP_PLAYER_CONTROLLERS="${CP_PLAYER_CONTROLLERS:-}" --env=CP_PLAYER_SLOTS="${CP_PLAYER_SLOTS:-}" --env=MC_DATA_ROOT="${MC_DATA_ROOT:-}" --env=MC_LEARNED_POLICY_PATH="${MC_LEARNED_POLICY_PATH:-}" org.godotengine.Godot --path "$PROJECT_DIR/src/game" --headless --rendering-method gl_compatibility res://engine/scenes/headless/player_api_main.tscn) # CP_NO_SLICE=1 runs Godot as a DIRECT child (no per-process systemd # scope) so the spawning client reaps the whole process group on # shutdown via os.killpg. The default `heavy_exec` path wraps each # Godot in its own `systemd-run --scope` unit (commit 8cb522c3c, # 2026-05-26) which orphans the Godot when the client kills only the # script PID — leaking one Godot per episode reset (gen0 contention # death, 2026-06-09; duel-v4 predates the scope wrapping and so never # leaked). RL trainers that contain resources at their OWN unit level # (CPUWeight/MemoryHigh on the train service) set CP_NO_SLICE=1 to get # back the deterministically-reaped model. Interactive / batch callers # leave it unset to keep the slice's sshd-preemption guarantee. if [ "${CP_NO_SLICE:-0}" = "1" ]; then exec "${FLATPAK_CMD[@]}" fi heavy_exec "player-api-$$" "${FLATPAK_CMD[@]}" ;; esac