feat(@projects/@magic-civilization): ✨ claude-player-mcp rendered tools — magic_civ_screenshot / open_screen (p2-86 phase 2)
- render_client.ts: TCP client that spawns the rendered game via
render-driver-server.sh, then sends screenshot / open_screen / ping correlated
by id (distinct from the headless stdin HarnessClient).
- scripts/render-driver-server.sh: boots the REAL game (MC_AUTO_START +
MC_MCP_RENDER, gl_compatibility, not --headless) so the driver captures real
frames; client connects on MC_MCP_PORT.
- index.ts: magic_civ_screenshot + magic_civ_open_screen tools, render-client
holder + cleanup.
Verified end-to-end through the BUILT TypeScript (no Claude restart needed):
RenderClient -> game -> TCP ping {ok}, screenshot -> a real 3420x1923 PNG of the
live world map. dist/ is gitignored (built locally per .mcp.json); the tools
surface in a Claude session after the next restart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6721f3f2f3
commit
70537bc0d1
3 changed files with 351 additions and 1 deletions
54
scripts/render-driver-server.sh
Executable file
54
scripts/render-driver-server.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env bash
|
||||
# Rendered-game launch for the MCP render driver (p2-86).
|
||||
#
|
||||
# Unlike player-api-server.sh (a --headless state bench), this boots the REAL
|
||||
# game WITH rendering so the driver's `screenshot` command captures real frames.
|
||||
# MC_AUTO_START=1 skips the menu into an interactive seeded game;
|
||||
# MC_MCP_RENDER=1 activates mcp_render_driver.gd, which listens on MC_MCP_PORT
|
||||
# (default 8787). The client (RenderClient) connects over TCP — stdout is not
|
||||
# used for the protocol (Godot block-buffers windowed stdout).
|
||||
#
|
||||
# Env:
|
||||
# MC_MCP_PORT — driver TCP port (default 8787)
|
||||
# MC_AUTOSTART_SEED — game seed (default 42)
|
||||
# MC_AUTOSTART_PLAYERS / MC_AUTOSTART_MAP_SIZE — optional overrides
|
||||
#
|
||||
# Linux note: rendered mode needs a display server (weston/xvfb on a GPU node,
|
||||
# per MAGIC_CIV_CLOUD_HANDOFF.md). macOS renders directly via the windowing
|
||||
# system. Headless state play is unchanged (player-api-server.sh).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
: "${MC_MCP_PORT:=8787}"
|
||||
: "${MC_AUTOSTART_SEED:=42}"
|
||||
|
||||
export MC_AUTO_START=1 MC_MCP_RENDER=1 MC_MCP_PORT MC_AUTOSTART_SEED \
|
||||
MC_AUTOSTART_PLAYERS MC_AUTOSTART_MAP_SIZE
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
GODOT_BIN="${GODOT_BIN:-godot}"
|
||||
if ! command -v "$GODOT_BIN" >/dev/null 2>&1; then
|
||||
echo "ERROR: no godot binary on PATH (set GODOT_BIN or 'brew install godot')." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$GODOT_BIN" \
|
||||
--path "$PROJECT_DIR/src/game" \
|
||||
--rendering-method gl_compatibility
|
||||
;;
|
||||
*)
|
||||
# Linux: flatpak Godot. Rendered mode requires a display (weston/xvfb);
|
||||
# the caller is responsible for providing WAYLAND_DISPLAY / DISPLAY.
|
||||
exec flatpak run --user \
|
||||
--env=MC_AUTO_START=1 \
|
||||
--env=MC_MCP_RENDER=1 \
|
||||
--env=MC_MCP_PORT="$MC_MCP_PORT" \
|
||||
--env=MC_AUTOSTART_SEED="$MC_AUTOSTART_SEED" \
|
||||
org.godotengine.Godot \
|
||||
--path "$PROJECT_DIR/src/game" \
|
||||
--rendering-method gl_compatibility
|
||||
;;
|
||||
esac
|
||||
|
|
@ -21,6 +21,7 @@ import { dirname, resolve as resolvePath } from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { HarnessClient } from "./harness.js";
|
||||
import { RenderClient } from "./render_client.js";
|
||||
import type { PlayerAction, Response } from "./types.js";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -30,6 +31,11 @@ const DEFAULT_SERVER_SCRIPT = resolvePath(
|
|||
"scripts",
|
||||
"player-api-server.sh",
|
||||
);
|
||||
const DEFAULT_RENDER_SCRIPT = resolvePath(
|
||||
PROJECT_ROOT,
|
||||
"scripts",
|
||||
"render-driver-server.sh",
|
||||
);
|
||||
|
||||
function emitStderr(line: string): void {
|
||||
process.stderr.write(line + "\n");
|
||||
|
|
@ -39,6 +45,15 @@ function serverScript(): string {
|
|||
return process.env["CP_SERVER"] ?? DEFAULT_SERVER_SCRIPT;
|
||||
}
|
||||
|
||||
function renderScript(): string {
|
||||
return process.env["CP_RENDER_SERVER"] ?? DEFAULT_RENDER_SCRIPT;
|
||||
}
|
||||
|
||||
function renderPort(): number {
|
||||
const p = Number.parseInt(process.env["MC_MCP_PORT"] ?? "8787", 10);
|
||||
return Number.isNaN(p) ? 8787 : p;
|
||||
}
|
||||
|
||||
function harnessEnv(): Record<string, string> {
|
||||
const passthrough = [
|
||||
"CP_SEED",
|
||||
|
|
@ -105,6 +120,43 @@ const END_TURN_TOOL = {
|
|||
},
|
||||
};
|
||||
|
||||
const SCREENSHOT_TOOL = {
|
||||
name: "magic_civ_screenshot",
|
||||
description:
|
||||
"Capture a PNG screenshot of the LIVE rendered Magic Civilization game. The " +
|
||||
"rendered driver auto-starts an interactive seeded game on first use. Returns " +
|
||||
"the absolute saved path and pixel [width,height]. Optional `path` (user://, " +
|
||||
"res://, or absolute) overrides the default save location.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Optional save path; defaults to user://screenshots/mcp_capture.png.",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
};
|
||||
|
||||
const OPEN_SCREEN_TOOL = {
|
||||
name: "magic_civ_open_screen",
|
||||
description:
|
||||
"Navigate the rendered game to a top-level screen before screenshotting. " +
|
||||
"Supported screens: 'main_menu', 'game_setup'. (In-game panels such as the " +
|
||||
"city screen are a follow-up.)",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
screen: {
|
||||
type: "string",
|
||||
description: "Target screen id: 'main_menu' | 'game_setup'.",
|
||||
},
|
||||
},
|
||||
required: ["screen"],
|
||||
},
|
||||
};
|
||||
|
||||
interface HarnessHolder {
|
||||
client: HarnessClient | null;
|
||||
}
|
||||
|
|
@ -140,6 +192,28 @@ function getOrCreateClient(): HarnessClient {
|
|||
return client;
|
||||
}
|
||||
|
||||
interface RenderHolder {
|
||||
client: RenderClient | null;
|
||||
}
|
||||
|
||||
const renderHolder: RenderHolder = { client: null };
|
||||
|
||||
function getOrCreateRenderClient(): RenderClient {
|
||||
if (renderHolder.client !== null && !renderHolder.client.isClosed) {
|
||||
return renderHolder.client;
|
||||
}
|
||||
if (renderHolder.client !== null) {
|
||||
emitStderr("previous render client closed; respawning");
|
||||
} else {
|
||||
emitStderr(`spawning render driver: ${renderScript()} (port ${renderPort()})`);
|
||||
}
|
||||
renderHolder.client = new RenderClient({
|
||||
serverScript: renderScript(),
|
||||
port: renderPort(),
|
||||
});
|
||||
return renderHolder.client;
|
||||
}
|
||||
|
||||
function responsePayload(r: Response): string {
|
||||
if (r.ok) {
|
||||
return JSON.stringify(
|
||||
|
|
@ -176,6 +250,29 @@ async function handleEndTurn(): Promise<string> {
|
|||
return responsePayload(r);
|
||||
}
|
||||
|
||||
async function handleScreenshot(args: unknown): Promise<string> {
|
||||
const path =
|
||||
typeof args === "object" && args !== null
|
||||
? (args as { path?: unknown }).path
|
||||
: undefined;
|
||||
const client = getOrCreateRenderClient();
|
||||
const r = await client.screenshot(typeof path === "string" ? path : undefined);
|
||||
return JSON.stringify(r, null, 2);
|
||||
}
|
||||
|
||||
async function handleOpenScreen(args: unknown): Promise<string> {
|
||||
if (typeof args !== "object" || args === null) {
|
||||
throw new Error("magic_civ_open_screen requires {screen: string}");
|
||||
}
|
||||
const screen = (args as { screen?: unknown }).screen;
|
||||
if (typeof screen !== "string") {
|
||||
throw new Error("magic_civ_open_screen requires {screen: string}");
|
||||
}
|
||||
const client = getOrCreateRenderClient();
|
||||
const r = await client.openScreen(screen);
|
||||
return JSON.stringify(r, null, 2);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const server = new Server(
|
||||
{ name: "claude-player-mcp", version: "0.1.0" },
|
||||
|
|
@ -183,7 +280,9 @@ async function main(): Promise<void> {
|
|||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: [VIEW_TOOL, ACT_TOOL, END_TURN_TOOL] };
|
||||
return {
|
||||
tools: [VIEW_TOOL, ACT_TOOL, END_TURN_TOOL, SCREENSHOT_TOOL, OPEN_SCREEN_TOOL],
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
|
|
@ -200,6 +299,12 @@ async function main(): Promise<void> {
|
|||
case "magic_civ_end_turn":
|
||||
text = await handleEndTurn();
|
||||
break;
|
||||
case "magic_civ_screenshot":
|
||||
text = await handleScreenshot(args);
|
||||
break;
|
||||
case "magic_civ_open_screen":
|
||||
text = await handleOpenScreen(args);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown tool: ${name}`);
|
||||
}
|
||||
|
|
@ -225,6 +330,10 @@ async function main(): Promise<void> {
|
|||
await holder.client.shutdown();
|
||||
holder.client = null;
|
||||
}
|
||||
if (renderHolder.client !== null) {
|
||||
renderHolder.client.close();
|
||||
renderHolder.client = null;
|
||||
}
|
||||
};
|
||||
process.on("SIGTERM", () => {
|
||||
cleanup().finally(() => process.exit(0));
|
||||
|
|
|
|||
187
tooling/claude-player-mcp/src/render_client.ts
Normal file
187
tooling/claude-player-mcp/src/render_client.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// TCP client for the rendered-game MCP driver (`mcp_render_driver.gd`, p2-86).
|
||||
//
|
||||
// Spawns the REAL rendered game via `scripts/render-driver-server.sh` (which
|
||||
// boots with MC_AUTO_START=1 MC_MCP_RENDER=1), waits for the driver's localhost
|
||||
// TCP port to open, then sends JSON-Lines commands correlated by `id`.
|
||||
//
|
||||
// Distinct from HarnessClient: that drives the headless state bench over
|
||||
// stdin/stdout (view/act/end_turn); this drives the rendered game over TCP
|
||||
// (screenshot/open_screen) because OS.read_string_from_stdin blocks a rendered
|
||||
// main loop and Godot block-buffers windowed stdout.
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { connect, type Socket } from "node:net";
|
||||
|
||||
export interface RenderClientOptions {
|
||||
/** Absolute path to render-driver-server.sh. */
|
||||
serverScript: string;
|
||||
/** Driver TCP port (must match MC_MCP_PORT passed to the game). */
|
||||
port: number;
|
||||
host?: string;
|
||||
env?: Record<string, string>;
|
||||
/** Max wait for the game to boot + the driver port to open. */
|
||||
startTimeoutMs?: number;
|
||||
/** Per-request timeout. Screenshots include a save, so keep this generous. */
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (v: Record<string, unknown>) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class RenderClient {
|
||||
private readonly opts: Required<RenderClientOptions>;
|
||||
private child: ChildProcess | null = null;
|
||||
private sock: Socket | null = null;
|
||||
private buf = "";
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private nextId = 1;
|
||||
private readyPromise: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
|
||||
constructor(opts: RenderClientOptions) {
|
||||
this.opts = {
|
||||
host: "127.0.0.1",
|
||||
env: {},
|
||||
startTimeoutMs: 45_000,
|
||||
requestTimeoutMs: 30_000,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
get isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
|
||||
private ensureReady(): Promise<void> {
|
||||
if (this.readyPromise === null) {
|
||||
this.readyPromise = this.start();
|
||||
}
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
private start(): Promise<void> {
|
||||
this.child = spawn(this.opts.serverScript, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
...this.opts.env,
|
||||
MC_MCP_PORT: String(this.opts.port),
|
||||
},
|
||||
// Ignore the game's stdin/stdout (we talk TCP); surface its stderr.
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
});
|
||||
this.child.on("exit", () => {
|
||||
this.closed = true;
|
||||
this.failAll(new Error("rendered game process exited"));
|
||||
});
|
||||
return this.connectWithRetry();
|
||||
}
|
||||
|
||||
private connectWithRetry(): Promise<void> {
|
||||
const deadline = Date.now() + this.opts.startTimeoutMs;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const attempt = (): void => {
|
||||
const s = connect({ host: this.opts.host, port: this.opts.port });
|
||||
s.once("connect", () => {
|
||||
this.sock = s;
|
||||
s.setEncoding("utf8");
|
||||
s.on("data", (chunk: string) => this.onData(chunk));
|
||||
s.on("close", () => {
|
||||
this.sock = null;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
s.once("error", () => {
|
||||
s.destroy();
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error("render driver TCP port never opened"));
|
||||
return;
|
||||
}
|
||||
setTimeout(attempt, 500);
|
||||
});
|
||||
};
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
private onData(chunk: string): void {
|
||||
this.buf += chunk;
|
||||
let idx = this.buf.indexOf("\n");
|
||||
while (idx >= 0) {
|
||||
const line = this.buf.slice(0, idx).trim();
|
||||
this.buf = this.buf.slice(idx + 1);
|
||||
if (line.length > 0) {
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
try {
|
||||
obj = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
obj = null;
|
||||
}
|
||||
if (obj !== null && typeof obj["id"] === "number") {
|
||||
const pend = this.pending.get(obj["id"] as number);
|
||||
if (pend !== undefined) {
|
||||
this.pending.delete(obj["id"] as number);
|
||||
clearTimeout(pend.timer);
|
||||
pend.resolve(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
idx = this.buf.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private send(body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return this.ensureReady().then(
|
||||
() =>
|
||||
new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
if (this.sock === null) {
|
||||
reject(new Error("render client not connected"));
|
||||
return;
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`render request ${id} (${String(body["type"])}) timed out`));
|
||||
}, this.opts.requestTimeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.sock.write(JSON.stringify({ ...body, id }) + "\n");
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ping(): Promise<Record<string, unknown>> {
|
||||
return this.send({ type: "ping" });
|
||||
}
|
||||
|
||||
screenshot(path?: string): Promise<Record<string, unknown>> {
|
||||
return this.send(path === undefined ? { type: "screenshot" } : { type: "screenshot", path });
|
||||
}
|
||||
|
||||
openScreen(screen: string): Promise<Record<string, unknown>> {
|
||||
return this.send({ type: "open_screen", screen });
|
||||
}
|
||||
|
||||
/** Stop the rendered game and tear down the socket. */
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.failAll(new Error("render client closed"));
|
||||
if (this.sock !== null) {
|
||||
this.sock.destroy();
|
||||
this.sock = null;
|
||||
}
|
||||
if (this.child !== null) {
|
||||
this.child.kill();
|
||||
this.child = null;
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(err: Error): void {
|
||||
for (const pend of this.pending.values()) {
|
||||
clearTimeout(pend.timer);
|
||||
pend.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue