feat(@projects/@magic-civilization): implement action dispatcher logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-10 14:48:21 -07:00
parent 7d9a8f2b12
commit 3e4c37dfa9
3 changed files with 422 additions and 0 deletions

View file

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

View 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 { .. }));
}
}

View file

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