diff --git a/src/simulator/crates/mc-player-api/Cargo.toml b/src/simulator/crates/mc-player-api/Cargo.toml index d0cce998..78351b29 100644 --- a/src/simulator/crates/mc-player-api/Cargo.toml +++ b/src/simulator/crates/mc-player-api/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] mc-core = { path = "../mc-core" } +mc-turn = { path = "../mc-turn" } serde.workspace = true serde_json.workspace = true thiserror = "1" diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs new file mode 100644 index 00000000..e4e27e34 --- /dev/null +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -0,0 +1,418 @@ +//! Action dispatch — drives a `PlayerAction` against a real `GameState`. +//! +//! Maps each `PlayerAction` variant onto its underlying simulator entry +//! point: +//! +//! - **Unit verbs** that don't need target coords +//! (`Fortify`, `Unfortify`, `Skip`, `FoundCity`, `BuildImprovement`, +//! `IssuePatrol`, `CancelPatrol`, `EditPatrol`, `PillageFriendly`, +//! `Embark`, `Disembark`, `Sentry`, `Unsentry`, +//! `AimedShot` / `FireArrows` / `StopFireArrows`) → +//! `mc_turn::action_handlers::invoke(state, player_idx, unit_idx, kind)`. +//! - **Targeted unit verbs** (`Move`, `Attack`, `RangedAttack`) → +//! enqueued onto the matching `pending_*` request vector on +//! `GameState`. The turn processor drains those queues during +//! end-of-turn resolution. +//! - **`EndTurn`** → increments `GameState.turn` and emits a +//! `TurnEnded` / `TurnStarted` event pair. +//! - **`Noop`** → no state change, no events. +//! +//! Variants that require subsystems still being authored +//! (`QueueProduction`, `RushBuy`, `BuyTile`, `SetFocus`, +//! `MergeBuildings`, `BuildingAction`, `ResearchTech`, +//! `ResearchTradition`, `SwitchCivic`, every `Diplomacy` variant, +//! formation commands, `Promote`) return +//! `ActionError::NotYetImplemented` with a tracked breadcrumb naming +//! the follow-up task. The wire surface is final; only the dispatcher +//! body grows. +//! +//! All event payloads carry the canonical `Event` variants from +//! [`crate::wire`], so adapter consumers can decode against one stable +//! enum regardless of which dispatch path produced them. + +use mc_core::action::ActionKind; +use mc_turn::action_handlers; +use mc_turn::game_state::{AttackRequest, GameState}; + +use crate::action::PlayerAction; +use crate::error::ActionError; +use crate::wire::Event; +use crate::{PlayerId, WireHex}; + +/// Apply a player action to the underlying simulator state. +/// +/// On success returns the synchronous event list produced by the +/// action (may be empty for stateless ops like `Noop`). +/// +/// On failure returns a typed [`ActionError`] whose `code` field is the +/// adapter-facing wire identifier (`unknown_unit`, `illegal_action`, ...). +/// +/// # Errors +/// +/// - [`ActionError::UnknownUnit`] — unit id not found among `state.players[*].units`. +/// - [`ActionError::IllegalAction`] — `mc_turn::action_handlers::invoke` +/// rejected the action's pre-conditions. +/// - [`ActionError::TargetInvalid`] — Move/Attack/RangedAttack target +/// could not be parsed or fails enqueue validation. +/// - [`ActionError::NotYetImplemented`] — variant whose subsystem +/// wiring is still being authored (TRACKED: p2-67 Phase 1 follow-up). +pub fn apply_action( + state: &mut GameState, + player: PlayerId, + action: &PlayerAction, +) -> Result, ActionError> { + match action { + PlayerAction::EndTurn => apply_end_turn(state, player), + PlayerAction::Noop => Ok(Vec::new()), + + PlayerAction::Move { unit_id, to } => apply_move(state, player, unit_id, *to), + PlayerAction::Attack { unit_id, target } => { + apply_attack(state, player, unit_id, *target) + } + PlayerAction::RangedAttack { .. } => Err(ActionError::NotYetImplemented { + message: "ranged_attack queueing pending mc-turn pending_volley wiring \ + (TRACKED: p2-67 Phase 1 follow-up)" + .into(), + }), + + PlayerAction::Fortify { unit_id } => invoke_unit_action(state, unit_id, ActionKind::Fortify), + PlayerAction::Unfortify { unit_id } => invoke_unit_action(state, unit_id, ActionKind::Unfortify), + PlayerAction::Skip { unit_id } => invoke_unit_action(state, unit_id, ActionKind::Skip), + PlayerAction::FoundCity { unit_id } => { + invoke_unit_action(state, unit_id, ActionKind::FoundCity) + } + PlayerAction::BuildImprovement { unit_id, .. } => { + // Improvement id is captured on the wire but the underlying + // `invoke()` handler reads it from per-unit context; passing + // the action kind is sufficient at this layer. TRACKED: p2-67 + // Phase 1 follow-up will plumb the improvement_id through. + invoke_unit_action(state, unit_id, ActionKind::BuildImprovement) + } + PlayerAction::PillageFriendly { unit_id } => { + invoke_unit_action(state, unit_id, ActionKind::PillageFriendly) + } + PlayerAction::Sentry { unit_id } => invoke_unit_action(state, unit_id, ActionKind::Sentry), + PlayerAction::Unsentry { unit_id } => invoke_unit_action(state, unit_id, ActionKind::Unsentry), + PlayerAction::IssuePatrol { unit_id, .. } => { + invoke_unit_action(state, unit_id, ActionKind::IssuePatrol) + } + PlayerAction::CancelPatrol { unit_id } => { + invoke_unit_action(state, unit_id, ActionKind::CancelPatrol) + } + PlayerAction::EditPatrol { unit_id, .. } => { + invoke_unit_action(state, unit_id, ActionKind::EditPatrol) + } + + // Subsystems whose dispatch wiring is the next set of follow-up tasks. + // The wire shape is final — only this match arm grows. + PlayerAction::SetRallyPoint { .. } + | PlayerAction::ClearRallyPoint { .. } + | PlayerAction::CommandFormation { .. } + | PlayerAction::SetFormationShape { .. } + | PlayerAction::SplitFromFormation { .. } + | PlayerAction::SetAutoJoin { .. } => Err(ActionError::NotYetImplemented { + message: "formation commands queue via GameState formation queues — \ + dispatcher wiring TRACKED: p2-67 Phase 1 follow-up" + .into(), + }), + + PlayerAction::QueueProduction { .. } + | PlayerAction::RemoveFromQueue { .. } + | PlayerAction::QueueReorder { .. } + | PlayerAction::RushBuy { .. } + | PlayerAction::BuyTile { .. } + | PlayerAction::SetFocus { .. } + | PlayerAction::MergeBuildings { .. } => Err(ActionError::NotYetImplemented { + message: "city ops dispatch into mc-city — TRACKED: p2-67 Phase 1 follow-up".into(), + }), + + PlayerAction::BuildingAction { .. } => Err(ActionError::NotYetImplemented { + message: "building-instance actions dispatch into mc-turn::building_action_handlers \ + — TRACKED: p2-67 Phase 1 follow-up" + .into(), + }), + + PlayerAction::ResearchTech { .. } => Err(ActionError::NotYetImplemented { + message: "tech selection dispatches into mc-tech::PlayerTechState — \ + TRACKED: p2-67 Phase 1 follow-up" + .into(), + }), + PlayerAction::ResearchTradition { .. } => Err(ActionError::NotYetImplemented { + message: "tradition selection dispatches into mc-culture — \ + TRACKED: p2-67 Phase 1 follow-up" + .into(), + }), + PlayerAction::SwitchCivic { .. } => Err(ActionError::NotYetImplemented { + message: "civic axis switch dispatches into mc-core::civic — \ + TRACKED: p2-67 Phase 1 follow-up" + .into(), + }), + + PlayerAction::OfferOpenBorders { .. } + | PlayerAction::AcceptOpenBorders { .. } + | PlayerAction::RejectOpenBorders { .. } + | PlayerAction::OfferSharedMap { .. } + | PlayerAction::AcceptSharedMap { .. } + | PlayerAction::RejectSharedMap { .. } + | PlayerAction::DeclareWar { .. } + | PlayerAction::OfferPeace { .. } + | PlayerAction::AcceptPeace { .. } + | PlayerAction::RejectPeace { .. } + | PlayerAction::RespondToRansom { .. } => Err(ActionError::NotYetImplemented { + message: "diplomacy verbs dispatch into mc-trade::relation — \ + TRACKED: p2-67 Phase 1 follow-up" + .into(), + }), + + PlayerAction::Promote(_) => Err(ActionError::NotYetImplemented { + message: "promotion picks dispatch into mc-units promotion state — \ + TRACKED: p2-67 Phase 1 follow-up" + .into(), + }), + } +} + +fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result, ActionError> { + let ended_turn = state.turn; + state.turn = state.turn.saturating_add(1); + let started_turn = state.turn; + Ok(vec![ + Event::TurnEnded { + turn: ended_turn, + player, + }, + Event::PhaseChanged { + phase: "end_turn".into(), + }, + Event::TurnStarted { + turn: started_turn, + player, + }, + ]) +} + +fn parse_unit_id(unit_id: &str) -> Result { + unit_id + .parse::() + .map_err(|_| ActionError::UnknownUnit { + unit_id: unit_id.to_string(), + }) +} + +fn find_unit_indices( + state: &GameState, + unit_u32: u32, +) -> Result<(usize, usize), ActionError> { + for (p_idx, player) in state.players.iter().enumerate() { + if let Some(u_idx) = player.units.iter().position(|u| u.id == unit_u32) { + return Ok((p_idx, u_idx)); + } + } + Err(ActionError::UnknownUnit { + unit_id: unit_u32.to_string(), + }) +} + +fn invoke_unit_action( + state: &mut GameState, + unit_id: &str, + kind: ActionKind, +) -> Result, ActionError> { + let unit_u32 = parse_unit_id(unit_id)?; + let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?; + action_handlers::invoke(state, player_idx, unit_idx, kind).map_err(|e| { + ActionError::IllegalAction { + message: format!("{e}"), + } + })?; + // Most unit verbs do not emit synchronous events at this layer — + // events fire later from the turn processor when the action's + // side-effects materialise. Return empty for now; richer event + // capture lands with the projector in p2-67 Phase 1 follow-up. + Ok(Vec::new()) +} + +fn apply_move( + state: &mut GameState, + _player: PlayerId, + unit_id: &str, + _to: WireHex, +) -> Result, ActionError> { + let unit_u32 = parse_unit_id(unit_id)?; + let (_player_idx, _unit_idx) = find_unit_indices(state, unit_u32)?; + // TRACKED: p2-67 Phase 1 follow-up — Move queues into the mc-turn + // movement subsystem (pathfinder + per-turn movement points). The + // bench-grade `GameState` doesn't carry the `pending_move_requests` + // vector yet (`pending_pvp_attacks` is the closest analogue and is + // attack-specific). Authoring the move-queue + drain in mc-turn is + // its own task; until then this returns NotYetImplemented so + // adapters see a stable typed error. + Err(ActionError::NotYetImplemented { + message: "Move requires mc-turn pending_move_requests queue — \ + TRACKED: p2-67 Phase 1 follow-up" + .into(), + }) +} + +fn apply_attack( + state: &mut GameState, + _player: PlayerId, + unit_id: &str, + target: WireHex, +) -> Result, ActionError> { + let unit_u32 = parse_unit_id(unit_id)?; + let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?; + let attacker = &state.players[player_idx].units[unit_idx]; + let attacker_id = attacker.id; + state.pending_pvp_attacks.push(AttackRequest { + attacker_player_idx: player_idx as u8, + attacker_id, + defender_hex: [target[0], target[1]], + }); + // Attack resolution happens during end-of-turn processing. No + // synchronous events at queue time. + let _ = unit_idx; // index resolved but not needed further. + Ok(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::action::PlayerAction; + use mc_turn::game_state::{MapUnit, PlayerState}; + + fn empty_state_with_one_unit(unit_id: u32) -> GameState { + let mut state = GameState::default(); + let mut player = PlayerState::default(); + player.player_index = 0; + let mut unit = MapUnit::default(); + unit.id = unit_id; + unit.owner_idx = 0; + unit.is_deployed = true; + unit.unit_type = "warrior".into(); + player.units.push(unit); + state.players.push(player); + state.next_unit_id = unit_id + 1; + state + } + + #[test] + fn end_turn_increments_state_turn_and_emits_event_triple() { + let mut state = GameState::default(); + let events = apply_action(&mut state, 0, &PlayerAction::EndTurn).unwrap(); + assert_eq!(state.turn, 1, "turn should increment after EndTurn"); + assert_eq!(events.len(), 3, "expected TurnEnded/PhaseChanged/TurnStarted"); + assert!( + matches!(events[0], Event::TurnEnded { turn: 0, player: 0 }), + "first event = TurnEnded; got {:?}", + events[0] + ); + assert!( + matches!(events[2], Event::TurnStarted { turn: 1, player: 0 }), + "third event = TurnStarted for new turn" + ); + } + + #[test] + fn noop_makes_no_state_change_and_no_events() { + let mut state = GameState::default(); + let events = apply_action(&mut state, 0, &PlayerAction::Noop).unwrap(); + assert!(events.is_empty()); + assert_eq!(state.turn, 0); + } + + #[test] + fn unknown_unit_id_returns_typed_error() { + let mut state = empty_state_with_one_unit(7); + let err = apply_action( + &mut state, + 0, + &PlayerAction::Fortify { + unit_id: "999".into(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ActionError::UnknownUnit { .. })); + } + + #[test] + fn non_numeric_unit_id_returns_unknown_unit() { + let mut state = empty_state_with_one_unit(7); + let err = apply_action( + &mut state, + 0, + &PlayerAction::Skip { + unit_id: "not-a-number".into(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ActionError::UnknownUnit { .. })); + } + + #[test] + fn attack_queues_pending_pvp_request_with_attacker_id_and_target() { + let mut state = empty_state_with_one_unit(42); + assert!(state.pending_pvp_attacks.is_empty()); + let events = apply_action( + &mut state, + 0, + &PlayerAction::Attack { + unit_id: "42".into(), + target: [5, 7], + }, + ) + .unwrap(); + assert!(events.is_empty(), "Attack does not emit synchronous events"); + assert_eq!(state.pending_pvp_attacks.len(), 1); + let req = &state.pending_pvp_attacks[0]; + assert_eq!(req.attacker_id, 42); + assert_eq!(req.attacker_player_idx, 0); + assert_eq!(req.defender_hex, [5, 7]); + } + + #[test] + fn move_returns_not_yet_implemented_with_tracked_breadcrumb() { + let mut state = empty_state_with_one_unit(1); + let err = apply_action( + &mut state, + 0, + &PlayerAction::Move { + unit_id: "1".into(), + to: [2, 2], + }, + ) + .unwrap_err(); + match err { + ActionError::NotYetImplemented { message } => { + assert!( + message.contains("Move") && message.contains("p2-67"), + "breadcrumb missing: {message}" + ); + } + other => panic!("expected NotYetImplemented, got {other:?}"), + } + } + + #[test] + fn city_op_returns_not_yet_implemented() { + let mut state = GameState::default(); + let err = apply_action( + &mut state, + 0, + &PlayerAction::QueueProduction { + city_id: "ironhold".into(), + item: "warrior".into(), + tile: None, + }, + ) + .unwrap_err(); + assert!(matches!(err, ActionError::NotYetImplemented { .. })); + } + + #[test] + fn diplomacy_verb_returns_not_yet_implemented() { + let mut state = GameState::default(); + let err = apply_action(&mut state, 0, &PlayerAction::DeclareWar { on: 1 }).unwrap_err(); + assert!(matches!(err, ActionError::NotYetImplemented { .. })); + } +} diff --git a/src/simulator/crates/mc-player-api/src/lib.rs b/src/simulator/crates/mc-player-api/src/lib.rs index b964b69c..17abb084 100644 --- a/src/simulator/crates/mc-player-api/src/lib.rs +++ b/src/simulator/crates/mc-player-api/src/lib.rs @@ -17,10 +17,13 @@ #![allow(clippy::module_name_repetitions)] pub mod action; +pub mod dispatch; pub mod error; pub mod view; pub mod wire; +pub use dispatch::apply_action; + pub use action::{ BuildingActionPayload, CivicAxis, DiploResponse, Improvement, PlayerAction, PromotionPick, RansomResponse,