fix(tooling): 🔥 remove claude-player adapter
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
ace555a5ae
commit
b3bada0712
7 changed files with 0 additions and 856 deletions
|
|
@ -1,57 +0,0 @@
|
|||
# @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`.
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
// Claude Agent SDK loop driving the harness.
|
||||
//
|
||||
// Three tools are exposed to Claude:
|
||||
// view() → returns the current PlayerView JSON
|
||||
// act(action) → applies one PlayerAction, returns events + new view
|
||||
// end_turn() → sugar for act({type:"end_turn"})
|
||||
//
|
||||
// The loop runs until a `game_over` notification fires, a `turn` field in
|
||||
// the view exceeds `maxTurns`, or the model emits `end_turn` `maxIdleEndTurns`
|
||||
// times in a row without any other action (defensive cap).
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
import type { HarnessClient } from "./harness.js";
|
||||
import type { PlayerAction, PlayerView, Response } from "./types.js";
|
||||
|
||||
export interface AgentOptions {
|
||||
/** Live HarnessClient. */
|
||||
harness: HarnessClient;
|
||||
/** Anthropic API client. */
|
||||
anthropic: Anthropic;
|
||||
/** Model id (claude-opus-4-7, claude-sonnet-4-6, ...). */
|
||||
model: string;
|
||||
/** Hard cap on turns played. */
|
||||
maxTurns: number;
|
||||
/** Defensive cap on consecutive `end_turn` calls without state change. */
|
||||
maxIdleEndTurns: number;
|
||||
/** Append-only log sink (one JSON entry per agent step). */
|
||||
onLog?: (entry: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are playing one player slot in Magic Civilization,
|
||||
a turn-based 4X strategy game (the "Age of Dwarves" variant — no magic, pure
|
||||
mundane tech). You are playing against the production AI which controls the
|
||||
other player slot(s).
|
||||
|
||||
Your goal: maximise your score. Score is dominated by city count, population,
|
||||
researched tech count, and military strength. Domination victory (controlling
|
||||
every opponent's original capital) ends the game in your favour immediately.
|
||||
|
||||
You drive the game by calling tools:
|
||||
- view() — read the current fog-aware game state.
|
||||
- act(action) — apply one player action. The action JSON shape matches the
|
||||
PlayerAction enum documented in the game's CLAUDE_PLAYER_API.md.
|
||||
- end_turn() — end your turn so the AI can take theirs. The harness handles
|
||||
AI turns automatically before returning control to you.
|
||||
|
||||
Always start your turn with view() to refresh your model of the state. Then
|
||||
take whichever actions advance your plan. End your turn when you've done all
|
||||
useful work — don't end early, but don't waste turns on cosmetic actions.
|
||||
|
||||
Per turn, prioritise (in order):
|
||||
1. Found a city if you have a Founder unit on a viable tile.
|
||||
2. Train a military unit if you have no army and an enemy is within 5 hexes.
|
||||
3. Build the next strategic building in your capital.
|
||||
4. Move units toward their assigned objectives (scouting / attacking / defending).
|
||||
5. Research the next tech on your path.
|
||||
|
||||
When you finish your turn, call end_turn(). Then read the new state and
|
||||
take the next turn. The game ends when you call end_turn() and receive a
|
||||
game_over notification, or when 100 turns have been played.`;
|
||||
|
||||
const TOOLS: Anthropic.Messages.Tool[] = [
|
||||
{
|
||||
name: "view",
|
||||
description: "Read the current fog-aware game state.",
|
||||
input_schema: { type: "object", properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
name: "act",
|
||||
description:
|
||||
"Take one player action. Returns events and the post-action view.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "object",
|
||||
description:
|
||||
"PlayerAction JSON (e.g. {type:'move',unit_id:'42',to:[5,7]}).",
|
||||
},
|
||||
},
|
||||
required: ["action"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "end_turn",
|
||||
description: "End your turn. Sugar for act({type:'end_turn'}).",
|
||||
input_schema: { type: "object", properties: {}, required: [] },
|
||||
},
|
||||
];
|
||||
|
||||
export interface AgentRunResult {
|
||||
turns_played: number;
|
||||
reason: "game_over" | "max_turns" | "idle_cap" | "blocker";
|
||||
final_view: PlayerView | null;
|
||||
}
|
||||
|
||||
export async function runAgent(opts: AgentOptions): Promise<AgentRunResult> {
|
||||
try {
|
||||
return await runAgentInner(opts);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
opts.onLog?.({ kind: "agent_fatal", error: message });
|
||||
return {
|
||||
turns_played: 0,
|
||||
reason: "blocker",
|
||||
final_view: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgentInner(opts: AgentOptions): Promise<AgentRunResult> {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [];
|
||||
let consecutiveIdleEnds = 0;
|
||||
let lastTurnNumber = -1;
|
||||
let finalView: PlayerView | null = null;
|
||||
|
||||
// Seed the conversation with the initial view.
|
||||
const initialView = await opts.harness.view();
|
||||
if (!initialView.ok) {
|
||||
return {
|
||||
turns_played: 0,
|
||||
reason: "blocker",
|
||||
final_view: null,
|
||||
};
|
||||
}
|
||||
finalView = initialView.view;
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Initial game state (read this before deciding any actions):\n\n" +
|
||||
JSON.stringify(initialView.view, null, 2),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (let step = 0; step < opts.maxTurns * 50; step++) {
|
||||
if (finalView !== null && finalView.turn > opts.maxTurns) {
|
||||
return { turns_played: finalView.turn, reason: "max_turns", final_view: finalView };
|
||||
}
|
||||
if (consecutiveIdleEnds >= opts.maxIdleEndTurns) {
|
||||
return {
|
||||
turns_played: finalView?.turn ?? 0,
|
||||
reason: "idle_cap",
|
||||
final_view: finalView,
|
||||
};
|
||||
}
|
||||
opts.onLog?.({ step, kind: "agent_request", messages: messages.length });
|
||||
const response = await opts.anthropic.messages.create({
|
||||
model: opts.model,
|
||||
max_tokens: 4096,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools: TOOLS,
|
||||
messages,
|
||||
});
|
||||
opts.onLog?.({ step, kind: "agent_response", stop_reason: response.stop_reason });
|
||||
messages.push({ role: "assistant", content: response.content });
|
||||
|
||||
const toolUses = response.content.filter(
|
||||
(c): c is Anthropic.Messages.ToolUseBlock => c.type === "tool_use",
|
||||
);
|
||||
if (toolUses.length === 0) {
|
||||
opts.onLog?.({ step, kind: "agent_finished_without_tool" });
|
||||
break;
|
||||
}
|
||||
|
||||
const toolResults: Anthropic.Messages.ToolResultBlockParam[] = [];
|
||||
let endTurnCalledThisStep = false;
|
||||
for (const tu of toolUses) {
|
||||
const result = await runOneTool(opts, tu);
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: tu.id,
|
||||
content: JSON.stringify(result.payload),
|
||||
});
|
||||
if (result.endedTurn) endTurnCalledThisStep = true;
|
||||
if (result.view !== null) finalView = result.view;
|
||||
if (result.gameOver) {
|
||||
opts.onLog?.({ step, kind: "game_over_observed" });
|
||||
return {
|
||||
turns_played: finalView?.turn ?? 0,
|
||||
reason: "game_over",
|
||||
final_view: finalView,
|
||||
};
|
||||
}
|
||||
}
|
||||
messages.push({ role: "user", content: toolResults });
|
||||
|
||||
if (endTurnCalledThisStep) {
|
||||
const newTurn = finalView?.turn ?? -1;
|
||||
if (newTurn === lastTurnNumber) {
|
||||
consecutiveIdleEnds++;
|
||||
} else {
|
||||
consecutiveIdleEnds = 0;
|
||||
lastTurnNumber = newTurn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
turns_played: finalView?.turn ?? 0,
|
||||
reason: "max_turns",
|
||||
final_view: finalView,
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolOutcome {
|
||||
payload: Record<string, unknown>;
|
||||
view: PlayerView | null;
|
||||
endedTurn: boolean;
|
||||
gameOver: boolean;
|
||||
}
|
||||
|
||||
async function runOneTool(
|
||||
opts: AgentOptions,
|
||||
tu: Anthropic.Messages.ToolUseBlock,
|
||||
): Promise<ToolOutcome> {
|
||||
try {
|
||||
return await runOneToolInner(opts, tu);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
payload: { ok: false, error: { code: "internal", message } },
|
||||
view: null,
|
||||
endedTurn: false,
|
||||
gameOver: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runOneToolInner(
|
||||
opts: AgentOptions,
|
||||
tu: Anthropic.Messages.ToolUseBlock,
|
||||
): Promise<ToolOutcome> {
|
||||
switch (tu.name) {
|
||||
case "view": {
|
||||
const r = await opts.harness.view();
|
||||
return outcomeFromResponse(r, false);
|
||||
}
|
||||
case "end_turn": {
|
||||
const r = await opts.harness.endTurn();
|
||||
return outcomeFromResponse(r, true);
|
||||
}
|
||||
case "act": {
|
||||
const input = tu.input as { action?: PlayerAction };
|
||||
if (!input || typeof input !== "object" || !input.action) {
|
||||
return {
|
||||
payload: {
|
||||
ok: false,
|
||||
error: { code: "parse_error", message: "act tool requires {action: PlayerAction}" },
|
||||
},
|
||||
view: null,
|
||||
endedTurn: false,
|
||||
gameOver: false,
|
||||
};
|
||||
}
|
||||
const r = await opts.harness.act(input.action);
|
||||
const ended = input.action.type === "end_turn";
|
||||
return outcomeFromResponse(r, ended);
|
||||
}
|
||||
default:
|
||||
return {
|
||||
payload: {
|
||||
ok: false,
|
||||
error: { code: "parse_error", message: `unknown tool ${tu.name}` },
|
||||
},
|
||||
view: null,
|
||||
endedTurn: false,
|
||||
gameOver: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeFromResponse(r: Response, endedTurn: boolean): ToolOutcome {
|
||||
if (r.ok) {
|
||||
const events = r.events ?? [];
|
||||
const gameOver = events.some(
|
||||
(e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "game_over",
|
||||
);
|
||||
return {
|
||||
payload: { ok: true, events, view: r.view },
|
||||
view: r.view,
|
||||
endedTurn,
|
||||
gameOver,
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload: { ok: false, error: r.error },
|
||||
view: null,
|
||||
endedTurn: false,
|
||||
gameOver: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
// JSON-Lines pump over a child process running `scripts/claude-player-server.sh`.
|
||||
//
|
||||
// Each request/response/notification is one JSON value per line. Responses are
|
||||
// correlated by an optional `id` field; the adapter assigns monotonically
|
||||
// increasing ids so it can route concurrent in-flight requests if it ever
|
||||
// adds parallelism (single-flight is enough for v1).
|
||||
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
import type {
|
||||
ErrResponse,
|
||||
Notification,
|
||||
OkResponse,
|
||||
PlayerAction,
|
||||
Response,
|
||||
} from "./types.js";
|
||||
|
||||
export interface HarnessOptions {
|
||||
/** Absolute path to claude-player-server.sh. */
|
||||
serverScript: string;
|
||||
/** Env-var overrides for the harness (CP_SEED, CP_PLAYERS, ...). */
|
||||
env?: Record<string, string>;
|
||||
/** Hook invoked for every async `Notification` line. */
|
||||
onNotification?: (note: Notification) => void;
|
||||
/** Hook invoked for every line read off stdout, before parsing. */
|
||||
onRawLine?: (direction: "<-" | "->", line: string) => void;
|
||||
/** Default per-request timeout in ms. */
|
||||
defaultTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
id: number;
|
||||
resolve: (resp: Response) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the harness child process. Exposes `view()`, `act()`, `endTurn()`,
|
||||
* `shutdown()` — each returns the matching `Response` once it lands.
|
||||
*/
|
||||
export class HarnessClient {
|
||||
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly opts: Required<HarnessOptions>;
|
||||
private nextId = 1;
|
||||
private closed = false;
|
||||
|
||||
constructor(opts: HarnessOptions) {
|
||||
this.opts = {
|
||||
env: {},
|
||||
onNotification: () => {},
|
||||
onRawLine: () => {},
|
||||
defaultTimeoutMs: 60_000,
|
||||
...opts,
|
||||
};
|
||||
this.child = spawn(this.opts.serverScript, [], {
|
||||
env: { ...process.env, ...this.opts.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}) as ChildProcessByStdio<Writable, Readable, Readable>;
|
||||
|
||||
const out = createInterface({ input: this.child.stdout });
|
||||
out.on("line", (line) => {
|
||||
this.opts.onRawLine("<-", line);
|
||||
this.handleLine(line);
|
||||
});
|
||||
this.child.stderr.on("data", (chunk: Buffer) => {
|
||||
// Surface harness stderr to our own stderr so users see crashes /
|
||||
// engine warnings without them mingling into the protocol channel.
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
this.child.on("exit", (code) => {
|
||||
this.closed = true;
|
||||
const err = new Error(`harness exited with code ${code ?? "null"}`);
|
||||
for (const req of this.pending.values()) {
|
||||
clearTimeout(req.timer);
|
||||
req.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the current fog-aware view. */
|
||||
async view(): Promise<Response> {
|
||||
return this.send({ type: "view" });
|
||||
}
|
||||
|
||||
/** Apply one action. Returns the response (ok or err). */
|
||||
async act(action: PlayerAction): Promise<Response> {
|
||||
return this.send({ type: "act", action });
|
||||
}
|
||||
|
||||
/** Sugar — `act({type:"end_turn"})`. */
|
||||
async endTurn(): Promise<Response> {
|
||||
return this.act({ type: "end_turn" });
|
||||
}
|
||||
|
||||
/** Tell the harness to exit cleanly. Resolves once the child exits. */
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
await this.send({ type: "shutdown" });
|
||||
} catch {
|
||||
// Child may have exited mid-shutdown — that's the desired terminal
|
||||
// state, so consume the error and fall through to the exit wait.
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
if (this.closed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.child.once("exit", () => resolve());
|
||||
this.child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
private send(body: Record<string, unknown>): Promise<Response> {
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
if (this.closed) {
|
||||
reject(new Error("harness already closed"));
|
||||
return;
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const payload = JSON.stringify({ ...body, id });
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`request ${id} (${String(body["type"])}) timed out`));
|
||||
}, this.opts.defaultTimeoutMs);
|
||||
this.pending.set(id, { id, resolve, reject, timer });
|
||||
this.opts.onRawLine("->", payload);
|
||||
this.child.stdin.write(payload + "\n");
|
||||
});
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Non-JSON line on stdout (e.g. engine warnings); ignore.
|
||||
return;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if ("id" in obj && (obj["id"] === null || typeof obj["id"] === "number")) {
|
||||
const id = obj["id"];
|
||||
if (typeof id === "number") {
|
||||
const pend = this.pending.get(id);
|
||||
if (pend !== undefined) {
|
||||
this.pending.delete(id);
|
||||
clearTimeout(pend.timer);
|
||||
const response = toResponse(obj);
|
||||
if (response !== null) {
|
||||
pend.resolve(response);
|
||||
} else {
|
||||
pend.reject(new Error(`malformed response shape: ${line}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No id → notification.
|
||||
if (typeof obj["type"] === "string") {
|
||||
this.opts.onNotification(obj as Notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toResponse(obj: Record<string, unknown>): Response | null {
|
||||
if (obj["ok"] === true && typeof obj["view"] === "object" && obj["view"] !== null) {
|
||||
const ok: OkResponse = {
|
||||
id: (obj["id"] as number | null) ?? null,
|
||||
ok: true,
|
||||
view: obj["view"] as OkResponse["view"],
|
||||
events: Array.isArray(obj["events"])
|
||||
? (obj["events"] as OkResponse["events"])
|
||||
: [],
|
||||
};
|
||||
return ok;
|
||||
}
|
||||
if (obj["ok"] === false && typeof obj["error"] === "object" && obj["error"] !== null) {
|
||||
const errObj = obj["error"] as Record<string, unknown>;
|
||||
const err: ErrResponse = {
|
||||
id: (obj["id"] as number | null) ?? null,
|
||||
ok: false,
|
||||
error: {
|
||||
code: typeof errObj["code"] === "string" ? errObj["code"] : "internal",
|
||||
message: typeof errObj["message"] === "string" ? errObj["message"] : "",
|
||||
},
|
||||
};
|
||||
return err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
// Entry point — spawns the harness, wires up the Claude Agent loop, and
|
||||
// writes an append-only run log under `.local/runs/<stamp>/log.jsonl`.
|
||||
// See `README.md` for usage and the full env-var contract.
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { dirname, resolve as resolvePath } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { runAgent } from "./agent.js";
|
||||
import { HarnessClient } from "./harness.js";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = resolvePath(HERE, "..", "..", "..");
|
||||
|
||||
function emit(line: string): void {
|
||||
process.stderr.write(line + "\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const apiKey = process.env["ANTHROPIC_API_KEY"];
|
||||
if (!apiKey || apiKey.length === 0) {
|
||||
emit("ANTHROPIC_API_KEY not set. Export it before running the adapter.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const serverScript =
|
||||
process.env["CP_SERVER"] ??
|
||||
resolvePath(PROJECT_ROOT, "scripts", "claude-player-server.sh");
|
||||
const model = process.env["CP_MODEL"] ?? "claude-opus-4-7";
|
||||
const maxTurns = parseInt(process.env["CP_MAX_TURNS"] ?? "100", 10);
|
||||
const maxIdleEnds = parseInt(process.env["CP_MAX_IDLE_ENDS"] ?? "3", 10);
|
||||
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.replace("T", "_")
|
||||
.replace("Z", "");
|
||||
const runDir = resolvePath(HERE, "..", ".local", "runs", stamp);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
const logPath = resolvePath(runDir, "log.jsonl");
|
||||
const wirePath = resolvePath(runDir, "wire.jsonl");
|
||||
const logStream = createWriteStream(logPath, { flags: "a" });
|
||||
const wireStream = createWriteStream(wirePath, { flags: "a" });
|
||||
const log = (entry: Record<string, unknown>): void => {
|
||||
logStream.write(JSON.stringify({ ts: Date.now(), ...entry }) + "\n");
|
||||
};
|
||||
|
||||
emit(`claude-player run dir: ${runDir}`);
|
||||
emit(`spawning harness: ${serverScript}`);
|
||||
|
||||
const harness = new HarnessClient({
|
||||
serverScript,
|
||||
env: {},
|
||||
onNotification: (note) => {
|
||||
log({ kind: "notification", ...note });
|
||||
},
|
||||
onRawLine: (direction, line) => {
|
||||
wireStream.write(`${direction} ${line}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const anthropic = new Anthropic({ apiKey });
|
||||
|
||||
try {
|
||||
const result = await runAgent({
|
||||
harness,
|
||||
anthropic,
|
||||
model,
|
||||
maxTurns,
|
||||
maxIdleEndTurns: maxIdleEnds,
|
||||
onLog: log,
|
||||
});
|
||||
log({ kind: "run_complete", ...result });
|
||||
emit(
|
||||
`run complete — reason=${result.reason}, turns=${result.turns_played}`,
|
||||
);
|
||||
await writeFile(
|
||||
resolvePath(runDir, "result.json"),
|
||||
JSON.stringify(result, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
} catch (err) {
|
||||
log({ kind: "fatal", error: err instanceof Error ? err.message : String(err) });
|
||||
emit(`fatal: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await harness.shutdown();
|
||||
logStream.end();
|
||||
wireStream.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
emit(`uncaught: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue