Development¶
See also CONTRIBUTING.md for code style, commit conventions, and PR flow.
Local setup¶
You need the .NET SDK 8 and uv. No WorldBox install is required to
build or test. Unity references come from the UnityEngine.Modules NuGet package, and the mod
reaches the game's own code only through reflection.
# macOS. The Homebrew cask wants an interactive sudo, the formula does not.
brew install dotnet uv
export DOTNET_ROOT=/opt/homebrew/opt/dotnet/libexec DOTNET_ROLL_FORWARD=Major
# Linux
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 --install-dir ~/.dotnet
export DOTNET_ROOT="$HOME/.dotnet" PATH="$HOME/.dotnet:$HOME/.dotnet/tools:$PATH"
SDK 10 builds the net462 mod fine. The net8.0 test project and csharpier 0.x need
DOTNET_ROLL_FORWARD=Major on that setup.
Working on the mod¶
dotnet restore mod/WorldBoxBridge.sln --locked-mode
dotnet build mod/WorldBoxBridge.sln --configuration Release -warnaserror
dotnet tool install -g csharpier --version 0.30.6
dotnet csharpier --check mod
Build output: mod/src/WorldBoxBridge/bin/Release/WorldBoxBridge.dll. Note there is no
target-framework folder in that path: AppendTargetFrameworkToOutputPath is off.
--locked-mode is real here, packages.lock.json is committed for both projects. If you change
a package version, regenerate it or the build fails with NU1004:
Deploying to a local game install¶
Throughout, <worldbox> is your Steam install directory.
# macOS / Linux, by hand
WB="<worldbox>"
cp mod/src/WorldBoxBridge/bin/Release/WorldBoxBridge.dll "$WB/BepInEx/plugins/"
Then fully close and relaunch WorldBox, BepInEx loads plugins once at startup. On macOS and
Linux the game must be started through run_bepinex.sh, otherwise BepInEx never loads and nothing
tells you why. Set the Steam launch option to "<worldbox>/run_bepinex.sh" %command%.
Liveness probe, with the token the mod generated into its config:
TOKEN=$(sed -n 's/^token = //p' "<worldbox>/BepInEx/config/WorldBoxBridge.cfg")
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8723/health
Tests¶
The mod test suite (xUnit, 104 cases) covers the suggester, the agent registry, the request-context permission/fog-of-war helpers, the turn-order rotation (incl. concurrency), and the message bus (delivery, broadcast fan-out, cursoring, bounded-inbox drop-oldest), without the game. The pattern is "linked sources": pure-logic files from the mod project are referenced as <Compile Include="..\..\src\..." Link="..." /> in the test csproj so they compile under net8 without Unity. Anything that genuinely needs WorldBox to be running lives in the server-side e2e suite instead.
Decompiling the game¶
Open <worldbox>/worldbox_Data/Managed/Assembly-CSharp.dll (macOS: worldbox.app/Contents/Resources/Data/Managed/) in ILSpy. The mod itself never references this assembly, everything game-specific goes through reflection (GameRefs), which is what lets it build on a bare CI runner from the UnityEngine.Modules NuGet package alone. Record findings in game-api-notes.md.
Working on the server¶
--self-check validates that the server can be loaded and emits its tool schemas without needing the mod online.
Tests¶
The integration suite spins up a fake bridge in pure Python (aiohttp) that mimics the mod's HTTP contract, no game required.
End-to-end smoke tests¶
These need:
- WorldBox running with the latest mod installed.
WORLDBOX_MCP_TOKENexported (or auto-discoverable).
CI skips this suite by default.
Keeping the documented tool surface honest¶
The tool count is stated in six files, and it has drifted three times. scripts/gen-docs.py
makes that impossible. It imports the MCP server in-process, asks it which tools are
registered, counts the commands the mod declares, and compares both against the docs. No game
and no network are involved, so it runs anywhere the server installs.
cd server
uv run python ../scripts/gen-docs.py --check # what CI runs
uv run python ../scripts/gen-docs.py --write # refresh the generated regions
Two mechanisms, on purpose:
- Generated regions. Counts live between markers and
--writerewrites them:<!-- gen-docs:begin total -->29<!-- gen-docs:end total -->. Three regions exist,total,total-wordsfor the spelled-out headings, andbridge-commandsfor the C# side. Prose outside the markers is never touched, which is how the per-version asset counts, the argument columns and the error model survive. The count in compatibility.md is deliberately left alone: that row records what a released version shipped and must not move when the surface grows. - Inventory checks. The category tables carry editorial columns, so rewriting them would
cost more than it saves. They are verified instead.
README.md,index.md,multi-agent.mdandcommand-reference.mdmust each name every registered tool, and anyworldbox_-prefixed identifier anywhere in the docs must resolve to a real tool. An identifier that looks like a tool without being one, a payload field for instance, goes in the script'sNOT_A_TOOLset. That includes examples: naming a tool that does not exist fails the check, which is the point.
It also cross-checks the two sides of the bridge. The mod declares one command fewer than the
server exposes tools, because /capabilities is served by the HTTP layer rather than by an
ICommand. Any other gap means a tool was added on one side only.
The script itself is covered by server/tests/unit/test_gen_docs.py, which drives it against
a throwaway tree. A check that stays quiet when the docs drift would be worse than no check.
Adding a new MCP tool¶
- Mod side,
mod/src/WorldBoxBridge/Commands/<Category>/<Name>Command.cs: - Implement
ICommand, pick aCommandCategory(Meta, Discovery, Action, Read, Control, Bus) and setRequiresMainThread. - The signature is
Task<object?> ExecuteAsync(JObject args, RequestContext ctx, CancellationToken). Callctx.Require(Permission.X)first to gate it, andctx.CanSeeKingdomorctx.RequireKingdomAccessfor fog-of-war and faction binding. - Reuse
AssetCatalog.Resolvefor any asset id, which gives youdid_you_meanfor free, andWorldAccessforMapBox, units, kingdoms and cities. - Throw
BridgeRejectionExceptionfor structured errors.HttpBridgemaps it to the right status and envelope. - Category semantics matter: Action and Control are turn-gated in
turn_basedsessions, Meta, Discovery, Read and Bus are not. That is whyturn_advancelives in Meta rather than Control, otherwise a session could deadlock permanently. A gated command that unblocks the whole session rather than advancing one agent can opt out by name inTurnGate.AlwaysAllowed, which is howdismiss_windowworks. Permission gating still applies on top, so weigh that before reaching for it. - Register it in
Plugin.cs#RegisterCommands, one line. - Server side,
server/src/worldbox_mcp/tools/<category>.py: add a@server.tool(name="worldbox_<your_name>", description=...)function. The description is what the model reads to decide when to call your tool, so be concrete about inputs, outputs and edge cases. - Update command-reference.md, and multi-agent.md
if the tool is session-aware. Add it to the category table in
README.mdanddocs/index.mdtoo, they are checked for completeness. - Run
uv run python ../scripts/gen-docs.py --writefromserver/, which refreshes every stated count. Then--check, and fix whatever it still reports. CI runs the same check. - Build, deploy and smoke-test against a running game.
When something breaks¶
| Symptom | First thing to check |
|---|---|
| Mod doesn't load on launch | <worldbox>/BepInEx/LogOutput.log, look for WorldBoxBridge vX.Y.Z starting up.... If the line is missing, BepInEx never picked up the DLL, so it is in the wrong folder. On macOS and Linux, check you launched through run_bepinex.sh. |
| Log looks normal but the plugin never runs | Plugin load exceptions do not reach LogOutput.log. They only appear in Unity's own Player.log (macOS: ~/Library/Logs/mkarpenko/WorldBox/Player.log). This is how a bad dependency bump hides. See gotcha 10 in game-api-notes.md. |
Bridge says it is listening but /health refuses the connection |
Confirm the port is really bound (netstat). If the log says IsBound=True and the OS disagrees, you hit gotcha 1 or 2. |
| Every command times out after 30s | The MainThreadDispatcher is not running. Look for [dispatcher] injected into Unity PlayerLoop Update phase. If absent, gotcha 3. |
Asset id rejected with UNKNOWN_ASSET when you know it exists |
Call the matching list_* in the same session, the game may have renamed it, and use the did_you_mean suggestions. |
list_kingdoms / list_cities return 0 with kingdoms alive |
Gotcha 4. Check WorldAccess.GetSimpleList still iterates through IEnumerable. |
dotnet restore fails on UnityEngine.Modules |
The BepInEx feed is unreachable, or the exact-id source mapping was removed from mod/NuGet.config. |
dotnet restore fails with NU1004 |
packages.lock.json is stale after a version change. Run a --force-evaluate restore and commit the result. |
CI Lint mod says dotnet-csharpier does not exist |
csharpier 1.x got installed instead of 0.30.6. The pin is deliberate. |
CI Build mod fails after a WorldBox update |
The game moved to a new Unity version. Bump UnityEngine.Modules in Directory.Packages.props to match /health → unity_version. |
Releasing (maintainers)¶
Land work on main with Conventional Commits. release-please runs on every push and maintains a
chore(main): release X.Y.Z PR carrying the version bumps and the generated changelog. feat:
bumps the minor, fix: the patch, feat!: the major. Four version files are kept in sync through
extra-files in release-please-config.json.
Merge PRs with a merge commit, not a squash. The repo takes the PR title as the squash subject,
so squashing a PR titled deps: ... hides the feat: commits inside it and release-please skips
the minor bump.
Merging the release PR tags the version, creates the GitHub Release, and triggers two jobs:
publish-pypipublishes the wheel and sdist through PyPI trusted publishing.build-and-attach-modbuilds the DLL on the runner and attachesWorldBoxBridge-vX.Y.Z.zipplus its.sha256to the release.
Verify with gh release view vX.Y.Z --json assets and by checking the version on PyPI.
If build-and-attach-mod ever fails, the manual fallback is to build locally, stage
WorldBoxBridge.dll with install-mod.ps1, LICENSE and README.md into a WorldBoxBridge/
folder, zip it as WorldBoxBridge-v<version>.zip, write the SHA256 next to it, and
gh release upload "v<version>" <zip> <zip>.sha256 --clobber.
docs/compatibility.md is still updated by hand after a release.