Initial commit: Claude Co-Engineer
A Factorio 2.x mod plus MCP server that lets Claude answer questions about a running game over Lua UDP, without RCON, so it works in singleplayer. - Read-only tools: overview, problems, entities, production, power, trends, research, recipes, ratio calculator, belt tracer, screenshots, Lua queries - In-game highlights with clickable [gps] links - /ask chat command answered by a local helper running claude -p, with a per-player model setting and a systemd user service Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
13 files changed
+3578
No files matched your search
@@ -0,0 +1,185 @@
|
||||
"""Answers in-game /ask questions by running Claude Code headless with the claude-co-engineer MCP server.
|
||||
|
||||
The mod sends {"type": "ask", "id", "player_index", "question"} to FACTORIO_ASK_PORT. We acknowledge it,
|
||||
run `claude -p` (which uses the CLI's own login, i.e. your subscription), relay which tools Claude is
|
||||
using as status lines, and send the final answer back through the game's UDP port.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .game_link import GameError, GameLink
|
||||
|
||||
GAME_PORT = int(os.environ.get("FACTORIO_UDP_PORT", "34200"))
|
||||
ASK_PORT = int(os.environ.get("FACTORIO_ASK_PORT", "34201"))
|
||||
CLAUDE = os.environ.get("FACTORIO_ASK_CLAUDE") or shutil.which("claude") or "claude"
|
||||
# Used when the mod doesn't send a model (the in-game "/ask model" setting normally does).
|
||||
DEFAULT_MODEL = os.environ.get("FACTORIO_ASK_MODEL", "sonnet")
|
||||
# Wins over the in-game setting, e.g. to try a model the dropdown doesn't list yet.
|
||||
MODEL_OVERRIDE = os.environ.get("FACTORIO_ASK_MODEL_OVERRIDE")
|
||||
TIMEOUT = float(os.environ.get("FACTORIO_ASK_TIMEOUT", "300"))
|
||||
WORKDIR = Path(os.environ.get("FACTORIO_ASK_WORKDIR", "~/.cache/claude-co-engineer/ask")).expanduser()
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
The user typed this question into Factorio's in-game chat with /ask, and your final answer is printed
|
||||
in that chat. Use the factorio tools to look at their game before answering.
|
||||
Format for the chat window: plain text, no Markdown (no **, #, backticks or tables), at most about
|
||||
6 short lines. Factorio rich text works and is encouraged: [gps=x,y] for clickable map locations,
|
||||
[item=iron-plate], [entity=assembling-machine-2], [fluid=water], [technology=automation] for icons.
|
||||
When you point at places, also call highlight with chat=false (your answer already has the links).
|
||||
Advise; don't play the game for them.
|
||||
"""
|
||||
|
||||
STATUS = {
|
||||
"get_overview": "looking around",
|
||||
"get_player": "checking your inventory",
|
||||
"inspect_entity": "inspecting that",
|
||||
"find_entities": "searching the area",
|
||||
"find_problems": "checking for problems",
|
||||
"get_production": "reading production stats",
|
||||
"get_power": "checking power",
|
||||
"get_trends": "looking at trends",
|
||||
"trace_belt": "following the belt",
|
||||
"get_research": "checking research",
|
||||
"get_recipe": "looking up recipes",
|
||||
"calculate_ratios": "calculating ratios",
|
||||
"screenshot": "taking a look",
|
||||
"highlight": "marking it on your map",
|
||||
"run_lua": "digging deeper",
|
||||
}
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
|
||||
return text.strip()
|
||||
|
||||
|
||||
class AskServer(asyncio.DatagramProtocol):
|
||||
def __init__(self):
|
||||
self.game = GameLink(GAME_PORT)
|
||||
self.sessions: dict[int, str] = {} # player_index -> Claude session id
|
||||
self.locks: dict[int, asyncio.Lock] = {}
|
||||
WORKDIR.mkdir(parents=True, exist_ok=True)
|
||||
self.mcp_config = WORKDIR / "mcp.json"
|
||||
self.mcp_config.write_text(json.dumps({
|
||||
"mcpServers": {
|
||||
"factorio": {
|
||||
"command": sys.executable,
|
||||
"args": ["-m", "claude_co_engineer.server"],
|
||||
"env": {"FACTORIO_UDP_PORT": str(GAME_PORT)},
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
def datagram_received(self, data: bytes, addr) -> None:
|
||||
try:
|
||||
message = json.loads(data)
|
||||
except ValueError:
|
||||
return
|
||||
player = message.get("player_index")
|
||||
if message.get("type") == "reset":
|
||||
self.sessions.pop(player, None)
|
||||
log(f"player {player}: conversation reset")
|
||||
elif message.get("type") == "ask":
|
||||
asyncio.ensure_future(self.handle_ask(message))
|
||||
|
||||
async def send(self, op: str, **args) -> None:
|
||||
try:
|
||||
await self.game.request(op, **args)
|
||||
except GameError as e:
|
||||
log(f"couldn't reach the game for {op}: {e}")
|
||||
|
||||
async def handle_ask(self, message: dict) -> None:
|
||||
ask_id, player, question = message["id"], message.get("player_index"), message["question"]
|
||||
model = MODEL_OVERRIDE or message.get("model") or DEFAULT_MODEL
|
||||
if not re.fullmatch(r"[\w.-]+", model):
|
||||
model = DEFAULT_MODEL
|
||||
log(f"player {player} asks #{ask_id} ({model}): {question}")
|
||||
await self.send("ask_ack", ask_id=ask_id)
|
||||
async with self.locks.setdefault(player, asyncio.Lock()):
|
||||
try:
|
||||
answer, is_error = await asyncio.wait_for(self.run_claude(ask_id, player, question, model), TIMEOUT)
|
||||
except TimeoutError:
|
||||
answer, is_error = f"Gave up after {TIMEOUT:g}s without an answer.", True
|
||||
except Exception as e: # noqa: BLE001 - report anything to the player rather than dying
|
||||
answer, is_error = f"The ask helper failed: {e}", True
|
||||
log(f"answer #{ask_id} ({'error' if is_error else 'ok'}): {answer}")
|
||||
await self.send("ask_answer", ask_id=ask_id, player_index=player, text=answer, error=is_error)
|
||||
|
||||
async def run_claude(self, ask_id: int, player: int, question: str, model: str) -> tuple[str, bool]:
|
||||
args = [
|
||||
CLAUDE, "-p", question,
|
||||
"--model", model,
|
||||
"--mcp-config", str(self.mcp_config), "--strict-mcp-config",
|
||||
"--tools", "", # no shell, file or web tools; only the game
|
||||
"--allowedTools", "mcp__factorio",
|
||||
"--append-system-prompt", SYSTEM_PROMPT,
|
||||
"--output-format", "stream-json", "--verbose",
|
||||
]
|
||||
if player in self.sessions:
|
||||
args += ["--resume", self.sessions[player]]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args, cwd=WORKDIR, stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=16 * 1024 * 1024,
|
||||
)
|
||||
stderr_task = asyncio.ensure_future(process.stderr.read())
|
||||
result, last_status = None, None
|
||||
try:
|
||||
async for line in process.stdout:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if event.get("type") == "assistant":
|
||||
for block in event["message"].get("content", []):
|
||||
if block.get("type") == "tool_use":
|
||||
status = STATUS.get(block["name"].removeprefix("mcp__factorio__"), "working")
|
||||
if status != last_status:
|
||||
last_status = status
|
||||
await self.send("ask_status", ask_id=ask_id, text=status + "...")
|
||||
elif event.get("type") == "result":
|
||||
result = event
|
||||
await process.wait()
|
||||
finally:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
|
||||
if result is None:
|
||||
stderr = (await stderr_task).decode(errors="replace").strip()
|
||||
return f"Claude exited without an answer: {stderr[-300:] or f'exit code {process.returncode}'}", True
|
||||
if result.get("session_id"):
|
||||
self.sessions[player] = result["session_id"]
|
||||
text = strip_markdown(result.get("result") or "")
|
||||
if "Not logged in" in text or "/login" in text:
|
||||
return "The claude CLI isn't logged in. Run 'claude auth login' in a terminal, then ask again.", True
|
||||
return text or "(no answer)", bool(result.get("is_error"))
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(message, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
async def serve() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.create_datagram_endpoint(AskServer, local_addr=("127.0.0.1", ASK_PORT))
|
||||
log(f"claude-co-engineer-ask listening on 127.0.0.1:{ASK_PORT}, game on {GAME_PORT}, claude = {CLAUDE}")
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
asyncio.run(serve())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user