feat(@projects/@magic-civilization): rename claude-player to player-api refactor

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-17 03:43:32 -07:00
parent 62d6a13ac9
commit 91ef4bc21f
16 changed files with 219 additions and 43 deletions

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# p2-67 Phase 5 — Real-apricot Claude-vs-AI demo driver, 25 EndTurns.
#
# Spawns claude-player-server.sh, sends CP_TURNS act:end_turn requests
# Spawns player-api-server.sh, sends CP_TURNS act:end_turn requests
# (default 25) followed by shutdown. Streams the FULL JSON-Lines
# response stream verbatim to stdout — caller redirects to transcript.jsonl.
#
@ -47,7 +47,7 @@ trap "rm -rf '$TMP'" EXIT
# Drive the harness. timeout is a hard ceiling; CP_TIMEOUT_SEC drives the
# in-process Godot watchdog inside the harness.
HARNESS_RC=0
timeout "$((CP_TIMEOUT_SEC + 60))" "$SCRIPT_DIR/claude-player-server.sh" \
timeout "$((CP_TIMEOUT_SEC + 60))" "$SCRIPT_DIR/player-api-server.sh" \
< "$TMP/in.jsonl" > "$TMP/out.jsonl" 2>"$TMP/err.log" \
|| HARNESS_RC=$?

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# p2-71 — 5-EndTurn smoke driver for the Claude Player API harness.
#
# Spawns claude-player-server.sh, sends 5 act:end_turn requests over stdin,
# Spawns player-api-server.sh, sends 5 act:end_turn requests over stdin,
# parses JSON-Lines responses, and prints a one-line verdict:
#
# {"turns": 5, "ai_turn_completed_events": N,
@ -37,7 +37,7 @@ trap "rm -rf '$TMP'" EXIT
} > "$TMP/in.jsonl"
# Run harness with timeout safety. 60s should be plenty for 5 turns on duel.
timeout 90 "$SCRIPT_DIR/claude-player-server.sh" < "$TMP/in.jsonl" > "$TMP/out.jsonl" 2>"$TMP/err.log" || true
timeout 90 "$SCRIPT_DIR/player-api-server.sh" < "$TMP/in.jsonl" > "$TMP/out.jsonl" 2>"$TMP/err.log" || true
# Parse — for each turn-response, count `ai_turn_completed` events and
# sum actions_applied across slots. Output one verdict line.

147
scripts/player-api-example.py Executable file
View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Example: drive the Magic Civilization Player API from a plain Python client.
This script exists as documentation-by-example: it shows that
`scripts/player-api-server.sh` is not Claude-specific. Any client that
speaks JSON-Lines over stdin/stdout can take a player slot Claude Code
via `tooling/claude-player-mcp/`, an OpenSpiel adapter pumping the same
pipe, or this 80-line random/greedy bot.
Wire-protocol contract: `src/game/engine/docs/PLAYER_API.md`.
Usage:
python3 scripts/player-api-example.py [TURNS]
Spawns the harness, plays `TURNS` turns (default 10) with a trivial
greedy policy, prints a one-line summary per turn, then shuts the
harness down cleanly.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
HARNESS = REPO_ROOT / "scripts" / "player-api-server.sh"
class PlayerApiClient:
"""Thin JSON-Lines client over a child process. Stateless except for
the request-id counter the harness holds the simulator state."""
def __init__(self, env_overrides: dict[str, str] | None = None) -> None:
env = {**os.environ, **(env_overrides or {})}
self._proc = subprocess.Popen(
["bash", str(HARNESS)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
cwd=str(REPO_ROOT),
text=True,
bufsize=1,
env=env,
)
self._next_id = 1
def _send(self, msg: dict[str, Any]) -> dict[str, Any] | None:
msg["id"] = self._next_id
self._next_id += 1
assert self._proc.stdin is not None and self._proc.stdout is not None
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()
# Read until correlated response (skip async notifications).
for _ in range(5000):
line = self._proc.stdout.readline()
if not line:
return None
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("id") == msg["id"]:
return obj
return None
def view(self) -> dict[str, Any] | None:
r = self._send({"type": "view"})
return r["view"] if r and r.get("ok") else None
def act(self, action: dict[str, Any]) -> bool:
r = self._send({"type": "act", "action": action})
return bool(r and r.get("ok"))
def end_turn(self) -> bool:
return self.act({"type": "end_turn"})
def shutdown(self) -> None:
self._send({"type": "shutdown"})
try:
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.kill()
def greedy_policy(view: dict[str, Any]) -> list[dict[str, Any]]:
"""Pick a small bundle of actions for this turn — found if possible,
fortify warriors, queue a worker in any city with an empty queue."""
actions: list[dict[str, Any]] = []
for u in view.get("units", []):
if u.get("owner") != view.get("player"):
continue
legal = [a["action"] for a in u.get("legal_actions", [])]
# 1. Found city if available (founder)
for a in legal:
if a["type"] == "found_city":
actions.append(a)
break
else:
# 2. Fortify warriors
for a in legal:
if a["type"] == "fortify" and not u.get("fortified"):
actions.append(a)
break
for c in view.get("cities", []):
if c.get("production_queue"):
continue
for a in (a["action"] for a in c.get("legal_actions", [])):
if a.get("type") == "queue_production" and a.get("item") == "worker":
actions.append(a)
break
return actions
def main() -> int:
turns = int(sys.argv[1]) if len(sys.argv) > 1 else 10
client = PlayerApiClient(env_overrides={"CP_SEED": "42", "CP_PLAYERS": "2"})
try:
for t in range(1, turns + 1):
view = client.view()
if view is None:
print(f"[t{t}] view failed; aborting")
return 1
score = view.get("score", {})
res = view.get("resources", {})
print(
f"[t{t:3d}] cities={int(score.get('city_count', 0))} "
f"units={int(score.get('unit_count', 0))} "
f"gold={int(res.get('gold', 0))} "
f"score={int(score.get('score_estimate', 0))}"
)
for action in greedy_policy(view):
if not client.act(action):
print(f" action failed: {action}")
break
if not client.end_turn():
print(f"[t{t}] end_turn failed; aborting")
return 1
finally:
client.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,14 +1,22 @@
#!/usr/bin/env bash
# p2-67 Phase 3 — Launch wrapper for the Claude Player headless harness.
# Generic external-player JSON-Lines harness for Magic Civilization.
#
# 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")`.
# 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 are forwarded into the sandbox (see CLAUDE_PLAYER_API.md):
# Env vars (see PLAYER_API.md for the full schema):
# CP_SEED, CP_PLAYERS, CP_CLAUDE_SLOT, CP_MAP_SIZE, CP_MAP_TYPE,
# CP_OMNISCIENT, CP_TIMEOUT_SEC, CP_LOG_FILE.
#
# `CP_CLAUDE_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.
@ -47,7 +55,7 @@ case "$(uname -s)" in
--path "$PROJECT_DIR/src/game" \
--headless \
--rendering-method gl_compatibility \
res://engine/scenes/headless/claude_player_main.tscn
res://engine/scenes/headless/player_api_main.tscn
;;
*)
exec flatpak run --user \
@ -63,6 +71,6 @@ case "$(uname -s)" in
--path "$PROJECT_DIR/src/game" \
--headless \
--rendering-method gl_compatibility \
res://engine/scenes/headless/claude_player_main.tscn
res://engine/scenes/headless/player_api_main.tscn
;;
esac

View file

@ -353,7 +353,7 @@ Three MCP tools become available after reload:
| `magic_civ_act` | `{action: PlayerAction}` | `{events, view}` |
| `magic_civ_end_turn` | none | Sugar for `act({type:"end_turn"})` |
The MCP server spawns `scripts/claude-player-server.sh` as a child on
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.

View file

@ -1,6 +0,0 @@
[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

@ -1,11 +1,17 @@
extends Node
## p2-67 Phase 3 — Headless harness for the Claude Player API.
## Headless harness for the generic external-player JSON-Lines 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`.
## `src/game/engine/docs/PLAYER_API.md`.
##
## Originally introduced in p2-67 as `claude_player_main.gd`. Renamed
## 2026-05-17 to drop the Claude-flavored naming — the wire protocol is
## client-agnostic; Claude Code is one consumer via
## `tooling/claude-player-mcp/`, an OpenSpiel/RL trainer can plug in via
## `subprocess.Popen`, and shell smoke tests use raw JSON-Lines.
##
## Env vars consumed:
## - `CP_SEED` — RNG seed (default 42)
@ -272,7 +278,7 @@ func _apply_combat_balance(gs: RefCounted) -> void:
## JSON-serialise the Array and hand it to the new Rust setters
## (`set_units_catalog_json` / `set_buildings_catalog_json`).
func _apply_ai_catalogs() -> void:
# Project root is mounted at `src/game/` (see claude-player-server.sh
# Project root is mounted at `src/game/` (see player-api-server.sh
# `--path` arg), so the bridge module's `res://` form drops the
# `src/game/` prefix.
var AiTurnBridgeState: Script = load("res://engine/src/modules/ai/ai_turn_bridge_state.gd")

View file

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

View file

@ -3393,7 +3393,7 @@ impl GdGameState {
/// `JSON.stringify`s the result before handing it to this setter — same
/// disk-IO discipline as `_apply_ai_personalities`.
///
/// **Order contract (claude_player_main.gd)**: this setter MUST run
/// **Order contract (player_api_main.gd)**: this setter MUST run
/// BEFORE `add_player_militarist`. Without a populated catalog,
/// `MapUnit::new` reads `base_moves = 0` (the `UnitsCatalog::get` miss
/// path), every AI `MoveUnit` is rejected at
@ -3435,7 +3435,7 @@ impl GdGameState {
/// `public/games/age-of-dwarves/data/combat_balance.json`.
///
/// Caller (GDScript boot, autoload `GameState.initialize_game` or the
/// headless `claude_player_main`) reads the file bytes and forwards the
/// headless `player_api_main`) reads the file bytes and forwards the
/// raw string. The Rust parser
/// (`mc_turn::combat_balance::load_combat_balance`) is the single source
/// of truth for the wire shape; unknown keys are ignored and missing

View file

@ -20,7 +20,7 @@
//! Error path: on any failure (parse, illegal action, internal) the
//! returned JSON is an error-shaped envelope with `ok: false` and a
//! typed `error` payload — adapters branch on the `ok` field exactly
//! as documented in `CLAUDE_PLAYER_API.md`.
//! as documented in `PLAYER_API.md`.
use godot::prelude::*;
use mc_ai::tactical::state::{TacticalBuildingSpec, TacticalUnitSpec};
@ -71,7 +71,7 @@ impl GdPlayerApi {
/// choices MUST follow up with `set_units_catalog_json` /
/// `set_buildings_catalog_json` / `set_difficulty_threshold_mult`
/// after every `load_state_json`. The harness
/// (`claude_player_main.gd::_apply_ai_catalogs`) does this once at
/// (`player_api_main.gd::_apply_ai_catalogs`) does this once at
/// boot — if a future flow re-loads state mid-game, repeat the
/// catalog setters.
#[func]

View file

@ -2,7 +2,7 @@
//!
//! Drives a 3-player `GameState` (Claude=slot 0, AI=slots 1+2) for up to
//! 25 turns and emits the canonical JSON-Lines wire transcript that
//! `claude_player_main.gd` would produce if it ran headlessly against the
//! `player_api_main.gd` would produce if it ran headlessly against the
//! same construction. Output lands under
//! `.local/demo-runs/2026-05-12-claude-vs-ai-mock/`:
//!

View file

@ -2,7 +2,7 @@
//!
//! Exercises `mc_player_api::apply_action(PlayerAction::EndTurn)` through 5
//! consecutive end-turn cycles against a hand-built `GameState` that mirrors
//! the GDScript headless harness (`claude_player_main.gd`) one-for-one.
//! the GDScript headless harness (`player_api_main.gd`) one-for-one.
//! Construction lives in `tests/common/mod.rs` so it can be reused by the
//! 25-turn `full_game_transcript` test (and any future scripted-game test).
//!

View file

@ -1,12 +1,23 @@
# @magic-civ/claude-player-mcp
MCP server exposing the Magic Civilization **Claude Player API** as tools
that Claude Code (or any MCP-compatible client) can call. Lets Claude Code
play the game directly — no API key, no second SDK layer.
Claude-Code-specific MCP transport for the generic **Magic Civilization
Player API**. Lets Claude Code play one slot of the game directly — no
API key, no second SDK layer.
This package is *one* client of the player API; the underlying Godot
harness (`scripts/player-api-server.sh` + `src/game/engine/scenes/headless/player_api_main.tscn`)
is client-agnostic and speaks JSON-Lines. Other consumers:
- **Plain Python / RL trainer / OpenSpiel adapter**`subprocess.Popen`
the harness directly; example in `scripts/player-api-example.py`.
- **Shell smoke / regression tests**`scripts/claude-smoke-5endturn.sh`,
`scripts/claude-demo-25turn.sh` write JSON-Lines to stdin.
Wire-protocol contract: `src/game/engine/docs/PLAYER_API.md`.
## What it does
Stdio-transport MCP server. Spawns `scripts/claude-player-server.sh` as a
Stdio-transport MCP server. Spawns `scripts/player-api-server.sh` as a
child process on first tool invocation; reuses the harness across calls.
Translates each MCP tool call into one JSON-Lines request to the harness
and returns the response as MCP `text` content.
@ -20,7 +31,7 @@ and returns the response as MCP `text` content.
| `magic_civ_end_turn` | none | Sugar for `magic_civ_act({type:"end_turn"})` |
Full `PlayerAction` taxonomy and view shape live in
`src/game/engine/docs/CLAUDE_PLAYER_API.md`.
`src/game/engine/docs/PLAYER_API.md`.
## Install + build
@ -72,11 +83,11 @@ Claude Code (stdio MCP client)
@magic-civ/claude-player-mcp (this package)
▼ spawn() + JSON-Lines pipe
scripts/claude-player-server.sh
scripts/player-api-server.sh
▼ flatpak Godot --headless
GdPlayerApi (api-gdext) → mc-player-api → mc-turn handlers
```
Owning objective: `.project/objectives/p2-67-claude-player-api.md`.
Wire-protocol contract: `src/game/engine/docs/CLAUDE_PLAYER_API.md`.
Wire-protocol contract: `src/game/engine/docs/PLAYER_API.md`.

View file

@ -1,4 +1,4 @@
// JSON-Lines pump over a child process running `scripts/claude-player-server.sh`.
// JSON-Lines pump over a child process running `scripts/player-api-server.sh`.
//
// Each request/response/notification is one JSON value per line. Responses are
// correlated by an optional `id` field; the MCP server assigns monotonically
@ -17,7 +17,7 @@ import type {
} from "./types.js";
export interface HarnessOptions {
/** Absolute path to claude-player-server.sh. */
/** Absolute path to player-api-server.sh. */
serverScript: string;
/** Env-var overrides for the harness (CP_SEED, CP_PLAYERS, ...). */
env?: Record<string, string>;

View file

@ -1,11 +1,15 @@
#!/usr/bin/env node
// MCP server entry — exposes the Magic Civilization Claude Player API as
// MCP server entry — exposes the Magic Civilization external-player API as
// stdio-transport MCP tools. Claude Code (or any MCP client) lists three
// tools and calls them: `magic_civ_view`, `magic_civ_act`, `magic_civ_end_turn`.
//
// Spawns `scripts/claude-player-server.sh` as a child process on first
// tool invocation and reuses it across calls in the same session. The
// child speaks JSON-Lines per `CLAUDE_PLAYER_API.md`.
// This package is the Claude-Code-specific transport for the *generic*
// player API; the underlying Godot harness is not Claude-flavored — it
// speaks JSON-Lines per `src/game/engine/docs/PLAYER_API.md` and is
// drivable by any client (OpenSpiel adapter, RL trainer, shell smoke).
//
// Spawns `scripts/player-api-server.sh` as a child process on first
// tool invocation and reuses it across calls in the same session.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@ -24,7 +28,7 @@ const PROJECT_ROOT = resolvePath(HERE, "..", "..", "..");
const DEFAULT_SERVER_SCRIPT = resolvePath(
PROJECT_ROOT,
"scripts",
"claude-player-server.sh",
"player-api-server.sh",
);
function emitStderr(line: string): void {
@ -73,7 +77,7 @@ const ACT_TOOL = {
"Take one player action in Magic Civilization. The `action` argument is a " +
"PlayerAction JSON object (e.g. {type:'move',unit_id:'42',to:[5,7]} or " +
"{type:'found_city',unit_id:'42'} or {type:'end_turn'}). The full action " +
"taxonomy is documented in src/game/engine/docs/CLAUDE_PLAYER_API.md. " +
"taxonomy is documented in src/game/engine/docs/PLAYER_API.md. " +
"Returns the events that fired plus the post-action view.",
inputSchema: {
type: "object" as const,

View file

@ -1,5 +1,5 @@
// Wire-protocol types — mirror the Rust enums in `mc-player-api`.
// Source of truth: src/game/engine/docs/CLAUDE_PLAYER_API.md.
// Source of truth: src/game/engine/docs/PLAYER_API.md.
export type WireHex = [number, number];
export type PlayerId = number;