From d968ecb2ca3a3a2590675c72074e98aee9abbde9 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 20 May 2026 18:12:13 -0700 Subject: [PATCH] =?UTF-8?q?feat(player-api):=20=E2=9C=A8=20Add=20suggest?= =?UTF-8?q?=20module=20and=20wire=20protocol=20serialization=20for=20playe?= =?UTF-8?q?r=20API=20suggestions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../crates/mc-player-api/src/dispatch.rs | 168 ++++++++++++++++++ src/simulator/crates/mc-player-api/src/lib.rs | 2 +- .../crates/mc-player-api/src/wire.rs | 35 ++++ .../crates/mc-player-api/tests/common/mod.rs | 2 + 4 files changed, 206 insertions(+), 1 deletion(-) diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 82b09f87..f7a000e6 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -1158,6 +1158,133 @@ fn locate_unit_hex(state: &GameState, unit_u32: u32) -> Option<(i32, i32)> { None } +/// Stage 6.1.6 — translate a tactical AI [`mc_ai::tactical::Action`] into +/// its [`PlayerAction`] wire form WITHOUT applying it. +/// +/// This is the pure, side-effect-free counterpart of [`apply_ai_action`]: +/// it builds exactly the same `PlayerAction` each `apply_ai_action` arm +/// would dispatch, but stops short of calling `apply_action`. Used by +/// [`suggest_actions`] to expose "what would the scripted AI do" over the +/// wire for the behavioural-cloning recorder. +/// +/// Returns `None` for AI-action variants with no `PlayerAction` analogue +/// (`AssignCitizen`, `DeploySiege`, `PackSiege`, `Bombard`) — those are +/// silent no-ops in `apply_ai_action`, so dropping them from the +/// suggested chain does not change how the game state would evolve. +fn ai_action_to_player_action( + state: &GameState, + action: &mc_ai::tactical::Action, +) -> Option { + use mc_ai::tactical::Action as AiAction; + match action { + AiAction::MoveUnit { unit_id, to_hex } => Some(PlayerAction::Move { + unit_id: unit_id.to_string(), + to: [to_hex.0, to_hex.1], + }), + AiAction::AttackTarget { attacker_id, target_id, .. } => { + // Mirror apply_ai_action: resolve target_id → hex. If the + // target unit is gone, the action is not representable — + // skip it (apply_ai_action would surface UnknownUnit). + let target_hex = locate_unit_hex(state, *target_id)?; + Some(PlayerAction::Attack { + unit_id: attacker_id.to_string(), + target: [target_hex.0, target_hex.1], + }) + } + AiAction::Fortify { unit_id } => Some(PlayerAction::Fortify { + unit_id: unit_id.to_string(), + }), + // Heal maps to Skip, exactly as in apply_ai_action. + AiAction::Heal { unit_id } => Some(PlayerAction::Skip { + unit_id: unit_id.to_string(), + }), + AiAction::FoundCity { settler_id, .. } => Some(PlayerAction::FoundCity { + unit_id: settler_id.to_string(), + }), + AiAction::SetProduction { city_id, item_id } => { + Some(PlayerAction::QueueProduction { + city_id: city_id.to_string(), + item: item_id.clone(), + tile: None, + }) + } + AiAction::EnqueueBuild { city_id, item_id, .. } => { + Some(PlayerAction::QueueProduction { + city_id: city_id.to_string(), + item: item_id.clone(), + tile: None, + }) + } + // Scout maps to Move, exactly as in apply_ai_action. + AiAction::Scout { unit_id, to_hex } => Some(PlayerAction::Move { + unit_id: unit_id.to_string(), + to: [to_hex.0, to_hex.1], + }), + AiAction::IssuePatrol { unit_id, waypoints } => { + Some(PlayerAction::IssuePatrol { + unit_id: unit_id.to_string(), + waypoints: waypoints.iter().map(|(c, r)| [*c, *r]).collect(), + }) + } + AiAction::PromotionPicked { unit_id, promotion_id } => { + Some(PlayerAction::Promote(crate::action::PromotionPick { + unit_id: unit_id.to_string(), + promotion_id: promotion_id.clone(), + })) + } + // No PlayerAction analogue — silent no-ops in apply_ai_action. + AiAction::AssignCitizen { .. } + | AiAction::DeploySiege { .. } + | AiAction::PackSiege { .. } + | AiAction::Bombard { .. } => None, + } +} + +/// Stage 6.1.6 — compute the scripted controller's action chain for +/// `slot` against the CURRENT state, WITHOUT applying any action or +/// advancing the turn. +/// +/// This is the read-only sibling of [`drive_ai_slot`]: it runs the exact +/// same projection + controller pipeline (`compute_vision` → +/// `project_tactical_with_vision` → `drive_controller_turn`) so the +/// suggestion is identical to what the engine would actually play for +/// that slot — but it returns the action chain as `PlayerAction`s +/// instead of mutating `GameState`. +/// +/// Takes `&GameState` (shared, not `&mut`): every call in the pipeline +/// is read-only. Determinism follows from `seed_for_ai_turn(turn, slot)` +/// being a pure function — calling `suggest_actions` twice on the same +/// state returns identical results and never touches the state. +pub fn suggest_actions(state: &GameState, slot: PlayerId) -> Vec { + let pi: usize = slot as usize; + if pi >= state.players.len() { + return Vec::new(); + } + // Mirror drive_ai_slot exactly: project through the slot's own + // vision so the suggestion is fog-consistent with what an AI turn + // would see. + let vision_state = + mc_vision::compute_vision(state, &mc_vision::VisionCatalog::default(), None); + let pv = vision_state.for_player(slot); + let mut tactical = + crate::projection::project_tactical_with_vision(state, slot, pv); + tactical.current_player = slot; + let weights = state.players[pi].scoring_weights.clone(); + let seed = seed_for_ai_turn(state.turn, slot); + let controller_id = state.players[pi].controller_id.clone(); + let actions = crate::controllers::drive_controller_turn( + &controller_id, + &tactical, + slot, + &weights, + seed, + ); + actions + .iter() + .filter_map(|a| ai_action_to_player_action(state, a)) + .collect() +} + fn parse_unit_id(unit_id: &str) -> Result { unit_id .parse::() @@ -2706,6 +2833,47 @@ mod tests { assert_ne!(seed_for_ai_turn(7, 1), seed_for_ai_turn(8, 1)); } + #[test] + fn suggest_actions_is_deterministic_and_non_mutating() { + // Stage 6.1.6 — `suggest_actions` must be a pure read: two calls + // on the same state produce an identical chain, and the state is + // byte-for-byte unchanged afterwards. + let state = make_state_with_units(vec![(0, 1, 0, 0), (1, 2, 5, 5)]); + // Snapshot via JSON dump (GameState has no PartialEq). + let before = serde_json::to_string(&state).unwrap(); + + let first = suggest_actions(&state, 1); + let after_first = serde_json::to_string(&state).unwrap(); + assert_eq!( + before, after_first, + "suggest_actions must not mutate GameState" + ); + + let second = suggest_actions(&state, 1); + let after_second = serde_json::to_string(&state).unwrap(); + assert_eq!( + before, after_second, + "a second suggest_actions call must not mutate GameState" + ); + + // Determinism: same state + slot → same chain. Compare via the + // PlayerAction wire JSON (PlayerAction has no PartialEq for the + // whole Vec in one shot, but the per-action JSON is canonical). + let first_json = serde_json::to_string(&first).unwrap(); + let second_json = serde_json::to_string(&second).unwrap(); + assert_eq!( + first_json, second_json, + "suggest_actions must be deterministic for a fixed (state, slot)" + ); + } + + #[test] + fn suggest_actions_out_of_range_slot_is_empty() { + let state = make_state_with_units(vec![(0, 1, 0, 0)]); + // Only slot 0 exists; slot 9 is out of range. + assert!(suggest_actions(&state, 9).is_empty()); + } + #[test] fn ai_siege_variants_are_silent_no_ops() { // DeploySiege / PackSiege / Bombard have no PlayerAction wire diff --git a/src/simulator/crates/mc-player-api/src/lib.rs b/src/simulator/crates/mc-player-api/src/lib.rs index 8cffbb6e..ab4e6115 100644 --- a/src/simulator/crates/mc-player-api/src/lib.rs +++ b/src/simulator/crates/mc-player-api/src/lib.rs @@ -29,7 +29,7 @@ pub use controllers::{ register_controller, registered_ids, AiController, AiControllerIdent, SandboxKind, ScriptedController, DEFAULT_CONTROLLER_ID, }; -pub use dispatch::{apply_action, apply_ai_action}; +pub use dispatch::{apply_action, apply_ai_action, suggest_actions}; pub use projection::{ project_tactical, project_tactical_with_vision, project_view, project_view_with_vision, }; diff --git a/src/simulator/crates/mc-player-api/src/wire.rs b/src/simulator/crates/mc-player-api/src/wire.rs index b506420a..91a966a4 100644 --- a/src/simulator/crates/mc-player-api/src/wire.rs +++ b/src/simulator/crates/mc-player-api/src/wire.rs @@ -43,6 +43,23 @@ pub enum Request { /// Action payload. action: PlayerAction, }, + /// Stage 6.1.6 — read-only "what would the in-box AI do here". Runs + /// the slot's scripted controller against the CURRENT state and + /// returns its action chain WITHOUT applying any action or advancing + /// the turn. Used by the behavioural-cloning recorder + /// (`tooling/rl_self_play/record_expert.py`) to harvest expert + /// `(observation, action)` demonstrations. Side-effect-free: two + /// `suggest` calls in a row return identical results and leave + /// `view` unchanged. + Suggest { + /// Optional correlation id. + #[serde(default, skip_serializing_if = "Option::is_none")] + id: RequestId, + /// Which player slot to compute the suggestion for. `None` ↔ + /// JSON-absent ↔ first entry of `CP_PLAYER_SLOTS`. + #[serde(default, skip_serializing_if = "Option::is_none")] + slot: Option, + }, /// Tear down the harness. Returns `{ok: true}` then closes stdin/stdout. Shutdown { /// Optional correlation id. @@ -363,6 +380,24 @@ mod tests { assert_eq!(req, back); } + #[test] + fn suggest_request_serialises_without_id_or_slot() { + let req = Request::Suggest { id: None, slot: None }; + let json = serde_json::to_string(&req).unwrap(); + assert_eq!(json, "{\"type\":\"suggest\"}"); + } + + #[test] + fn suggest_request_with_id_and_slot_round_trips() { + let req = Request::Suggest { id: Some(9), slot: Some(2) }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"type\":\"suggest\""), "json={}", json); + assert!(json.contains("\"id\":9"), "json={}", json); + assert!(json.contains("\"slot\":2"), "json={}", json); + let back: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(req, back); + } + #[test] fn ok_response_serialises_with_ok_true() { let resp = Response::Ok { diff --git a/src/simulator/crates/mc-player-api/tests/common/mod.rs b/src/simulator/crates/mc-player-api/tests/common/mod.rs index fb12508a..784d0d3a 100644 --- a/src/simulator/crates/mc-player-api/tests/common/mod.rs +++ b/src/simulator/crates/mc-player-api/tests/common/mod.rs @@ -212,6 +212,7 @@ pub fn build_runtime_units_catalog() -> UnitsCatalog { capturable: false, ransom_multiplier: 2.0, build_cost: 0, + logistics: None, }); cat.insert(UnitStats { id: "dwarf_founder".into(), @@ -221,6 +222,7 @@ pub fn build_runtime_units_catalog() -> UnitsCatalog { capturable: true, ransom_multiplier: 2.0, build_cost: 80, + logistics: None, }); cat }