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:
parent
7c8af2be96
commit
87399fdd79
6 changed files with 303 additions and 6 deletions
|
|
@ -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");
|
||||
|
|
|
|||
206
src/simulator/crates/mc-player-api/src/controllers.rs
Normal file
206
src/simulator/crates/mc-player-api/src/controllers.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue