From 81a674de1e875c43932b877a17d612ae08f77214 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 10 May 2026 17:02:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20claude-player=20headless=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- scripts/claude-player-server.sh | 44 ++++ .../scenes/headless/claude_player_main.gd | 216 ++++++++++++++++++ .../scenes/headless/claude_player_main.tscn | 6 + tooling/claude-player/README.md | 57 +++++ tooling/claude-player/package.json | 25 ++ tooling/claude-player/src/types.ts | 155 +++++++++++++ tooling/claude-player/tsconfig.json | 26 +++ 7 files changed, 529 insertions(+) create mode 100755 scripts/claude-player-server.sh create mode 100644 src/game/engine/scenes/headless/claude_player_main.gd create mode 100644 src/game/engine/scenes/headless/claude_player_main.tscn create mode 100644 tooling/claude-player/README.md create mode 100644 tooling/claude-player/package.json create mode 100644 tooling/claude-player/src/types.ts create mode 100644 tooling/claude-player/tsconfig.json diff --git a/scripts/claude-player-server.sh b/scripts/claude-player-server.sh new file mode 100755 index 00000000..8c35bac5 --- /dev/null +++ b/scripts/claude-player-server.sh @@ -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 diff --git a/src/game/engine/scenes/headless/claude_player_main.gd b/src/game/engine/scenes/headless/claude_player_main.gd new file mode 100644 index 00000000..6d9782c7 --- /dev/null +++ b/src/game/engine/scenes/headless/claude_player_main.gd @@ -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" diff --git a/src/game/engine/scenes/headless/claude_player_main.tscn b/src/game/engine/scenes/headless/claude_player_main.tscn new file mode 100644 index 00000000..8ae3c768 --- /dev/null +++ b/src/game/engine/scenes/headless/claude_player_main.tscn @@ -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") diff --git a/tooling/claude-player/README.md b/tooling/claude-player/README.md new file mode 100644 index 00000000..b0106999 --- /dev/null +++ b/tooling/claude-player/README.md @@ -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//`: +- `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` | `/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`. diff --git a/tooling/claude-player/package.json b/tooling/claude-player/package.json new file mode 100644 index 00000000..086fa3e1 --- /dev/null +++ b/tooling/claude-player/package.json @@ -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" + } +} diff --git a/tooling/claude-player/src/types.ts b/tooling/claude-player/src/types.ts new file mode 100644 index 00000000..18835754 --- /dev/null +++ b/tooling/claude-player/src/types.ts @@ -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; + 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; + }; + 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>; + 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; +} diff --git a/tooling/claude-player/tsconfig.json b/tooling/claude-player/tsconfig.json new file mode 100644 index 00000000..f4bd0801 --- /dev/null +++ b/tooling/claude-player/tsconfig.json @@ -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"] +}