docs(game-engine): 📝 Update player API and AI controller documentation with new methods, clarifications, and tooling references

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 02:40:54 -07:00
parent 18baeacc03
commit 29757bf9ea
3 changed files with 153 additions and 2 deletions

View file

@ -0,0 +1,123 @@
# Writing a WASM AI Controller
Audience: Rust developers who want to ship a custom AI for Magic Civilization. This document is the **interface contract** — the host engine promises to call your module exactly as described here, and your module promises to honour these exports. The reference implementation lives in `learned-duel-v1b` (see below).
Status: contract is stable for Stage 5b onward. Items flagged **TBD** are subject to change before public release.
## What an AI controller does
Once per AI turn, the engine serialises the visible tactical state, hands it to your WASM module, and asks for a plan. You return an ordered list of `Action`s; the engine applies them in sequence, validates each against the authoritative simulator, and discards any that fail validation. Your module never mutates game state directly — it only proposes actions.
**Determinism is mandatory.** Given the same `(state, slot, seed)` triple, your controller MUST emit the same `Vec<Action>` byte-for-byte every time. The host uses this property for replay, netcode rollback, and save fingerprints. Use the supplied `seed` for any randomness; do not call host clocks or system RNG (none are exposed).
## Required WASM exports
Your module must export three symbols. Rust pseudocode signatures:
```rust
// Called once at load. Return non-zero to signal "ready"; zero rejects the module.
// No state passed in; do lazy init on first decide_turn if needed.
#[no_mangle] pub extern "C" fn init() -> u32;
// The hot path. Called once per AI turn.
// state_ptr..state_ptr+state_len : postcard-serialised TacticalState in linear memory
// slot : which player slot you are deciding for
// seed : deterministic PRNG seed for this turn
// out_ptr..out_ptr+out_cap : scratch buffer the host allocated for your reply
// Write a postcard-serialised Vec<Action> into out_ptr; return bytes written, or
// a negative i32 on error.
#[no_mangle] pub extern "C" fn decide_turn(
state_ptr: i32, state_len: i32,
slot: u32, seed: u64,
out_ptr: i32, out_cap: i32,
) -> i32;
```
Plus an identity string. WASM has no native string return, so expose two globals at fixed names; the host reads `__ident_len` bytes starting at `__ident_ptr`:
```rust
#[no_mangle] pub static __ident_ptr: i32 = /* address of a static b"name 1.2.3" */;
#[no_mangle] pub static __ident_len: i32 = /* its length */;
```
The string format is `"<name> <semver>"` (e.g. `"learned-duel 1.0.0-b"`). It feeds into `controller_id` mapping and save fingerprints.
## Marshalling: postcard
The wire format on both sides is [postcard](https://docs.rs/postcard) — a no_std `serde` codec that is deterministic, compact, and schema-compatible with the Rust `TacticalState` / `Action` types. The host serialises once per turn, the guest deserialises once; same crate version on both sides means zero shape mismatch.
Backwards compatibility: adding a field to `TacticalState` is safe **only** if the host annotates the new field with `#[serde(default)]`. This is a host-side guarantee — you, the mod author, don't have to do anything, but you should pin the version of the `mc-tactical-types` crate your mod was built against so a newer host doesn't silently drop fields you depend on.
## `manifest.json`
Ship your `.wasm` next to a `manifest.json`:
```json
{
"id": "learned-duel-v1b",
"name": "Learned Duel (1v1 sparring AI)",
"author": "your-handle",
"version": "1.0.0-b",
"kind": "ai_controller",
"controller_id": "learned:duel-v1b",
"sandbox": "wasm",
"signature": null
}
```
`sandbox: "native"` is permitted for `.so` / `.dylib` / `.dll` controllers but the `signature` field then becomes required — an ed25519 signature of the binary against the engine publisher pubkey. Unsigned native mods are refused at load time. **TBD:** the public key distribution channel is not yet finalised.
## Sandbox guarantees and limits
The host runs WASM mods under [Wasmtime](https://wasmtime.dev/) with:
- **Fuel budget per turn**: 100M instructions (**TBD — subject to change before release**). Exceeding the budget cancels the turn; the engine treats it as if your controller returned an empty `Vec<Action>`.
- **Linear memory cap**: 16 MB (**TBD**).
- **No host imports**: no clocks, no filesystem, no network, no host RNG. The `seed` argument is your only entropy source.
- **Epoch-based interruption**: the host can cancel a stuck turn mid-execution without UB.
Native mods get none of these protections, which is why they require a signature.
## Reference mod
`public/games/age-of-dwarves/mods/learned-duel-v1b.wasm` is the canonical reference controller (lands in Stage 6). Open-source repo: **TBD — URL placeholder until the mod template repo is published.**
A minimum viable controller — pass turns and do nothing — looks like:
```rust
#[no_mangle] pub extern "C" fn init() -> u32 { 1 }
#[no_mangle] pub extern "C" fn decide_turn(
_sp: i32, _sl: i32, _slot: u32, _seed: u64,
out_ptr: i32, _out_cap: i32,
) -> i32 {
// postcard-encoded empty Vec<Action> is a single 0x00 byte (length prefix = 0).
unsafe { *(out_ptr as *mut u8) = 0; }
1
}
```
## Building your mod
```
cargo build --release --target wasm32-unknown-unknown
wasm-opt -Oz target/wasm32-unknown-unknown/release/<name>.wasm \
-o <name>.opt.wasm
```
`wasm-opt` ships with [binaryen](https://github.com/WebAssembly/binaryen). Drop the optimised `.wasm` plus your `manifest.json` into:
```
~/Documents/MagicCivilization/mods/<id>/
manifest.json
<name>.opt.wasm
```
The engine scans that directory at startup and registers any well-formed `ai_controller` mods. For the FFI helpers many mod authors lean on (memory allocation hooks, panic handlers), [wasm-bindgen](https://docs.rs/wasm-bindgen) is supported but not required — the three exports above are the only contract.
## Open questions (TBD)
- Final fuel + memory caps.
- Native-mod signing key distribution.
- Reference-mod public repo URL.
- Versioning policy for `TacticalState` / `Action` (host promises `#[serde(default)]` on new fields; we still need a deprecation policy for removals).

View file

@ -58,6 +58,14 @@ field. If the adapter omits `id`, the harness echoes `null`.
{"id": 45, "type": "shutdown"}
```
`view` and `act` also accept an optional `slot: <PlayerId>` field. When the
harness drives more than one external slot (see `CP_PLAYER_SLOTS` below) the
adapter selects which slot a request targets with `{"type":"view","slot":2}`
/ `{"type":"act","slot":2,"action":...}`. Omitting `slot` is back-compat:
the harness routes to the single bound slot (legacy `CP_CLAUDE_SLOT`
behaviour). The wire field is `#[serde(default)]` on `Request::View` /
`Request::Act` so older clients keep working unchanged.
**Response** (harness → adapter):
```json
{"id": 42, "ok": true, "view": { ... }}
@ -332,7 +340,9 @@ Events about hidden enemy units / hidden tiles are dropped.
|---|---|---|
| `CP_SEED` | `42` | Seed passed to `MapGenerator` and `GameState.initialize_game` |
| `CP_PLAYERS` | `2` | Total player slots |
| `CP_CLAUDE_SLOT` | `0` | Which slot Claude controls; others run `AiTurnBridge` |
| `CP_CLAUDE_SLOT` | `0` | Legacy single-slot binding; superseded by `CP_PLAYER_SLOTS` |
| `CP_PLAYER_SLOTS` | `<CP_CLAUDE_SLOT>` | Comma-separated `PlayerId` list — slots driven by the external adapter. The AI loop skips every listed slot; `view`/`act` requests route by their `slot` field. |
| `CP_PLAYER_CONTROLLERS` | unset | Comma-separated `controller_id` list, one per remaining AI slot. `player_api_main.gd` calls `GdGameState.set_player_controller(slot, id)` for each non-external slot at startup. Unknown ids fall back to `scripted:default`. |
| `CP_MAP_SIZE` | `duel` | `MapGenerator` size key |
| `CP_MAP_TYPE` | `continents` | `MapGenerator` map type |
| `CP_TURN_LIMIT` | `100` | Auto-end the game at this turn (max) |
@ -340,6 +350,24 @@ Events about hidden enemy units / hidden tiles are dropped.
| `CP_OMNISCIENT` | `0` | `1` disables fog redaction (debug only) |
| `CP_LOG_FILE` | unset | If set, harness mirrors all wire I/O to this path |
## AI controller registry
Every AI-driven slot resolves to an `AiController` implementation via a
`controller_id`-keyed registry in `mc_player_api::controllers`. The in-box
`ScriptedController` (`controller_id = "scripted:default"`,
`DEFAULT_CONTROLLER_ID`) is the fallback for any slot whose id is unset or
unrecognised; mod-supplied controllers `register_controller(id, factory)`
during engine init and become available to `registered_ids()` /
`GdGameState.registered_controller_ids()` (exposed to the GDScript UI
picker in `game_setup.tscn`). `PlayerState.controller_id: String` carries
the choice through the simulator and into `PresentationPlayer` (plus a
`controller_hash` for save-determinism); `dispatch::drive_ai_slot` looks
the id up and hands the slot to `drive_controller_turn`. The save
envelope reflects this — `SaveEnvelope::CURRENT_VERSION` bumped 1 → 2 to
cover the new field. From GDScript, set per-slot bindings with
`GdGameState.set_player_controller(slot, controller_id)` (mirrored by
`HarnessConfig.player_controllers` on the Python side).
## Client integration: MCP server for Claude Code
The canonical client is **Claude Code**, talking to a thin MCP server that

View file

@ -10,7 +10,7 @@ Fantasy 4X turn-based strategy game in Godot 4 + Rust, hex grid.
## Five Non-Negotiable Rails (Always Active)
1. **Rust is the simulation source of truth.** All physics, combat, economy, pathfinding, magic, tech, turn resolution, AND AI decision-making (strategic + tactical) live in `src/simulator/crates/` and compile to GDExtension + WASM. GDScript AI files (`simple_heuristic_ai.gd`, `ai_tactical.gd`, `ai_military.gd`) are tech-debt tracked by `p0-26-ai-tactical-rust-port.md`, not a permanent exception. New AI work lands in `mc-ai` + `api-gdext/src/ai.rs` behind a `GdAiController` / `GdMcTreeController` bridge.
1. **Rust is the simulation source of truth.** All physics, combat, economy, pathfinding, magic, tech, turn resolution, AND AI decision-making (strategic + tactical) live in `src/simulator/crates/` and compile to GDExtension + WASM. GDScript AI files (`simple_heuristic_ai.gd`, `ai_tactical.gd`, `ai_military.gd`) are tech-debt tracked by `p0-26-ai-tactical-rust-port.md`, not a permanent exception. New AI work lands in `mc-ai` + `api-gdext/src/ai.rs` behind a `GdAiController` / `GdMcTreeController` bridge. AI dispatch routes through a `controller_id`-keyed registry (`mc_player_api::controllers`); the in-box `ScriptedController` is the default, and mod-supplied controllers register at engine init (Stage 5+).
2. **JSON game packs are the canonical content store.** All stats, costs, effects, thresholds in `public/games/age-of-dwarves/data/*.json`. Neither Rust nor GDScript hardcodes game content.
3. **GDScript is presentation only.** Rendering, UI, input, signals, and thin GDExtension wrappers. No simulation logic.
4. **TTS voice is `ravdess02`.** Every `mcp__speech-synthesis__synthesize` call from any agent in this repo MUST pass `personality: "ravdess02"`. Never default, never omit.