magicciv/tooling/claude-player-mcp/src/harness.ts
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

205 lines
6.3 KiB
TypeScript

// 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
// increasing ids so it can route concurrent in-flight requests.
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 player-api-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;
/** True after the child exits (clean or crash). Lets the MCP layer
* detect a dead harness and respawn instead of erroring forever. */
get isClosed(): boolean {
return this.closed;
}
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) => {
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 — terminal state is fine.
}
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 {
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;
}
}
}
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 events = Array.isArray(obj["events"])
? (obj["events"] as Array<Record<string, unknown>>)
: undefined;
const ok: OkResponse = events === undefined
? {
id: (obj["id"] as number | null) ?? null,
ok: true,
view: obj["view"] as OkResponse["view"],
}
: {
id: (obj["id"] as number | null) ?? null,
ok: true,
view: obj["view"] as OkResponse["view"],
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;
}