feat(player-api): Introduce MultiSlot trait, SlotManager, and related controllers for unified multi-slot interactions; update wire protocol and dispatch logic; add comprehensive tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 23:59:30 -07:00
parent 7c8af2be96
commit 87399fdd79
6 changed files with 303 additions and 6 deletions

View file

@ -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");

View file

@ -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<Action>;
/// 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<Action> {
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<HashMap<String, Box<dyn AiController>>>;
fn registry() -> &'static Registry {
static REGISTRY: OnceLock<Registry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut map: HashMap<String, Box<dyn AiController>> = 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<String>, controller: Box<dyn AiController>) {
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<Action> {
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<String> {
let guard = registry()
.read()
.expect("controller registry RwLock poisoned");
let mut ids: Vec<String> = 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<Action> {
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);
}
}

View file

@ -243,9 +243,15 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
// (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<Vec<Event>,
///
/// 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<u8>` (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<u8> {
let mut out: Vec<u8> = 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::<u8>() {
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::<u8>() {
out.push(n);
}
}
}
out
}
fn victory_config_from_env() -> Option<mc_turn::VictoryConfig> {
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) {

View file

@ -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();

View file

@ -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<PlayerId>,
},
/// 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<PlayerId>,
/// 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"));

View file

@ -449,6 +449,7 @@ fn drive_game(out_dir: &Path, max_turns: u32) -> (Vec<TurnSummary>, 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<TurnSummary>, 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);