From e124fba470d118efb5686e2e18d5fc9082ab4204 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 10 May 2026 17:04:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20claudio=20agent=20loop=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- tooling/claude-player/src/agent.ts | 296 +++++++++++++++++++++++++++ tooling/claude-player/src/harness.ts | 198 ++++++++++++++++++ tooling/claude-player/src/index.ts | 99 +++++++++ 3 files changed, 593 insertions(+) create mode 100644 tooling/claude-player/src/agent.ts create mode 100644 tooling/claude-player/src/harness.ts create mode 100644 tooling/claude-player/src/index.ts diff --git a/tooling/claude-player/src/agent.ts b/tooling/claude-player/src/agent.ts new file mode 100644 index 00000000..3021464a --- /dev/null +++ b/tooling/claude-player/src/agent.ts @@ -0,0 +1,296 @@ +// 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) => 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 { + 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 { + 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; + view: PlayerView | null; + endedTurn: boolean; + gameOver: boolean; +} + +async function runOneTool( + opts: AgentOptions, + tu: Anthropic.Messages.ToolUseBlock, +): Promise { + 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 { + 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, + }; +} diff --git a/tooling/claude-player/src/harness.ts b/tooling/claude-player/src/harness.ts new file mode 100644 index 00000000..d4497a53 --- /dev/null +++ b/tooling/claude-player/src/harness.ts @@ -0,0 +1,198 @@ +// 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; + /** 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; + private readonly pending = new Map(); + private readonly opts: Required; + 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; + + 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 { + return this.send({ type: "view" }); + } + + /** Apply one action. Returns the response (ok or err). */ + async act(action: PlayerAction): Promise { + return this.send({ type: "act", action }); + } + + /** Sugar — `act({type:"end_turn"})`. */ + async endTurn(): Promise { + return this.act({ type: "end_turn" }); + } + + /** Tell the harness to exit cleanly. Resolves once the child exits. */ + async shutdown(): Promise { + 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((resolve) => { + if (this.closed) { + resolve(); + return; + } + this.child.once("exit", () => resolve()); + this.child.stdin.end(); + }); + } + + private send(body: Record): Promise { + return new Promise((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; + + 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): 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; + 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; +} diff --git a/tooling/claude-player/src/index.ts b/tooling/claude-player/src/index.ts new file mode 100644 index 00000000..af597ec5 --- /dev/null +++ b/tooling/claude-player/src/index.ts @@ -0,0 +1,99 @@ +// Entry point — spawns the harness, wires up the Claude Agent loop, and +// writes an append-only run log under `.local/runs//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 { + 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): 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); +});