magicciv/src/game/engine/docs/PLAYER_API.md
Natalie 91ef4bc21f feat(@projects/@magic-civilization): rename claude-player to player-api refactor
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-17 03:43:32 -07:00

18 KiB

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):

{"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):

{"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):

{"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 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.

// 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).

{
  "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:

{
  "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: <player>}
Offer Shared Map {type: "offer_shared_map", to: <player>}
Declare War {type: "declare_war", on: <player>}
Offer Peace {type: "offer_peace", to: <player>}
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.rsActionKind (unit verbs)
  • src/simulator/crates/mc-core/src/city_action.rsCityAction
  • src/simulator/crates/mc-core/src/building_action.rsBuildingActionKind
  • 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