From ac35e3ff2c73ca9929a897aa45b44f574d4ba361 Mon Sep 17 00:00:00 2001 From: Fabian van Koppen Date: Thu, 29 Oct 2026 21:44:52 +0100 Subject: [PATCH] Add CLAUDE.md Commands, the isolated headless-server test setup, and the architecture that spans the mod, MCP server and /ask helper: UDP request flow and chunking, deferred replies, multiplayer determinism, the read-only contract, Lua-to-JSON quirks and the token-budget conventions. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d4a153 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A read-only Factorio 2.x advisor. Claude looks at the user's live game and explains or suggests; +it never plays for them. Three pieces talk over UDP on localhost (no RCON, so it works in singleplayer): + +- `mod/claude-co-engineer/` — the in-game mod. All queries are answered in `control.lua`. +- `server/claude_co_engineer/server.py` — the MCP server (`claude-co-engineer-mcp`), registered in + Claude Code/Desktop under the key `factorio`, so tools appear as `mcp__factorio__`. +- `server/claude_co_engineer/ask.py` — the `/ask` helper (`claude-co-engineer-ask`, a systemd user + service). It runs `claude -p` with only this MCP server and posts the answer to the game chat. + +## Commands + +```bash +uv sync --directory server # create/refresh server/.venv +uv run --directory server claude-co-engineer-mcp # MCP server on stdio +luac -p mod/claude-co-engineer/*.lua # Lua syntax check +luac -l -p mod/claude-co-engineer/control.lua | grep -oE '_ENV "[a-z_]+"' | sort -u # globals used +systemctl --user restart claude-co-engineer-ask # after changing ask.py/server.py +journalctl --user -u claude-co-engineer-ask -f # /ask questions and answers +``` + +There are no tests or linters. The globals check matters: anything besides Factorio/Lua globals +(`game`, `helpers`, `storage`, `defines`, `prototypes`, …) is a local used before it's defined in +`control.lua`, which is `nil` at runtime. + +Call a mod op directly, bypassing MCP (the game must be running with `--enable-lua-udp 34200`): + +```bash +server/.venv/bin/python -c "import asyncio; from claude_co_engineer.game_link import GameLink; print(asyncio.run(GameLink(34200).request('ping')))" +``` + +The Factorio API reference is installed locally: +`~/.steam/steam/steamapps/common/Factorio/doc-html/runtime-api.json`. Check method and field names +there; many changed in 2.0/2.1 (e.g. recipe `categories`, `get_contents()` returning arrays). + +### Testing without touching the user's save + +Run the game binary as a headless server with its own write-data and mod directory (`-c config.ini` +with `[path] write-data=…`, `--mod-directory` containing a symlink to the mod), created with +`--create test.zip` and started with `--start-server test.zip --enable-lua-udp `. Its +`--server-settings` must set `auto_pause: false` (build it from `data/server-settings.example.json`; +missing keys make the server refuse to start). Otherwise ticks stop with nobody connected and no +query is answered. Headless servers can't take screenshots. + +Reloading changes: `control.lua` changes need a save reload; `settings.lua`/`info.json` changes need +a full game restart. MCP server changes need a Claude Desktop restart or a new Claude Code session; +`/ask` starts a fresh MCP server per question. + +## Architecture + +**Request flow.** `GameLink` (`game_link.py`) sends `{"id", "op", "args"}` JSON to the game's UDP port. +The mod's `on_tick` calls `helpers.recv_udp()`. That raises `on_udp_packet_received`, which dispatches to +`handlers[op]` in `control.lua` and replies to the packet's source port. Replies are chunked as +`id|seq|total|` (32 KB chunks, 2 MB cap) and reassembled by `GameLink`. Each MCP tool is a thin +wrapper around one op. Op names don't always match tool names (`ratio`, `trends`, `production`, +`power`, `research`, `recipe`, `player`, `ping`). + +**Deferred replies.** A handler that needs to watch the game for a few ticks returns +`{__defer_ticks = n, __finish = fn}` instead of a result; `finish_deferred` runs `fn` and replies +later (`trace_belt` uses this to measure belt motion). These live in a plain local, not `storage`. + +**Multiplayer determinism.** UDP packets become input actions, so handlers run on every peer. +Handlers must be deterministic and only touch `storage` in ways every peer repeats. `send_udp` is +called with `for_player` in multiplayer (only the requesting instance answers) and `nil` in +singleplayer. + +**Read-only contract.** Handlers only read game state. The exceptions are `highlight` (rendering +objects, which are visual and expire) and `run_lua` (arbitrary code). `run_lua` is gated by +"Allow Lua queries", and in multiplayer also by the admin-only "Allow Lua queries in multiplayer". +Text from the world (station names, labels) can reach the model, which is why multiplayer Lua is +off by default. + +**Lua→JSON quirks.** `helpers.table_to_json` drops `nil` fields and encodes empty tables as `[]`, so +Python code reading results must use `.get()` and `or {}`. `serialize()` in `control.lua` turns +LuaObjects into small summaries; return plain values from handlers where possible. + +**`/ask` pipeline.** `/ask` (mod) → UDP to the helper port (mod setting, default 34201) → `ask.py` +acks immediately (the mod warns after 3 s without an ack) → `claude -p` with the question on stdin, +`--tools ""`, `--allowedTools mcp__factorio`, `--strict-mcp-config` and `--output-format stream-json`. +`tool_use` events become `ask_status` chat lines (the `STATUS` map) and the result becomes `ask_answer`. +Sessions are kept per player in memory, so a helper restart forgets conversations; `/ask-new` resets +one. The model comes from `FACTORIO_ASK_MODEL_OVERRIDE`, then the per-player mod setting, then `FACTORIO_ASK_MODEL`. +The helper and `GameLink` only accept packets from the game's UDP port. + +## Conventions + +- **Token budget is a design constraint.** Tools are registered with the local `tool()` decorator + (compact JSON text, no structured copy), and `_compact_schema` strips titles and null-unions + from the advertised schemas. Keep tool descriptions to a line or two; shared conventions belong + in `INSTRUCTIONS`. Keep outputs summarised (counts, nearest-N, merged sections) rather than + exhaustive. +- **Errors that should reach the model** are raised as `GameError` (a `ToolError` subclass). + Other exceptions show up as a generic "Error executing tool". In Lua, expected errors use + `error(msg, 0)` so no file:line prefix is added. +- **The MCP SDK is v2** (`mcp.server.MCPServer`, not `FastMCP`). +- **Adding a tool** touches the handler in `control.lua`, the wrapper in `server.py`, the `STATUS` + entry in `ask.py`, and the tools table in `README.md`. +- **Space Age:** mineable items are treated as mined in `calculate_ratios`, so asteroid recipes for + ore are never chosen. Recycling recipes are skipped.