feat(player-api): Add suggest module and wire protocol serialization for player API suggestions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-20 18:12:13 -07:00
parent e57d7ba40f
commit d968ecb2ca
4 changed files with 206 additions and 1 deletions

View file

@ -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<PlayerAction> {
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<PlayerAction> {
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<u32, ActionError> {
unit_id
.parse::<u32>()
@ -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

View file

@ -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,
};

View file

@ -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<PlayerId>,
},
/// 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 {

View file

@ -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
}