From 29757bf9eaa5502a693644ea1904b5a49164a2a4 Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 18 May 2026 02:40:54 -0700 Subject: [PATCH] =?UTF-8?q?docs(game-engine):=20=F0=9F=93=9D=20Update=20pl?= =?UTF-8?q?ayer=20API=20and=20AI=20controller=20documentation=20with=20new?= =?UTF-8?q?=20methods,=20clarifications,=20and=20tooling=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- docs/modding/ai-controller.md | 123 +++++++++++++++++++++++++++++ src/game/engine/docs/PLAYER_API.md | 30 ++++++- tooling/claude/CLAUDE.md | 2 +- 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 docs/modding/ai-controller.md diff --git a/docs/modding/ai-controller.md b/docs/modding/ai-controller.md new file mode 100644 index 00000000..07dcc1a9 --- /dev/null +++ b/docs/modding/ai-controller.md @@ -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` 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 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 `" "` (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`. +- **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 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/.wasm \ + -o .opt.wasm +``` + +`wasm-opt` ships with [binaryen](https://github.com/WebAssembly/binaryen). Drop the optimised `.wasm` plus your `manifest.json` into: + +``` +~/Documents/MagicCivilization/mods// + manifest.json + .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). diff --git a/src/game/engine/docs/PLAYER_API.md b/src/game/engine/docs/PLAYER_API.md index 2b64f071..0cba94a4 100644 --- a/src/game/engine/docs/PLAYER_API.md +++ b/src/game/engine/docs/PLAYER_API.md @@ -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: ` 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` | `` | 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 diff --git a/tooling/claude/CLAUDE.md b/tooling/claude/CLAUDE.md index e746f309..26c685b3 100644 --- a/tooling/claude/CLAUDE.md +++ b/tooling/claude/CLAUDE.md @@ -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.