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 { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { HarnessClient } from "./harness.js";
|
import { HarnessClient } from "./harness.js";
|
||||||
|
import { RenderClient } from "./render_client.js";
|
||||||
import type { PlayerAction, Response } from "./types.js";
|
import type { PlayerAction, Response } from "./types.js";
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -30,6 +31,11 @@ const DEFAULT_SERVER_SCRIPT = resolvePath(
|
||||||
"scripts",
|
"scripts",
|
||||||
"player-api-server.sh",
|
"player-api-server.sh",
|
||||||
);
|
);
|
||||||
|
const DEFAULT_RENDER_SCRIPT = resolvePath(
|
||||||
|
PROJECT_ROOT,
|
||||||
|
"scripts",
|
||||||
|
"render-driver-server.sh",
|
||||||
|
);
|
||||||
|
|
||||||
function emitStderr(line: string): void {
|
function emitStderr(line: string): void {
|
||||||
process.stderr.write(line + "\n");
|
process.stderr.write(line + "\n");
|
||||||
|
|
@ -39,6 +45,15 @@ function serverScript(): string {
|
||||||
return process.env["CP_SERVER"] ?? DEFAULT_SERVER_SCRIPT;
|
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> {
|
function harnessEnv(): Record<string, string> {
|
||||||
const passthrough = [
|
const passthrough = [
|
||||||
"CP_SEED",
|
"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 {
|
interface HarnessHolder {
|
||||||
client: HarnessClient | null;
|
client: HarnessClient | null;
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +192,28 @@ function getOrCreateClient(): HarnessClient {
|
||||||
return client;
|
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 {
|
function responsePayload(r: Response): string {
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
|
|
@ -176,6 +250,29 @@ async function handleEndTurn(): Promise<string> {
|
||||||
return responsePayload(r);
|
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> {
|
async function main(): Promise<void> {
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: "claude-player-mcp", version: "0.1.0" },
|
{ name: "claude-player-mcp", version: "0.1.0" },
|
||||||
|
|
@ -183,7 +280,9 @@ async function main(): Promise<void> {
|
||||||
);
|
);
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
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) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
|
@ -200,6 +299,12 @@ async function main(): Promise<void> {
|
||||||
case "magic_civ_end_turn":
|
case "magic_civ_end_turn":
|
||||||
text = await handleEndTurn();
|
text = await handleEndTurn();
|
||||||
break;
|
break;
|
||||||
|
case "magic_civ_screenshot":
|
||||||
|
text = await handleScreenshot(args);
|
||||||
|
break;
|
||||||
|
case "magic_civ_open_screen":
|
||||||
|
text = await handleOpenScreen(args);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`unknown tool: ${name}`);
|
throw new Error(`unknown tool: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
@ -225,6 +330,10 @@ async function main(): Promise<void> {
|
||||||
await holder.client.shutdown();
|
await holder.client.shutdown();
|
||||||
holder.client = null;
|
holder.client = null;
|
||||||
}
|
}
|
||||||
|
if (renderHolder.client !== null) {
|
||||||
|
renderHolder.client.close();
|
||||||
|
renderHolder.client = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
cleanup().finally(() => process.exit(0));
|
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