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 <noreply@anthropic.com>
This commit is contained in:
faabandClaude Opus 5.5 committed 2026-10-29 21:44:52 +01:00
1 parent c5b2f6d603
commit ac35e3ff2c
1 file changed
+104
+104
View File
@@ -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__<tool>`.
- `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 <port>`. 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|<json>` (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.