From 70537bc0d16f471877d4b7a0c229f1b4c277e433 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 18 Jun 2026 21:09:10 -0500 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20claude-player-mcp=20rendered=20tools=20=E2=80=94=20?= =?UTF-8?q?magic=5Fciv=5Fscreenshot=20/=20open=5Fscreen=20(p2-86=20phase?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- scripts/render-driver-server.sh | 54 +++++ tooling/claude-player-mcp/src/index.ts | 111 ++++++++++- .../claude-player-mcp/src/render_client.ts | 187 ++++++++++++++++++ 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100755 scripts/render-driver-server.sh create mode 100644 tooling/claude-player-mcp/src/render_client.ts diff --git a/scripts/render-driver-server.sh b/scripts/render-driver-server.sh new file mode 100755 index 00000000..a5fda385 --- /dev/null +++ b/scripts/render-driver-server.sh @@ -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 diff --git a/tooling/claude-player-mcp/src/index.ts b/tooling/claude-player-mcp/src/index.ts index ee3f18b4..e5e51030 100644 --- a/tooling/claude-player-mcp/src/index.ts +++ b/tooling/claude-player-mcp/src/index.ts @@ -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 { 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 { return responsePayload(r); } +async function handleScreenshot(args: unknown): Promise { + 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 { + 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 { const server = new Server( { name: "claude-player-mcp", version: "0.1.0" }, @@ -183,7 +280,9 @@ async function main(): Promise { ); 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 { 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 { 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)); diff --git a/tooling/claude-player-mcp/src/render_client.ts b/tooling/claude-player-mcp/src/render_client.ts new file mode 100644 index 00000000..c2a1cb2f --- /dev/null +++ b/tooling/claude-player-mcp/src/render_client.ts @@ -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; + /** 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) => void; + reject: (e: Error) => void; + timer: NodeJS.Timeout; +} + +export class RenderClient { + private readonly opts: Required; + private child: ChildProcess | null = null; + private sock: Socket | null = null; + private buf = ""; + private readonly pending = new Map(); + private nextId = 1; + private readyPromise: Promise | 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 { + if (this.readyPromise === null) { + this.readyPromise = this.start(); + } + return this.readyPromise; + } + + private start(): Promise { + 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 { + const deadline = Date.now() + this.opts.startTimeoutMs; + return new Promise((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 | null = null; + try { + obj = JSON.parse(line) as Record; + } 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): Promise> { + return this.ensureReady().then( + () => + new Promise>((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> { + return this.send({ type: "ping" }); + } + + screenshot(path?: string): Promise> { + return this.send(path === undefined ? { type: "screenshot" } : { type: "screenshot", path }); + } + + openScreen(screen: string): Promise> { + 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(); + } +}