feat(@projects/@magic-civilization): ✨ implement action dispatcher logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
7d9a8f2b12
commit
3e4c37dfa9
3 changed files with 422 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
418
src/simulator/crates/mc-player-api/src/dispatch.rs
Normal file
418
src/simulator/crates/mc-player-api/src/dispatch.rs
Normal file
|
|
@ -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<Vec<Event>, 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<Vec<Event>, 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<u32, ActionError> {
|
||||
unit_id
|
||||
.parse::<u32>()
|
||||
.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<Vec<Event>, 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<Vec<Event>, 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<Vec<Event>, 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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue