205 lines
6.3 KiB
TypeScript
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;
|
|
}
|