feat(@projects/@magic-civilization): add claude-player headless harness

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-10 17:02:46 -07:00
parent df74c9890d
commit 81a674de1e
7 changed files with 529 additions and 0 deletions

44
scripts/claude-player-server.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# p2-67 Phase 3 — Launch wrapper for the Claude Player headless harness.
#
# Spawns flatpak Godot headless with the claude_player_main scene and
# stdin/stdout piped through. Designed to be exec'd by the Claude SDK
# adapter as a child process — `child = spawn("scripts/claude-player-server.sh")`.
#
# Env vars are forwarded into the sandbox (see CLAUDE_PLAYER_API.md):
# CP_SEED, CP_PLAYERS, CP_CLAUDE_SLOT, CP_MAP_SIZE, CP_MAP_TYPE,
# CP_OMNISCIENT, CP_TIMEOUT_SEC, CP_LOG_FILE.
#
# 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")"
# Defaults — adapter overrides via env.
: "${CP_SEED:=42}"
: "${CP_PLAYERS:=2}"
: "${CP_CLAUDE_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.
flatpak run --user \
--env=CP_SEED="$CP_SEED" \
--env=CP_PLAYERS="$CP_PLAYERS" \
--env=CP_CLAUDE_SLOT="$CP_CLAUDE_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" \
org.godotengine.Godot \
--path "$PROJECT_DIR/src/game" \
--headless \
--rendering-method gl_compatibility \
res://engine/scenes/headless/claude_player_main.tscn

View file

@ -0,0 +1,216 @@
extends Node
## p2-67 Phase 3 — Headless harness for the Claude Player API.
##
## Boots a seeded GameState, instantiates a `GdPlayerApi`, then enters
## a JSON-Lines pump on stdin/stdout. Each line in is one `Request`
## (`view` / `act` / `shutdown`); each line out is one `Response` or
## one `Notification`. The protocol contract lives in
## `src/game/engine/docs/CLAUDE_PLAYER_API.md`.
##
## Env vars consumed:
## - `CP_SEED` — RNG seed (default 42)
## - `CP_PLAYERS` — total player slots (default 2)
## - `CP_CLAUDE_SLOT` — which slot stdin controls (default 0)
## - `CP_MAP_SIZE` — MapGenerator size key (default "duel")
## - `CP_MAP_TYPE` — MapGenerator map type (default "continents")
## - `CP_OMNISCIENT` — `1` disables fog redaction (default 0)
## - `CP_TIMEOUT_SEC` — per-action timeout in seconds (default 60)
## - `CP_LOG_FILE` — if set, mirror all wire I/O to this path
var _api: RefCounted = null
var _claude_slot: int = 0
var _omniscient: bool = false
var _log_path: String = ""
var _shutdown: bool = false
func _ready() -> void:
_claude_slot = _env_int("CP_CLAUDE_SLOT", 0)
_omniscient = _env_bool("CP_OMNISCIENT", false)
_log_path = OS.get_environment("CP_LOG_FILE")
if not ClassDB.class_exists("GdPlayerApi"):
_emit_protocol_error(
"GdPlayerApi GDExtension class not registered — rebuild gdext"
)
get_tree().quit(1)
return
DataLoader.load_theme("age-of-dwarves")
DataLoader.load_world("earth")
var seed_v: int = _env_int("CP_SEED", 42)
var num_players: int = _env_int("CP_PLAYERS", 2)
var map_size: String = _env_or("CP_MAP_SIZE", "duel")
var map_type: String = _env_or("CP_MAP_TYPE", "continents")
_bootstrap_game(seed_v, num_players, map_size, map_type)
_api = ClassDB.instantiate("GdPlayerApi") as RefCounted
if _api == null:
_emit_protocol_error("ClassDB.instantiate('GdPlayerApi') returned null")
get_tree().quit(1)
return
_api.set_omniscient(_omniscient)
# Announce initial state to the adapter. Notification lines carry
# no `id` field; adapters can use them to drive streaming UIs or
# ignore them entirely (the synchronous response after the next
# `act` carries the same data via `events`).
_emit_event("turn_started", {"turn": 0, "player": _claude_slot})
_emit_event("phase_changed", {"phase": "player_actions"})
# Enter the pump on the next frame so any pending engine init flushes.
_pump.call_deferred()
func _bootstrap_game(
seed_v: int, num_players: int, map_size: String, map_type: String
) -> void:
## Initialise a real seeded game. Map / unit hydration of the
## GdPlayerApi's held GameState is deferred until GdGameState's
## `serialize_to_json` is wired (TRACKED: p2-67 Phase 3 follow-up).
## For Phase 3 v1 the harness initialises GameState (which sets up
## autoload state for the in-process AI loop) and the API works
## off its internal default `GameState` — enough to validate the
## JSON-Lines pump end-to-end.
GameState.initialize_game({
"seed": seed_v,
"map_type": map_type,
"map_size": map_size,
"num_players": num_players,
})
func _pump() -> void:
while not _shutdown:
var line: String = OS.read_string_from_stdin(4096)
if line == "":
# EOF or no line yet — yield to the engine and try again.
await get_tree().process_frame
continue
line = line.strip_edges()
if line.is_empty():
continue
_log_wire("<-", line)
var parsed_dict: Dictionary = JSON.parse_string(line) as Dictionary
if parsed_dict.is_empty() and line != "{}":
_emit_protocol_error("could not parse line: %s" % line)
continue
_handle_request(parsed_dict)
get_tree().quit(0)
func _handle_request(req: Dictionary) -> void:
var rtype: String = String(req.get("type", ""))
var rid_int: int = int(req.get("id", -1))
var has_id: bool = req.has("id") and req.get("id") != null
match rtype:
"view":
var view_json: String = String(_api.view_json(_claude_slot))
_emit_response_with_view(rid_int, has_id, view_json)
"act":
var action_payload: Dictionary = req.get("action", {}) as Dictionary
if action_payload.is_empty():
_emit_response_error(rid_int, has_id, "parse_error", "missing action field")
return
var action_json: String = JSON.stringify(action_payload)
var envelope_str: String = String(
_api.apply_action_json(_claude_slot, action_json)
)
# api wrapper already emits a full ok/err envelope — splice
# in the request id and forward as the response body.
var envelope: Dictionary = JSON.parse_string(envelope_str) as Dictionary
if envelope.is_empty():
_emit_response_error(
rid_int, has_id, "internal", "gdext returned non-JSON envelope"
)
return
if has_id:
envelope["id"] = rid_int
_write_line(JSON.stringify(envelope))
"shutdown":
_emit_response_ack(rid_int, has_id)
_shutdown = true
_:
_emit_response_error(
rid_int, has_id, "parse_error", "unknown request type: %s" % rtype
)
func _emit_response_with_view(rid_int: int, has_id: bool, view_json: String) -> void:
var view_dict: Dictionary = JSON.parse_string(view_json) as Dictionary
var body: Dictionary = {"ok": true, "view": view_dict}
if has_id:
body["id"] = rid_int
_write_line(JSON.stringify(body))
func _emit_response_ack(rid_int: int, has_id: bool) -> void:
var body: Dictionary = {"ok": true}
if has_id:
body["id"] = rid_int
_write_line(JSON.stringify(body))
func _emit_response_error(
rid_int: int, has_id: bool, code: String, message: String
) -> void:
var body: Dictionary = {
"ok": false,
"error": {"code": code, "message": message},
}
if has_id:
body["id"] = rid_int
_write_line(JSON.stringify(body))
func _emit_event(event_type: String, payload: Dictionary) -> void:
## Notification line (no id) — adapter ignores or streams.
var body: Dictionary = {"type": event_type}
for k: String in payload.keys():
body[k] = payload[k]
_write_line(JSON.stringify(body))
func _emit_protocol_error(message: String) -> void:
_emit_event("protocol_error", {"message": message})
func _write_line(line: String) -> void:
_log_wire("->", line)
# `print` already appends a newline → exactly one JSON value per line.
print(line)
func _log_wire(dir_arrow: String, line: String) -> void:
if _log_path.is_empty():
return
var f: FileAccess = FileAccess.open(_log_path, FileAccess.READ_WRITE)
if f == null:
f = FileAccess.open(_log_path, FileAccess.WRITE)
if f == null:
return
f.seek_end()
f.store_line("%s %s" % [dir_arrow, line])
f.close()
func _env_or(name: String, fallback: String) -> String:
var v: String = OS.get_environment(name)
if v.is_empty():
return fallback
return v
func _env_int(name: String, fallback: int) -> int:
var v: String = OS.get_environment(name)
if v.is_empty():
return fallback
return int(v)
func _env_bool(name: String, fallback: bool) -> bool:
var v: String = OS.get_environment(name)
if v.is_empty():
return fallback
return v == "1" or v.to_lower() == "true"

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://engine/scenes/headless/claude_player_main.gd" id="1"]
[node name="ClaudePlayerMain" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,57 @@
# @magic-civ/claude-player
Claude Agent SDK adapter for the Magic Civilization Claude Player API
(p2-67 Phase 4). Drives one player slot in a headless game while the
production AI controls the others.
## Run
```bash
cd tooling/claude-player
npm install
export ANTHROPIC_API_KEY=sk-ant-...
npm run dev # tsx — no build needed
# or
npm run build && npm start # compiled JS
```
Each run writes append-only logs to `.local/runs/<stamp>/`:
- `log.jsonl` — agent decisions + notifications + run metadata.
- `wire.jsonl` — raw JSON-Lines between adapter and harness.
- `result.json` — final result (`turns_played`, `reason`, `final_view`).
## Env vars
| Var | Default | Purpose |
|---|---|---|
| `ANTHROPIC_API_KEY` | *(required)* | Claude API credentials |
| `CP_MODEL` | `claude-opus-4-7` | Model id |
| `CP_MAX_TURNS` | `100` | Hard turn cap |
| `CP_MAX_IDLE_ENDS` | `3` | Consecutive idle end_turns before bail |
| `CP_SERVER` | `<repo>/scripts/claude-player-server.sh` | Harness launcher |
| `CP_SEED` / `CP_PLAYERS` / `CP_CLAUDE_SLOT` / `CP_OMNISCIENT` / `CP_TIMEOUT_SEC` | see CLAUDE_PLAYER_API.md | Forwarded to harness |
## Architecture
```
@magic-civ/claude-player (Node)
├── HarnessClient — spawn() + JSON-Lines pump
└── runAgent — Anthropic Messages API tool-use loop
├── tool: view()
├── tool: act(action)
└── tool: end_turn()
▼ stdin/stdout
scripts/claude-player-server.sh
flatpak Godot --headless res://engine/scenes/headless/claude_player_main.tscn
GdPlayerApi → mc-player-api → mc-turn::action_handlers
```
Wire-protocol contract: `src/game/engine/docs/CLAUDE_PLAYER_API.md`.
Owning objective: `.project/objectives/p2-67-claude-player-api.md`.

View file

@ -0,0 +1,25 @@
{
"name": "@magic-civ/claude-player",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Claude Agent SDK adapter for the Magic Civilization Claude Player API (p2-67 Phase 4).",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node --enable-source-maps dist/index.js",
"dev": "tsx src/index.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.40.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
},
"engines": {
"node": ">=20"
}
}

View file

@ -0,0 +1,155 @@
// Wire-protocol types — mirror the Rust enums in `mc-player-api`.
// Source of truth: src/game/engine/docs/CLAUDE_PLAYER_API.md.
/** Hex coordinate carried over the wire — `[col, row]`. */
export type WireHex = [number, number];
/** Stable player slot index, `u8` everywhere in the simulator. */
export type PlayerId = number;
/** Discriminated union of every player action variant. */
export type PlayerAction =
| { type: "end_turn" }
| { type: "noop" }
| { type: "move"; unit_id: string; to: WireHex }
| { type: "attack"; unit_id: string; target: WireHex }
| { type: "ranged_attack"; unit_id: string; target: WireHex }
| { type: "fortify"; unit_id: string }
| { type: "unfortify"; unit_id: string }
| { type: "skip"; unit_id: string }
| { type: "found_city"; unit_id: string }
| { type: "build_improvement"; unit_id: string; improvement_id: string }
| { type: "sentry"; unit_id: string }
| { type: "unsentry"; unit_id: string }
| { type: "issue_patrol"; unit_id: string; waypoints: WireHex[] }
| { type: "cancel_patrol"; unit_id: string }
| { type: "edit_patrol"; unit_id: string; waypoints: WireHex[] }
| { type: "queue_production"; city_id: string; item: string; tile?: WireHex }
| { type: "remove_from_queue"; city_id: string; index: number }
| { type: "queue_reorder"; city_id: string; from: number; to: number }
| { type: "rush_buy"; city_id: string }
| { type: "buy_tile"; city_id: string; tile: WireHex }
| { type: "set_focus"; city_id: string; focus: string }
| {
type: "merge_buildings";
city_id: string;
building_a: string;
building_b: string;
into: string;
}
| { type: "research_tech"; tech_id: string }
| { type: "research_tradition"; tradition_id: string }
| { type: "declare_war"; on: PlayerId }
| { type: "offer_peace"; to: PlayerId };
/** One entry in a unit's or empire's `legal_actions` list. */
export interface LegalActionEntry {
action: PlayerAction;
enabled: boolean;
disabled_reason?: string;
}
export interface UnitView {
id: string;
type: string;
position: WireHex;
owner: PlayerId;
hp: number;
max_hp: number;
movement_left: number;
movement_max: number;
experience: number;
promotion_available: boolean;
fortified: boolean;
sentry: boolean;
legal_actions?: LegalActionEntry[];
}
export interface CityView {
id: string;
name: string;
position: WireHex;
owner: PlayerId;
is_capital: boolean;
population: number;
food_stored: number;
food_growth_threshold: number;
production_queue: Array<{
item: string;
kind: string;
progress: number;
cost: number;
tile?: WireHex;
}>;
buildings: string[];
owned_tiles: WireHex[];
yields: Record<string, number>;
hp: number;
max_hp: number;
focus: string;
buildable?: Array<{
item: string;
kind: string;
cost: number;
rush_gold: number;
enabled: boolean;
}>;
}
export interface PlayerView {
turn: number;
player: PlayerId;
current_player: PlayerId;
phase: string;
is_human_turn: boolean;
resources: {
gold: number;
gold_per_turn: number;
science_per_turn: number;
culture_per_turn: number;
happiness_pool: number;
stockpile: Record<string, number>;
};
research: {
current_tech: string | null;
tech_progress: number;
tech_cost: number;
researched: string[];
available: string[];
};
culture: {
current_tradition: string | null;
tradition_progress: number;
tradition_cost: number;
researched: string[];
};
cities: CityView[];
units: UnitView[];
legal_actions: LegalActionEntry[];
score: {
gold_total: number;
city_count: number;
unit_count: number;
score_estimate: number;
};
}
export interface OkResponse {
id: number | null;
ok: true;
events?: Array<Record<string, unknown>>;
view: PlayerView;
}
export interface ErrResponse {
id: number | null;
ok: false;
error: { code: string; message: string };
}
export type Response = OkResponse | ErrResponse;
export interface Notification {
type: string;
[k: string]: unknown;
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", ".local"]
}