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>
105 lines
6.4 KiB
Markdown
105 lines
6.4 KiB
Markdown
# 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.
|