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:
parent
e57d7ba40f
commit
d968ecb2ca
4 changed files with 206 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue