# Claude Player API **Status:** Phase 0 design draft, p2-67. **Owner:** simulator-infra. **Wire:** JSON-Lines over stdin/stdout (one JSON value per line, `\n` terminator). **Encoding:** UTF-8 strict, no BOM, no trailing whitespace. ## Purpose Let a Claude Agent SDK process play one slot of a Magic Civilization game while the production AI plays the others. Every action Claude takes maps 1-to-1 to a button or interaction the human UI exposes — no privileged shortcuts, no direct `GameState` mutation. Game state is read via a **fog-aware projection** so Claude only sees what the player it's bound to could see. ## Wire protocol ### Framing JSON-Lines. Each message is a single JSON value followed by `\n`. The harness treats `\r\n` as `\n`. Any line that fails to parse triggers a `protocol_error` notification and is otherwise ignored. No length prefixes, no envelopes beyond the single JSON value. ### Direction ``` Adapter ──Request──▶ Harness ◀─Response─ ◀─Notify── (event broadcasts; many per turn) ``` Requests and responses are correlated by an optional client-supplied `id` field. If the adapter omits `id`, the harness echoes `null`. ### Message shapes **Request** (adapter → harness): ```json {"id": 42, "type": "view"} {"id": 43, "type": "act", "action": {"type": "move", "unit_id": "u_1", "to": [5, 7]}} {"id": 44, "type": "act", "action": {"type": "end_turn"}} {"id": 45, "type": "shutdown"} ``` **Response** (harness → adapter): ```json {"id": 42, "ok": true, "view": { ... }} {"id": 43, "ok": true, "events": [ ... ], "view": { ... }} {"id": 43, "ok": false, "error": {"code": "illegal_action", "message": "...", "details": {...}}} ``` **Notification** (harness → adapter, no `id`): ```json {"type": "turn_started", "turn": 12, "player": 0} {"type": "ai_turn_completed", "player": 1, "actions_applied": 14} {"type": "city_founded", "city_id": "clan2_capital", "owner": 1, "position": [22, 11]} {"type": "turn_timeout", "player": 0, "elapsed_sec": 60.2} {"type": "game_over", "winner": 0, "victory_type": "domination"} {"type": "protocol_error", "message": "could not parse line 17: unexpected EOF"} ``` The harness emits one notification per `EventBus` signal so the adapter can keep its world model in sync without polling. The full mapping is in [Notifications](#notifications) below. ### Request types | `type` | Body | Returns | |---|---|---| | `view` | `{}` | `{ ok, view }` | | `act` | `{ action: PlayerAction }` | `{ ok, events, view }` (or error) | | `shutdown` | `{}` | `{ ok: true }` then closes the pipe | `view` is a pure read; calling it repeatedly between actions is free. The returned `view` after `act` is the post-action state, so adapters do not have to call `view` separately after every move. ### Error codes | `code` | Meaning | |---|---| | `illegal_action` | Action shape is valid but action is not in `legal_actions` for current state | | `not_your_turn` | The bound player slot is not the current player | | `parse_error` | Action JSON did not deserialise into a `PlayerAction` | | `unknown_unit` / `unknown_city` / `unknown_tech` | Referenced id not present | | `target_invalid` | Move/Attack target is out-of-range, blocked, or off-map | | `internal` | Underlying `mc-turn` returned an unexpected error (bug — file an issue) | ## `PlayerAction` enum Wire-tagged on a `type` field, snake_case variants. Defined in Rust as `mc_player_api::PlayerAction`. Every variant maps to one user-visible interaction. ```jsonc // Unit actions — the unit panel + map clicks {"type": "move", "unit_id": "u_42", "to": [5, 7]} {"type": "attack", "unit_id": "u_42", "target": [6, 7]} {"type": "ranged_attack", "unit_id": "u_42", "target": [9, 7]} {"type": "fortify", "unit_id": "u_42"} {"type": "skip", "unit_id": "u_42"} {"type": "found_city", "unit_id": "u_42"} {"type": "build_improvement","unit_id":"u_42", "improvement_id": "farm"} {"type": "issue_patrol", "unit_id": "u_42", "waypoints": [[5,7],[6,8]]} {"type": "set_rally_point", "unit_id": "u_42", "to": [10, 10]} {"type": "command_formation","formation_id": 3, "command": "advance", "to": [12, 5]} // ... + every other ActionKind variant — see mc-core::action::ActionKind // City actions — city screen buttons {"type": "queue_production","city_id": "ironhold", "item": "dwarf_warrior"} {"type": "queue_production","city_id": "ironhold", "item": "dwarf_forge", "tile": [3, 4]} {"type": "remove_from_queue","city_id": "ironhold", "index": 2} {"type": "queue_reorder", "city_id": "ironhold", "from": 0, "to": 2} {"type": "rush_buy", "city_id": "ironhold"} {"type": "buy_tile", "city_id": "ironhold", "tile": [4, 5]} {"type": "set_focus", "city_id": "ironhold", "focus": "production"} {"type": "merge_buildings", "city_id": "ironhold", "building_a": "rifle_range", "building_b": "barding_hall", "into": "war_academy"} // Building actions — building panel buttons inside city screen {"type": "building_action", "city_id": "ironhold", "building_id": "watchtower_42", "kind": "set_rally", "target": [8, 8]} {"type": "building_action", "city_id": "ironhold", "building_id": "watchtower_42", "kind": "garrison_in", "unit_id": "u_42"} // ... covers every BuildingActionKind variant (set_rally, garrison_in, repair, // toggle_active, fire_arrows, raze, annex, stockpile, ...) // Empire-level {"type": "research_tech", "tech_id": "bronze_working"} {"type": "research_tradition", "tradition_id": "ancestor_veneration"} {"type": "switch_civic", "axis": "authority", "choice": "council"} // Diplomacy — one variant per offer + response {"type": "offer_open_borders", "to": 1} {"type": "accept_open_borders","from": 1} {"type": "reject_open_borders","from": 1} {"type": "offer_shared_map", "to": 1} {"type": "declare_war", "on": 1} {"type": "offer_peace", "to": 1} {"type": "respond_to_ransom", "offer_id": 17, "response": "accept"} // Meta {"type": "end_turn"} {"type": "noop"} // captured for telemetry; legal but no-op ``` ## `PlayerView` shape The fog-aware projection returned by `view` and after every successful `act`. Read-only on the wire (mutations come from `act`). ```jsonc { "turn": 12, "player": 0, // bound player index "current_player": 0, // who has priority right now "phase": "player_actions", // mirrors TurnManager.Phase "is_human_turn": true, // false when AI has priority "resources": { "gold": 87, "gold_per_turn": 4, "science_per_turn": 6, "culture_per_turn": 3, "happiness_pool": 0, "stockpile": {"iron": 4, "wood": 12, "grain": 0} }, "research": { "current_tech": "bronze_working", "tech_progress": 18, "tech_cost": 40, "researched": ["mining", "pottery"], "available": ["bronze_working", "calendar", "writing"] }, "culture": { "current_tradition": "ancestor_veneration", "tradition_progress": 5, "tradition_cost": 30, "researched": [] }, "civics": { "authority": "council", "labor": "guild", "economy": null, "anarchy_turns_remaining": 0 }, "cities": [ { "id": "ironhold", "name": "Ironhold", "position": [12, 5], "owner": 0, "is_capital": true, "population": 5, "food_stored": 12, "food_growth_threshold": 30, "production_queue": [ {"item": "dwarf_warrior", "kind": "unit", "progress": 8, "cost": 40}, {"item": "dwarf_granary", "kind": "building", "progress": 0, "cost": 60} ], "buildings": ["dwarf_palace"], "owned_tiles": [[12,5],[12,4],[11,5],[13,5],[12,6],[11,6],[13,4]], "yields": {"food": 6, "production": 4, "gold": 2, "science": 1, "culture": 1}, "hp": 100, "max_hp": 100, "focus": "balanced", "buildable": [ {"item": "dwarf_warrior", "kind": "unit", "cost": 40, "rush_gold": 80}, {"item": "dwarf_granary", "kind": "building", "cost": 60, "rush_gold": 120} ] } ], "units": [ { "id": "u_42", "type": "dwarf_warrior", "position": [10, 5], "owner": 0, "hp": 100, "max_hp": 100, "movement_left": 2, "movement_max": 2, "experience": 0, "promotion_available": false, "fortified": false, "sentry": false, "legal_actions": [ {"action": {"type": "move", "unit_id": "u_42", "to": [11, 5]}, "enabled": true}, {"action": {"type": "fortify", "unit_id": "u_42"}, "enabled": true}, {"action": {"type": "attack", "unit_id": "u_42", "target": [11, 5]}, "enabled": false, "disabled_reason": "no_enemy_in_range"} ] } ], "tiles": [ // Only tiles the player has explored. Hidden tiles omitted entirely // unless CP_OMNISCIENT=1. {"position": [12, 5], "biome": "plains", "substrate": "rock", "improvement": null, "river": false, "explored": true, "visible": true, "owner_city": "ironhold"} ], "diplomacy": [ {"player": 1, "race": "dwarf", "name": "Clan Goldvein", "relation": "peace", "open_borders": false, "shared_map": false, "agreements_active": []} ], "pending_events": { "ransom_offers": [ {"offer_id": 17, "captor": 1, "captive_unit": "dwarf_worker", "price": 80, "expires_in_turns": 2} ], "promotion_picks": [], "anarchy_choice": null }, "legal_actions": [ // Top-level actions not tied to a specific unit/city: end_turn, research_tech, // research_tradition, switch_civic, diplomacy offers, etc. {"action": {"type": "end_turn"}, "enabled": true} ], "score": { "gold_total": 87, "city_count": 1, "unit_count": 2, "score_estimate": 142 } } ``` ### Fog-of-war redaction When `CP_OMNISCIENT=0` (default): - **Tiles**: only those in `Player.observations.explored_tiles`. Tiles in `visible_now` carry `visible: true`; previously-seen tiles are `visible: false` and their `improvement` / `units` fields reflect the *last seen* observation. - **Units**: only own units + enemy units in current vision range. Enemy unit `id` is replaced with an opaque per-turn token if the enemy's identity isn't already known (prevents tracking). - **Cities**: own cities + enemy cities the player has ever seen. `population` is last-observed value, not live. - **Tech**: opponents' `researched` list is hidden. Their `tech_index` count is exposed for relative-strength comparison. - **Diplomacy**: own agreements only. Opponent ↔ opponent agreements hidden unless one party has shared maps with the bound player. When `CP_OMNISCIENT=1`: full state, no redaction. Used by the snapshot test in `mc-player-api::tests` and for debugging only. ## Notifications One JSON-Lines notification per `EventBus` signal, in the same order they fire inside the simulator. The adapter can ignore them entirely (it has the full view after every `act`) but they make streaming UIs cheap. | `type` | Body schema | |---|---| | `turn_started` | `{turn, player}` | | `turn_ended` | `{turn, player}` | | `phase_changed` | `{phase}` | | `ai_turn_started` / `ai_turn_completed` | `{player[, actions_applied]}` | | `unit_created` / `unit_destroyed` / `unit_moved` / `unit_promoted` | `{unit_id, ...}` | | `city_founded` / `city_captured` / `city_grew` / `city_starved` | `{city_id, ...}` | | `city_building_completed` / `city_unit_completed` | `{city_id, item}` | | `combat_resolved` | `{attacker_id, defender_id, result: {damage, kills, ...}}` | | `tech_researched` / `culture_researched` | `{id, player}` | | `unit_captured` / `civilian_destroyed` / `ransom_offered` / `ransom_accepted` / `ransom_expired` | per-event payload | | `wonder_built` | `{wonder_id, player}` | | `player_eliminated` | `{player}` | | `game_over` | `{winner, victory_type}` | | `turn_timeout` | `{player, elapsed_sec}` (harness substitutes AI for that turn) | | `protocol_error` | `{message}` | Notifications are filtered through the same fog-of-war projection as `view`. Events about hidden enemy units / hidden tiles are dropped. ## Environment variables (harness) | Var | Default | Meaning | |---|---|---| | `CP_SEED` | `42` | Seed passed to `MapGenerator` and `GameState.initialize_game` | | `CP_PLAYERS` | `2` | Total player slots | | `CP_CLAUDE_SLOT` | `0` | Which slot Claude controls; others run `AiTurnBridge` | | `CP_MAP_SIZE` | `duel` | `MapGenerator` size key | | `CP_MAP_TYPE` | `continents` | `MapGenerator` map type | | `CP_TURN_LIMIT` | `100` | Auto-end the game at this turn (max) | | `CP_TIMEOUT_SEC` | `60` | Per-action timeout; expiry triggers `turn_timeout` + AI substitution | | `CP_OMNISCIENT` | `0` | `1` disables fog redaction (debug only) | | `CP_LOG_FILE` | unset | If set, harness mirrors all wire I/O to this path | ## Client integration: MCP server for Claude Code The canonical client is **Claude Code**, talking to a thin MCP server that wraps the harness. No Anthropic API key, no second agent loop — Claude Code is the agent, and the MCP server is just a tool surface. The server lives at `tooling/claude-player-mcp/`. Build and wire in via `.mcp.json`: ```json { "mcpServers": { "magic-civ": { "command": "node", "args": ["./tooling/claude-player-mcp/dist/index.js"] } } } ``` Three MCP tools become available after reload: | Tool | Args | Returns | |---|---|---| | `magic_civ_view` | none | Fog-aware `PlayerView` JSON | | `magic_civ_act` | `{action: PlayerAction}` | `{events, view}` | | `magic_civ_end_turn` | none | Sugar for `act({type:"end_turn"})` | The MCP server spawns `scripts/player-api-server.sh` as a child on first tool invocation and reuses the harness across the session. Any MCP-stdio client works the same way — `claude-player-mcp` is just a thin pump. ## UI button → action audit This audit grounds the `PlayerAction` enum in the real interactions the human UI exposes. Verified by grepping each scene's `_on_*` handlers and `pressed.connect` calls. ### `world_map_hud.gd` + `top_bar.gd` | Button | Action | |---|---| | `EndTurnButton` | `{type: "end_turn"}` | | `TechButton` | (opens panel — read via `view.research`) | | `DiplomacyButton` | (opens panel — read via `view.diplomacy`) | | `ChronicleButton` | (read-only — exposed via notifications) | | `EncyclopediaButton` | (read-only static reference; not in API) | | `StatsButton` | (read-only — exposed via `view.score`) | | `BugReportButton` | (out of scope) | | `TutorialButton` | (out of scope) | | `InfoButton` | (read-only) | ### `city_screen.gd` | Button / interaction | Action | |---|---| | `add_queue` button on a buildable | `{type: "queue_production", city_id, item, [tile]}` | | `rush_pressed` | `{type: "rush_buy", city_id}` | | `buy_tile_pressed` | `{type: "buy_tile", city_id, tile}` | | `focus_pressed(mode)` | `{type: "set_focus", city_id, focus: mode}` | | `queue_up` / `queue_down` | `{type: "queue_reorder", city_id, from, to}` | | `queue_remove` | `{type: "remove_from_queue", city_id, index}` | | `merge_btn_pressed` | `{type: "merge_buildings", city_id, building_a, building_b, into}` | | `building_panel_set_rally` | `{type: "building_action", kind: "set_rally", ...}` | | `building_panel_clear_rally` | `{type: "building_action", kind: "clear_rally", ...}` | | `building_panel_garrison_in/out` | `{type: "building_action", kind: "garrison_in"/"garrison_out", ...}` | | `building_panel_repair` | `{type: "building_action", kind: "repair", ...}` | | `building_panel_toggle_active` | `{type: "building_action", kind: "toggle_active", ...}` | | `citizen_tile_clicked` | (UI hint — citizens are auto-assigned by mc-city; no API verb yet) | ### `tech_tree.gd` / `culture_tree.gd` | Click | Action | |---|---| | Select tech node | `{type: "research_tech", tech_id}` | | Select tradition node | `{type: "research_tradition", tradition_id}` | ### `diplomacy_panel.gd` | Button | Action | |---|---| | Offer Open Borders | `{type: "offer_open_borders", to: }` | | Offer Shared Map | `{type: "offer_shared_map", to: }` | | Declare War | `{type: "declare_war", on: }` | | Offer Peace | `{type: "offer_peace", to: }` | | Accept / Reject offer | `{type: "accept_open_borders", from}` etc. | ### Map interactions (no panel) | Click | Action | |---|---| | Left-click own unit | (selection — local UI state only, not in API) | | Right-click target hex with unit selected | `{type: "move", unit_id, to}` (or attack if enemy) | | Right-click ranged target | `{type: "ranged_attack", unit_id, target}` | | Movement-mode confirm | `{type: "move", ...}` (single trip) | | Patrol pick-mode confirm | `{type: "issue_patrol", unit_id, waypoints}` | | Founder on tile + Found City button | `{type: "found_city", unit_id}` | | Worker on tile + improvement choice | `{type: "build_improvement", unit_id, improvement_id}` | ## Open follow-ups - Citizen tile reassignment — the city screen lets the player override auto-assignment but mc-city's API doesn't surface it. Tracked separately. - Save/load via the API — out of scope for v1; adapter can `shutdown` and re-spawn with same `CP_SEED`. - Multi-Claude (Claude vs Claude) — needs two harness instances with shared state, or one harness with multiple stdin/stdout pairs. Defer. ## References - `src/simulator/crates/mc-core/src/action.rs` — `ActionKind` (unit verbs) - `src/simulator/crates/mc-core/src/city_action.rs` — `CityAction` - `src/simulator/crates/mc-core/src/building_action.rs` — `BuildingActionKind` - `src/simulator/crates/mc-mcts-service/src/framing.rs` — line-codec precedent - `src/game/engine/scenes/tests/auto_play.gd` — full headless-game harness - `.project/objectives/p2-67-claude-player-api.md` — owning objective