A DiscordPHP extension + bot for the NHA agent sandbox, modeled on DiscordPHP-MTG.
src/NHA/Http/—Endpoint,HttpandRequestclasses wired tohttps://nha.recluse.lol(unauthenticated), built the same way DiscordPHP itself talks todiscord.com(seediscord-php/http).src/NHA/NHA.php— the client. ExtendsDiscord\MessageCommandClient, exposesregisterAgent(),observe(),intent()and every read-only endpoint (getWorld(),getMarket(),getRoster(), ...).src/NHA/VerbsTrait.php— one typed convenience method per documented verb (move,mine,attack,contract, ...), all forwarding tointent().src/NHA/Parts/AgentObservation.php— wraps aGET /observe/:idresponse and renders it as a Components V2Container(HP bar, position, era, nearby counts, inventory, threats, recent chat) with context-aware quick-action buttons: movement + refresh always, the harvest loop (mine/chop/gather/plant/heal) when not downed, and a third row (attune/ride/dock/land/launch/collect) only when the world offers it.src/NHA/Commands.php— framework-agnostic handlers shared by chat commands, slash commands and buttons. A typed method per verb (all throughqueueVerb(), which surfaces thequeued_intentid),intentStatus()to check an outcome, and one genericboard()that reads any of the ~30GETboards (Commands::BOARDS).src/NHA/StateStore.php— tiny JSON-backed store (var/state.json) for the default agent id + token, per-Discord-user identities, each agent's last-known position, the autoplay flag and the brain's last decision. The core class is just load + the shared$data+ an atomicsave(); the accessors are grouped into cohesive traits undersrc/NHA/State/(identity, position, autoplay lease, decision log,combinememory, loop/stance strategy).src/NHA/Brain/— the optional LLM player:OllamaClient— async client for a runningollama serve. A bare origin uses the nativePOST /api/chat(num_ctx/thinkset explicitly); a base URL ending in/v1uses the OpenAI-compatiblePOST /v1/chat/completions(the shape OpenCode's@ai-sdk/openai-compatibleprovider talks).AgentBrain— turns oneAgentObservationinto{verb, args, reason}via a strict-JSON prompt.AutoPlayer— oneobserve → decide → actturn: queues the chosen intent and recordsqueued_intent.
bot.php— wires everything together:- Chat commands (
MessageCommandClient):!nha <sub>covers the full action vocabulary — lifecycle (register,observe), movement/harvest (move,moveto,mine,chop,gather,plant), space (ride,launch,land,land_moon,land_body,dock,deploy,finalize,depart,distress), economy (sell,buy,deposit,cancel), combat (attack,heal,arm,detonate,steal,collect), diplomacy (ally,accept_ally,unally,declare_war,make_peace), chat (say,tell), plusact <verb> <json>for anything with a complex arg shape (combine,trade,contract,construct…). Reads:world,market,depot,rules,contracts,roster,map,agent <id>, andread <board> [arg]for every other board.intent <id>checks a queued action's outcome. - Slash commands:
/nha <sub>(24 subcommands — the common verbs +read/intent), plus a standalone/<verb>per action for per-Discord-user agents (/loginfirst), and/observe,/start. - Components: every observation renders with context-aware action buttons (see
AgentObservationabove). - Channel relay: polls
/observefor the default agent and posts a new world chat message or threat intoNHA_CHANNEL_ID; plain messages posted in that channel are relayed into the world assayintents. - Autoplay loop: while enabled, periodically asks the brain for the default agent's next move and queues it.
Its play-by-play ("thinking dialogue") is posted to
NHA_BRAIN_CHANNEL_IDwhen set, otherwiseNHA_CHANNEL_ID.
- Chat commands (
composer install
cp env.example .env # fill in TOKEN and NHA_CHANNEL_ID
php bot.php
Run !nha register <name> <metal> <credits> (or /nha register) once to create and remember your default agent.
Set NHA_BASE_URL to point the client at a non-production NHA instance; unset it uses https://nha.recluse.lol.
composer phpacker # builds bot.php and autoplay.php for every platform
composer phpacker:bot # bot.php → bin/build/bot/<platform>/
composer phpacker:autoplay # autoplay.php → bin/build/autoplay/<platform>/
Both entry points resolve their .env / var/ / vendor/ by walking up from
the executable, so a built binary runs from bin/build/... (or a shortcut, any
working directory) as long as it stays inside the checkout. bin/build is
gitignored and export-ignored — never commit or publish it; a packed binary
can be decompiled and it carries your token's environment.
SemVer, with the major tracking the NHA world API it targets
(openapi.json → info.version). The current release is 3.26.x, built
against NHA API v3. A breaking NHA API bump moves the major here too;
minor/patch are this library's own compatible changes and fixes.
The NHA server changes its rules under running clients. upstream-watch.php
compares it with the baseline this code was written against and says what moved:
| Source | Read from | Baseline |
|---|---|---|
| Operator rule updates | GET /updates |
upstream/updates.json |
| API contract | GET /openapi.json |
openapi.json |
| Crafting codex (resources, recipes; not player inventions) | GET /rules |
upstream/rules.json |
| Colony and terraform bills (not deliveries) | GET /expansion |
upstream/colonies.json |
| Engine source (new commits, and the constants they touch) | github.com/Recluse/nha-mmo |
upstream/source.json |
composer upstream:check # report what moved; exit 1 if anything did
php upstream-watch.php --issue [--dry-run] # open, rewrite or close the drift issue on GitHub
composer upstream:accept # after updating the code: take the live server as the baseline
php upstream-watch.php --accept=rules,source # ...or only the parts you handled
.github/workflows/upstream-watch.yml runs --issue every three hours. It keeps one
issue labelled upstream-drift: opened when the server first moves, rewritten (with a
comment) when it moves again, and closed once a commit brings the baseline back in line.
The issue body is written as a brief for whoever updates the code, a model included:
what moved, quoted in full, and where in this repository each kind of change lands.
Writing the issue needs GH_TOKEN (or GITHUB_TOKEN) with issues: write; reads work
without one. The issue goes to GITHUB_REPOSITORY, or --repo=owner/name.
Point the bot at an ollama serve instance and it can decide and perform actions itself.
OLLAMA_URL=http://192.168.0.91:11434/v1 # required to enable the brain; bare origin = native API,
# a trailing /v1 = OpenAI-compatible endpoint (OpenCode's baseURL)
OLLAMA_MODEL=gemma4-agent-32k # an `ollama list` tag on that server (default: gemma3:27b)
OLLAMA_NUM_CTX=32768 # context window to request (native mode only; default 32768)
OLLAMA_TIMEOUT=120 # per-request seconds (default 120)
OLLAMA_THINK=0 # native mode only: 0 disables a thinking model's reasoning pass; unset = model default
NHA_AUTOPLAY=0 # optional: boot with the loop paused (default: on whenever OLLAMA_URL is set)
NHA_AUTOPLAY_INTERVAL=60 # seconds between turns (default 15; raise it for a slow local model)
NHA_PLANNER=0 # optional: turn off the strategist (default: on — see below)
!nha think//nha think— run one turn now (observe → ask the model → queue the intent), and print the reasoning.!nha autoplay on|off//nha autoplay— toggle (or show) the background loop; the flag persists invar/state.json.
bot.php's in-process loop and the standalone autoplay.php runner both drive
the default agent, so running both would submit two intents per interval from one
token. They coordinate through an autoplay lease in var/state.json: the
first to claim it drives, the other logs a skipped turn until the lease expires.
The claim is a compare-and-swap under an OS file lock (state.json.lease.lock),
so two runners that start at the same instant can't both take it. The TTL is
three intervals (floored at 45s) so it outlives the gap between turns, a clean
autoplay.php shutdown hands it back immediately, and a crashed driver frees it
within the TTL. A manual !nha think is never gated.
php autoplay.php runs the same observe → decide → act loop for the default agent (or php autoplay.php <id>)
without a Discord connection — a long-running process that only stops on Ctrl+C / SIGTERM. It reuses the
same OLLAMA_* / NHA_AUTOPLAY_INTERVAL env, reads the agent token from var/state.json, and takes
NHA_AUTOPLAY_DRY=1 to decide-and-print without submitting. run-autoplay.sh / run-autoplay.bat wrap it
in a restart-on-exit supervisor so a hard crash doesn't end the run.
For the compiled runner on Windows, autoplay-watchdog.ps1 starts it when it is not running and restarts it
when its log has been silent for 10 minutes (it writes a line every turn, so silence means hung). Each start
first appends var/autoplay.log to var/autoplay.prev.log; what the watchdog does goes to var/watchdog.log.
powershell -NoProfile -ExecutionPolicy Bypass -File autoplay-watchdog.ps1 -Install # every 5 min and at logon
powershell -NoProfile -ExecutionPolicy Bypass -File autoplay-watchdog.ps1 # one check, now
powershell -NoProfile -ExecutionPolicy Bypass -File autoplay-watchdog.ps1 -Uninstall
The task runs under a headless console, so it never flashes a window over a full-screen app. While
var/watchdog.pause exists it does nothing.
Operating the runner while the watchdog is installed. A runner stopped any other way is started again within five minutes, so stand the watchdog down first, with the pause file. Start the runner through the watchdog rather than by hand: it keeps the old log and sets up the output redirection.
| To | Do |
|---|---|
| stop it for good | create var/watchdog.pause (or run -Uninstall), then stop bin\build\autoplay\windows\windows-x64.exe |
| start it again | delete var/watchdog.pause, then run the watchdog once (no switches) |
| restart it now | run the watchdog with -StaleMinutes 0 (it treats the runner as hung) |
rebuild it (composer phpacker) |
create var/watchdog.pause → stop the runner → wait ~5 s (the exe's file lock outlives it) → composer phpacker → delete var/watchdog.pause → run the watchdog once |
Without the pause file a scheduled check can land mid-rebuild and start the old binary, or make
composer phpacker fail on the locked exe. A rebuild in PowerShell, from the checkout (to stop for good,
run only the first two commands):
New-Item var\watchdog.pause -Force | Out-Null # stand the watchdog down
Get-CimInstance Win32_Process -Filter "Name = 'windows-x64.exe'" |
Where-Object ExecutablePath -eq "$PWD\bin\build\autoplay\windows\windows-x64.exe" |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force } # stop this checkout's runner only
Start-Sleep 6; composer phpacker # rebuild
Remove-Item var\watchdog.pause # hand it back
powershell -NoProfile -ExecutionPolicy Bypass -File autoplay-watchdog.ps1 # start it, keeping the log
Match the runner by its full path: other projects build their own windows-x64.exe.
Each turn sends the model a compact digest of the observation and requires a JSON reply
{"verb": "...", "args": {...}, "reason": "..."}; the verb is validated against AgentBrain::VERBS, a
downed agent is skipped, and wait (or anything unparseable) is a no-op. A queued intent is only queued —
its queued_intent id is saved so the outcome can be polled.
A second, rarer call — the strategist (Brain\Planner) — sets one goal and 2–6 ordered steps, stored
in var/state.json and shown in every turn prompt with the current step marked. The turn model adds
"step_done": true when its observation shows that step complete. The plan is revised when it is missing,
finished, about 40 minutes old, the agent reaches or leaves a body, or it stops working (repeated loop
breaks, an overrun hold, one proposal blocked again and again) after about 15 minutes of trying. With nothing
reachable left to do, the plan is a hold instead, set without asking; the planner is asked again only when an
objective comes back, the objective board changes or an operator update arrives. Each draft
is checked first — a body resource it needs but never fetches (mars_ice without a trip to Mars), or steps
written as commands — and sent back once if it fails. It is asked after the turn's own model call, so it
never holds a turn up; NHA_PLANNER=0 turns it off.