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:
parent
18baeacc03
commit
29757bf9ea
3 changed files with 153 additions and 2 deletions
123
docs/modding/ai-controller.md
Normal file
123
docs/modding/ai-controller.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue