diff --git a/src/simulator/crates/mc-core/src/player_presentation.rs b/src/simulator/crates/mc-core/src/player_presentation.rs index b68c3644..e896a58a 100644 --- a/src/simulator/crates/mc-core/src/player_presentation.rs +++ b/src/simulator/crates/mc-core/src/player_presentation.rs @@ -30,6 +30,19 @@ pub struct PresentationPlayer { pub color: [u8; 4], /// True for the local human player; false for AI. pub is_human: bool, + /// Stage 3 (mod system) — controller registry id assigned to this + /// slot at game-setup time. `"scripted:default"`, `"learned:duel-v1b"`, + /// or any mod-supplied id. Empty on v1 saves loaded into v2 engines; + /// the loader maps empty → `scripted:default`. + #[serde(default)] + pub controller_id: String, + /// SHA-256 of the loaded controller's payload (WASM bytes or native + /// dylib bytes), or zero for built-in controllers. Recorded so saves + /// fingerprint exactly which mod was active — multiplayer / replay + /// loads reject mismatched payloads with a clear error instead of + /// silently desyncing. + #[serde(default)] + pub controller_hash: [u8; 32], } #[cfg(test)] @@ -53,6 +66,8 @@ mod tests { gender_preset: "male".into(), color: [51, 102, 255, 255], is_human: true, + controller_id: String::new(), + controller_hash: [0u8; 32], }; let json = serde_json::to_string(&p).expect("serialize"); let back: PresentationPlayer = serde_json::from_str(&json).expect("deserialize"); @@ -71,6 +86,8 @@ mod tests { gender_preset: "male".into(), color: [51, 102, 255, 255], is_human: true, + controller_id: String::new(), + controller_hash: [0u8; 32], }, PresentationPlayer { slot: 1, @@ -79,6 +96,8 @@ mod tests { gender_preset: "female".into(), color: [230, 51, 51, 255], is_human: false, + controller_id: "scripted:default".into(), + controller_hash: [0u8; 32], }, ]; let json = serde_json::to_string(&players).expect("serialize"); diff --git a/src/simulator/crates/mc-player-api/src/controllers.rs b/src/simulator/crates/mc-player-api/src/controllers.rs new file mode 100644 index 00000000..5a06e174 --- /dev/null +++ b/src/simulator/crates/mc-player-api/src/controllers.rs @@ -0,0 +1,206 @@ +//! Pluggable AI controllers (Stage 3 of the mod-system plan). +//! +//! Replaces the hardwired `mc_ai::tactical::run_ai_turn` call inside +//! `dispatch::drive_ai_slot` with a trait-object dispatch. Each AI slot +//! carries a `controller_id` (`"scripted:default"`, `"learned:duel-v1b"`, +//! mod-supplied ids…) that the dispatch looks up in this registry. +//! +//! The default `ScriptedController` wraps the existing MCTS+heuristic +//! pipeline. Mods (Stage 5) and the learned RL policy (Stage 6) register +//! additional implementations via [`register_controller`]. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +use mc_ai::evaluator::ScoringWeights; +use mc_ai::tactical::{Action, TacticalState}; + +/// Default controller id stamped on every AI slot at game-setup time. +/// `dispatch::drive_ai_slot` falls back to this id when a slot's +/// `controller_id` is empty or unregistered. +pub const DEFAULT_CONTROLLER_ID: &str = "scripted:default"; + +/// Sandbox kind a controller runs under. Recorded in the manifest +/// (Stage 5) and surfaced through [`AiControllerIdent`] so the save +/// envelope can fingerprint the controller for multiplayer + replay +/// integrity checks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SandboxKind { + /// Compiled into the engine (`scripted:*` and the in-box learned AI). + Builtin, + /// Loaded from a `.wasm` mod payload via `mc-mod-host` (Stage 5). + Wasm, + /// Loaded from a signed native `.so` / `.dll` / `.dylib`. + Native, +} + +/// Stable identity card a controller emits via [`AiController::ident`]. +/// Used by save / replay / multiplayer-lobby code to fingerprint which +/// AI a slot was running so reloading a save without the same mod +/// installed surfaces a friendly error instead of a desync. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AiControllerIdent { + /// Registration key (e.g. `"scripted:default"`, `"learned:duel-v1b"`). + pub id: String, + /// Human-facing version string. Mods bump this when their behaviour + /// changes; the engine treats different versions as distinct ids + /// for save-compatibility purposes. + pub version: String, + /// Sandbox the controller executes inside. + pub sandbox: SandboxKind, +} + +/// Pluggable per-slot AI decision maker. One instance per registered +/// `controller_id`; called once per AI turn from `drive_ai_slot`. +/// +/// Implementations MUST be deterministic for a given `(state, slot, seed)` +/// tuple — replay, save-load, and multi-seed evaluation all rely on this. +pub trait AiController: Send + Sync { + /// Decide the action chain this slot wants to execute this turn. + /// Mirrors the existing `mc_ai::tactical::run_ai_turn` signature so + /// the scripted wrapper is a one-line delegate. + fn decide_turn( + &self, + state: &TacticalState, + slot: u8, + weights: &ScoringWeights, + seed: u64, + ) -> Vec; + + /// Identity card for save / replay / lobby integrity checks. + fn ident(&self) -> AiControllerIdent; +} + +/// Default in-box controller — wraps `mc_ai::tactical::run_ai_turn` so +/// no behavioural change vs the pre-Stage-3 dispatch path. +pub struct ScriptedController; + +impl AiController for ScriptedController { + fn decide_turn( + &self, + state: &TacticalState, + slot: u8, + weights: &ScoringWeights, + seed: u64, + ) -> Vec { + mc_ai::tactical::run_ai_turn(state, slot, weights, seed) + } + + fn ident(&self) -> AiControllerIdent { + AiControllerIdent { + id: DEFAULT_CONTROLLER_ID.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + sandbox: SandboxKind::Builtin, + } + } +} + +type Registry = RwLock>>; + +fn registry() -> &'static Registry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut map: HashMap> = HashMap::new(); + map.insert( + DEFAULT_CONTROLLER_ID.to_string(), + Box::new(ScriptedController), + ); + RwLock::new(map) + }) +} + +/// Register (or replace) a controller under `id`. Stage 5's `mod_loader` +/// calls this once per `ai_controller` mod at engine init; tests can +/// register fakes here. +pub fn register_controller(id: impl Into, controller: Box) { + let mut guard = registry() + .write() + .expect("controller registry RwLock poisoned"); + guard.insert(id.into(), controller); +} + +/// Drive a turn for `slot` via the controller registered for `id`, +/// falling back to [`DEFAULT_CONTROLLER_ID`] when `id` is empty or +/// unknown. Centralises the lookup so `dispatch::drive_ai_slot` stays +/// a one-line registry call. +pub fn drive_controller_turn( + id: &str, + state: &TacticalState, + slot: u8, + weights: &ScoringWeights, + seed: u64, +) -> Vec { + let guard = registry() + .read() + .expect("controller registry RwLock poisoned"); + let lookup_id = if id.is_empty() || !guard.contains_key(id) { + DEFAULT_CONTROLLER_ID + } else { + id + }; + guard + .get(lookup_id) + .expect("default controller must always be registered") + .decide_turn(state, slot, weights, seed) +} + +/// List every registered controller id. Stage 8's game-setup UI uses +/// this to populate the per-slot AI picker. +pub fn registered_ids() -> Vec { + let guard = registry() + .read() + .expect("controller registry RwLock poisoned"); + let mut ids: Vec = guard.keys().cloned().collect(); + ids.sort(); + ids +} + +#[cfg(test)] +mod tests { + use super::*; + + struct CountingController; + + impl AiController for CountingController { + fn decide_turn( + &self, + _state: &TacticalState, + _slot: u8, + _weights: &ScoringWeights, + _seed: u64, + ) -> Vec { + Vec::new() + } + + fn ident(&self) -> AiControllerIdent { + AiControllerIdent { + id: "test:counting".to_string(), + version: "0.1.0".to_string(), + sandbox: SandboxKind::Builtin, + } + } + } + + #[test] + fn default_controller_is_preregistered() { + let ids = registered_ids(); + assert!( + ids.iter().any(|id| id == DEFAULT_CONTROLLER_ID), + "scripted:default must be auto-registered, got {ids:?}" + ); + } + + #[test] + fn register_then_dispatch_routes_to_new_controller() { + register_controller("test:counting", Box::new(CountingController)); + let ids = registered_ids(); + assert!(ids.iter().any(|id| id == "test:counting")); + } + + #[test] + fn scripted_controller_ident_matches_default_id() { + let ident = ScriptedController.ident(); + assert_eq!(ident.id, DEFAULT_CONTROLLER_ID); + assert_eq!(ident.sandbox, SandboxKind::Builtin); + } +} diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 0ddb418b..a609ffc5 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -243,9 +243,15 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result, // (3) apply each Action via apply_ai_action (which routes back through // this same apply_action dispatcher for DRY semantics). // The scripted heuristic that lived here is gone — p2-68's whole point. + // Stage 4 (multi-slot adapter) — skip every externally-driven slot, + // not just the one that called EndTurn. Without this, in a 5-learned + // vs 5-scripted FFA the harness's internal AI would still run for + // four of the learned slots and step on the Python-side decisions. + // `player` is always included in the skip set (it just ended its turn). + let external_slots = external_player_slots_from_env(); for ai_slot in 0..state.players.len() { let ai_slot_u8: u8 = ai_slot as u8; - if ai_slot_u8 == player { + if ai_slot_u8 == player || external_slots.contains(&ai_slot_u8) { continue; } let ai_actions: u32 = drive_ai_slot(state, ai_slot_u8); @@ -325,6 +331,41 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result, /// /// Unknown values are ignored (return `None`) so a typo doesn't silently /// alter behaviour mid-training. +/// Stage 4 (multi-slot adapter) — slots driven by the external client. +/// +/// Read from `CP_PLAYER_SLOTS` (comma-separated, e.g. `"0,2,3"`) and +/// fall back to `CP_PLAYER_SLOT` (single value) for back-compat with +/// the single-slot wire. Returned as a `Vec` (small N: ≤ MAX_PLAYERS); +/// callers test membership via linear scan. +/// +/// Empty result = no slots are externally driven. `apply_end_turn` then +/// retains the original "skip only the calling player" semantics so +/// fixtures and unit tests without env vars set keep working unchanged. +fn external_player_slots_from_env() -> Vec { + let mut out: Vec = Vec::new(); + if let Ok(plural) = std::env::var("CP_PLAYER_SLOTS") { + for piece in plural.split(',') { + let trimmed = piece.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(n) = trimmed.parse::() { + if !out.contains(&n) { + out.push(n); + } + } + } + } + if out.is_empty() { + if let Ok(single) = std::env::var("CP_PLAYER_SLOT") { + if let Ok(n) = single.trim().parse::() { + out.push(n); + } + } + } + out +} + fn victory_config_from_env() -> Option { let mode = std::env::var("CP_VICTORY_MODE").ok()?; match mode.as_str() { @@ -500,7 +541,19 @@ fn drive_ai_slot(state: &mut GameState, ai_slot: u8) -> u32 { tactical.current_player = ai_slot; let weights = state.players[pi].scoring_weights.clone(); let seed = seed_for_ai_turn(state.turn, ai_slot); - let actions = mc_ai::tactical::run_ai_turn(&tactical, ai_slot, &weights, seed); + // Stage 3 (mod system) — route through the controller registry + // instead of hardcoding the scripted MCTS path. Empty / unknown + // ids fall back to `DEFAULT_CONTROLLER_ID` inside + // `drive_controller_turn` so legacy fixtures without + // `controller_id` set keep working unchanged. + let controller_id = state.players[pi].controller_id.clone(); + let actions = crate::controllers::drive_controller_turn( + &controller_id, + &tactical, + ai_slot, + &weights, + seed, + ); let mut applied: u32 = 0; for action in actions { match apply_ai_action(state, ai_slot, action) { diff --git a/src/simulator/crates/mc-player-api/src/lib.rs b/src/simulator/crates/mc-player-api/src/lib.rs index 26c4f77d..dd3c4017 100644 --- a/src/simulator/crates/mc-player-api/src/lib.rs +++ b/src/simulator/crates/mc-player-api/src/lib.rs @@ -17,12 +17,17 @@ #![allow(clippy::module_name_repetitions)] pub mod action; +pub mod controllers; pub mod dispatch; pub mod error; pub mod projection; pub mod view; pub mod wire; +pub use controllers::{ + register_controller, registered_ids, AiController, AiControllerIdent, SandboxKind, + ScriptedController, DEFAULT_CONTROLLER_ID, +}; pub use dispatch::{apply_action, apply_ai_action}; pub use projection::{project_tactical, project_view, project_view_with_vision}; @@ -53,6 +58,7 @@ mod tests { fn end_turn_round_trips_via_act_request() { let req = Request::Act { id: Some(7), + slot: None, action: PlayerAction::EndTurn, }; let line = serde_json::to_string(&req).unwrap(); diff --git a/src/simulator/crates/mc-player-api/src/wire.rs b/src/simulator/crates/mc-player-api/src/wire.rs index 3d36412e..b506420a 100644 --- a/src/simulator/crates/mc-player-api/src/wire.rs +++ b/src/simulator/crates/mc-player-api/src/wire.rs @@ -25,12 +25,21 @@ pub enum Request { /// Optional correlation id. #[serde(default, skip_serializing_if = "Option::is_none")] id: RequestId, + /// Stage 4 (multi-slot adapter) — which player slot this request + /// targets. `None` ↔ JSON-absent ↔ first entry of + /// `CP_PLAYER_SLOTS` (back-compat with the single-slot wire). + #[serde(default, skip_serializing_if = "Option::is_none")] + slot: Option, }, /// Take one player action. Act { /// Optional correlation id. #[serde(default, skip_serializing_if = "Option::is_none")] id: RequestId, + /// Stage 4 (multi-slot adapter) — which player slot this action + /// applies to. `None` ↔ first entry of `CP_PLAYER_SLOTS`. + #[serde(default, skip_serializing_if = "Option::is_none")] + slot: Option, /// Action payload. action: PlayerAction, }, @@ -339,14 +348,14 @@ mod tests { #[test] fn view_request_serialises_without_id() { - let req = Request::View { id: None }; + let req = Request::View { id: None, slot: None }; let json = serde_json::to_string(&req).unwrap(); assert_eq!(json, "{\"type\":\"view\"}"); } #[test] fn view_request_with_id_round_trips() { - let req = Request::View { id: Some(5) }; + let req = Request::View { id: Some(5), slot: None }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("\"type\":\"view\"")); assert!(json.contains("\"id\":5")); diff --git a/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs b/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs index 08fe4d4d..dcb39806 100644 --- a/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs +++ b/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs @@ -449,6 +449,7 @@ fn drive_game(out_dir: &Path, max_turns: u32) -> (Vec, DriveOutcome next_req_id += 1; let view_req = Request::View { id: Some(view_req_id), + slot: None, }; write_jsonl(&mut transcript, &view_req); @@ -478,6 +479,7 @@ fn drive_game(out_dir: &Path, max_turns: u32) -> (Vec, DriveOutcome next_req_id += 1; let act_req = Request::Act { id: Some(act_req_id), + slot: None, action: action.clone(), }; write_jsonl(&mut transcript, &act_req); @@ -1444,7 +1446,7 @@ fn drive_strong_claude_game( // dispatch each `mc_ai::Action` directly via `apply_ai_action`. let view_req_id = next_req_id; next_req_id += 1; - let view_req = Request::View { id: Some(view_req_id) }; + let view_req = Request::View { id: Some(view_req_id), slot: None }; write_jsonl(&mut transcript, &view_req); let view = project_view(&state, 0, false); let view_resp = Response::Ok { @@ -1519,6 +1521,7 @@ fn drive_strong_claude_game( next_req_id += 1; let act_req = Request::Act { id: Some(act_req_id), + slot: None, action: PlayerAction::EndTurn, }; write_jsonl(&mut transcript, &act_req); @@ -1927,7 +1930,7 @@ fn drive_real_mcts_claude_game( // Snapshot. let view_req_id = next_req_id; next_req_id += 1; - let view_req = Request::View { id: Some(view_req_id) }; + let view_req = Request::View { id: Some(view_req_id), slot: None }; write_jsonl(&mut transcript, &view_req); let view = project_view(&state, 0, false); let view_resp = Response::Ok { @@ -1956,6 +1959,7 @@ fn drive_real_mcts_claude_game( next_req_id += 1; let act_req = Request::Act { id: Some(act_req_id), + slot: None, action: PlayerAction::EndTurn, }; write_jsonl(&mut transcript, &act_req);