magicciv/docs/modding/ai-controller.md

130 lines
6.8 KiB
Markdown
Raw Permalink Normal View History

# 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.
### Fog of war
The `TacticalState` you receive is **already vision-filtered** for your bound player. Enemy units and cities whose hex is not in your player's `visible` set are omitted from `players[other].units` / `.cities`; tile resources outside `explored` are stripped. Your own slot is always surfaced in full. The host computes vision via `mc_vision::compute_vision` and feeds the filtered projection through `project_tactical_with_vision` (`mc-player-api/src/projection.rs`). Plan around this — your controller does **not** see the whole map, and assuming otherwise will produce moves that target hexes you never explored.
The `CP_OMNISCIENT=1` env flag on the host disables filtering for debug repros only; a shipped mod must never rely on it.
## `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).